Bläddra i källkod

fix(transition): handle transition appear with slotted v-show at runtime (#14632)

edison 1 månad sedan
förälder
incheckning
4f5ad60edc

+ 82 - 0
packages-private/vapor-e2e-test/__tests__/transition.spec.ts

@@ -1648,6 +1648,88 @@ describe('vapor transition', () => {
       E2E_TIMEOUT,
     )
 
+    test(
+      'reusable transition slot v-show on appear',
+      async () => {
+        const btnSelector = '.show-appear-reusable-slot > button'
+        const containerSelector = '.show-appear-reusable-slot > div'
+        const childSelector = `${containerSelector} > div`
+        const hiddenBtnSelector =
+          '.show-appear-not-enter-reusable-slot > button'
+        const hiddenContainerSelector =
+          '.show-appear-not-enter-reusable-slot > div'
+        const hiddenChildSelector = `${hiddenContainerSelector} > div`
+
+        let calls = (window as any).getCalls()
+        expect(calls).toStrictEqual(['beforeEnter', 'onEnter'])
+        await expect.element(css(hiddenChildSelector)).not.toBeVisible()
+
+        await expect
+          .element(css(childSelector))
+          .toHaveClass('test-appear-active')
+        await transitionFinish()
+        await expect
+          .element(css(containerSelector))
+          .toContainHTML('<div class="test">content</div>')
+        calls = (window as any).getCalls()
+        expect(calls).toStrictEqual(['beforeEnter', 'onEnter', 'afterEnter'])
+
+        click(btnSelector)
+        await nextTick()
+        await nextFrame()
+        expect(html(containerSelector)).toContain(
+          '<div class="test test-leave-from test-leave-active">content</div>',
+        )
+        await nextFrame()
+        expect(html(containerSelector)).toContain(
+          '<div class="test test-leave-active test-leave-to">content</div>',
+        )
+        await transitionFinish()
+        await expect
+          .element(css(containerSelector))
+          .toContainHTML(
+            '<div class="test" style="display: none;">content</div>',
+          )
+
+        click(btnSelector)
+        await nextTick()
+        await nextFrame()
+        expect(html(containerSelector)).toContain(
+          '<div class="test test-enter-from test-enter-active" style="">content</div>',
+        )
+        await nextFrame()
+        expect(html(containerSelector)).toContain(
+          '<div class="test test-enter-active test-enter-to" style="">content</div>',
+        )
+        await transitionFinish()
+        await expect
+          .element(css(containerSelector))
+          .toContainHTML('<div class="test" style="">content</div>')
+
+        ;(window as any).resetCalls()
+        click(hiddenBtnSelector)
+        await nextTick()
+        await nextFrame()
+        expect(html(hiddenContainerSelector)).toContain(
+          '<div class="test test-enter-from test-enter-active" style="">content</div>',
+        )
+        await nextFrame()
+        expect(html(hiddenContainerSelector)).toContain(
+          '<div class="test test-enter-active test-enter-to" style="">content</div>',
+        )
+        calls = (window as any).getCalls()
+        expect(calls).toStrictEqual(['beforeEnter', 'onEnter'])
+        expect(calls).not.contain('afterEnter')
+        await transitionFinish()
+        await expect
+          .element(css(hiddenContainerSelector))
+          .toContainHTML('<div class="test" style="">content</div>')
+        calls = (window as any).getCalls()
+        expect(calls).toStrictEqual(['beforeEnter', 'onEnter', 'afterEnter'])
+      },
+      E2E_TIMEOUT,
+    )
+
     test(
       'transition events should not call onEnter with v-show false',
       async () => {

+ 51 - 0
packages-private/vapor-e2e-test/transition/cases/transition-with-v-show/reusable-transition-slot-v-show-on-appear.vue

@@ -0,0 +1,51 @@
+<script setup vapor>
+import {
+  createComponent,
+  defineVaporComponent,
+  ref,
+  VaporTransition,
+} from 'vue'
+
+const shown = ref(true)
+const hidden = ref(false)
+const calls = []
+window.getCalls = () => calls
+window.resetCalls = () => calls.splice(0, calls.length)
+
+const MyTransition = defineVaporComponent((props, { slots }) => {
+  return createComponent(
+    VaporTransition,
+    {
+      name: () => 'test',
+      appear: () => true,
+      appearFromClass: () => 'test-appear-from',
+      appearToClass: () => 'test-appear-to',
+      appearActiveClass: () => 'test-appear-active',
+      onBeforeEnter: () => () => calls.push('beforeEnter'),
+      onEnter: () => () => calls.push('onEnter'),
+      onAfterEnter: () => () => calls.push('afterEnter'),
+    },
+    slots,
+  )
+})
+</script>
+
+<template>
+  <div class="show-appear-reusable-slot">
+    <div>
+      <MyTransition>
+        <div v-show="shown" class="test">content</div>
+      </MyTransition>
+    </div>
+    <button @click="shown = !shown">button</button>
+  </div>
+
+  <div class="show-appear-not-enter-reusable-slot">
+    <div>
+      <MyTransition>
+        <div v-show="hidden" class="test">content</div>
+      </MyTransition>
+    </div>
+    <button @click="hidden = !hidden">button</button>
+  </div>
+</template>

+ 1 - 3
packages/compiler-vapor/__tests__/transforms/__snapshots__/TransformTransition.spec.ts.snap

@@ -71,18 +71,16 @@ exports[`compiler: transition > v-show + appear 1`] = `
 const t0 = _template("<h1>foo")
 
 export function render(_ctx) {
-  const deferredApplyVShows = []
   const n1 = _createComponent(_VaporTransition, {
     appear: () => (""),
     persisted: () => ("")
   }, {
     "default": () => {
       const n0 = t0()
-      deferredApplyVShows.push(() => _applyVShow(n0, () => (_ctx.show)))
+      _applyVShow(n0, () => (_ctx.show))
       return n0
     }
   }, true)
-  deferredApplyVShows.forEach(fn => fn())
   return n1
 }"
 `;

+ 0 - 3
packages/compiler-vapor/src/generate.ts

@@ -201,9 +201,6 @@ export function generate(
       `const ${setTemplateRefIdent} = ${context.helper('createTemplateRefSetter')}()`,
     )
   }
-  if (ir.hasDeferredVShow) {
-    push(NEWLINE, `const deferredApplyVShows = []`)
-  }
   push(...genBlockContent(ir.block, context, true))
   push(INDENT_END, NEWLINE)
 

+ 0 - 4
packages/compiler-vapor/src/generators/block.ts

@@ -73,10 +73,6 @@ export function genBlockContent(
   push(...genOperations(operation, context))
   push(...genEffects(effect, context, genEffectsExtraFrag))
 
-  if (root && context.ir.hasDeferredVShow) {
-    push(NEWLINE, `deferredApplyVShows.forEach(fn => fn())`)
-  }
-
   push(NEWLINE, `return `)
 
   const returnNodes = returns.map(n => `n${n}`)

+ 1 - 3
packages/compiler-vapor/src/generators/vShow.ts

@@ -7,15 +7,13 @@ export function genVShow(
   oper: DirectiveIRNode,
   context: CodegenContext,
 ): CodeFragment[] {
-  const { deferred, element } = oper
+  const { element } = oper
   return [
     NEWLINE,
-    deferred ? `deferredApplyVShows.push(() => ` : undefined,
     ...genCall(context.helper('applyVShow'), `n${element}`, [
       `() => (`,
       ...genExpression(oper.dir.exp!, context),
       `)`,
     ]),
-    deferred ? `)` : undefined,
   ]
 }

+ 0 - 2
packages/compiler-vapor/src/ir/index.ts

@@ -67,7 +67,6 @@ export interface RootIRNode {
   directive: Set<string>
   block: BlockIRNode
   hasTemplateRef: boolean
-  hasDeferredVShow: boolean
 }
 
 export interface IfIRNode extends BaseIRNode {
@@ -210,7 +209,6 @@ export interface DirectiveIRNode extends BaseIRNode {
   builtin?: boolean
   asset?: boolean
   modelType?: 'text' | 'dynamic' | 'radio' | 'checkbox' | 'select'
-  deferred?: boolean
 }
 
 export interface CreateComponentIRNode extends BaseIRNode {

+ 0 - 1
packages/compiler-vapor/src/transform.ts

@@ -329,7 +329,6 @@ export function transform(
     directive: new Set(),
     block: newBlock(node),
     hasTemplateRef: false,
-    hasDeferredVShow: false,
   }
 
   const context = new TransformContext(ir, node, options)

+ 0 - 17
packages/compiler-vapor/src/transforms/vShow.ts

@@ -2,13 +2,11 @@ import {
   DOMErrorCodes,
   ElementTypes,
   ErrorCodes,
-  NodeTypes,
   createCompilerError,
   createDOMCompilerError,
 } from '@vue/compiler-dom'
 import type { DirectiveTransform } from '../transform'
 import { IRNodeTypes } from '../ir'
-import { findProp, isTransitionTag } from '../utils'
 
 export const transformVShow: DirectiveTransform = (dir, node, context) => {
   const { exp, loc } = dir
@@ -29,26 +27,11 @@ export const transformVShow: DirectiveTransform = (dir, node, context) => {
     return
   }
 
-  // lazy apply vshow if the node is inside a transition with appear
-  let shouldDeferred = false
-  const parentNode = context.parent && context.parent.node
-  if (parentNode && parentNode.type === NodeTypes.ELEMENT) {
-    shouldDeferred = !!(
-      isTransitionTag(parentNode.tag) &&
-      findProp(parentNode, 'appear', false, true)
-    )
-
-    if (shouldDeferred) {
-      context.ir.hasDeferredVShow = true
-    }
-  }
-
   context.registerOperation({
     type: IRNodeTypes.DIRECTIVE,
     element: context.reference(),
     dir,
     name: 'show',
     builtin: true,
-    deferred: shouldDeferred,
   })
 }

+ 219 - 1
packages/runtime-vapor/__tests__/components/Transition.spec.ts

@@ -5,10 +5,31 @@ import {
   template,
 } from '../../src'
 import { resolveTransitionBlock } from '../../src/components/Transition'
-import { makeRender } from '../_utils'
+import { nextTick, ref } from 'vue'
+import { compile, makeRender } from '../_utils'
 
 const define = makeRender()
 
+function createAppearTestState(
+  show: boolean,
+  extraState: Record<string, any> = {},
+) {
+  const onBeforeAppear = vi.fn()
+  const onAppear = vi.fn()
+  const data = ref({
+    show,
+    ...extraState,
+    onBeforeAppear,
+    onAppear,
+  })
+
+  return {
+    data,
+    onBeforeAppear,
+    onAppear,
+  }
+}
+
 describe('Transition', () => {
   test('prefers explicit component key over uid when resolving child', () => {
     const Child = defineVaporComponent({
@@ -96,4 +117,201 @@ describe('Transition', () => {
     const resolved = resolveTransitionBlock(child)!
     expect(resolved.$key).toBe(child.uid)
   })
+
+  test('direct child with initial hidden v-show should not trigger appear hooks', async () => {
+    const { data, onBeforeAppear, onAppear } = createAppearTestState(false)
+    const App = compile(
+      `<template>
+        <Transition
+          appear
+          @before-appear="data.onBeforeAppear"
+          @appear="data.onAppear"
+        >
+          <div v-show="data.show">foo</div>
+        </Transition>
+      </template>`,
+      data,
+    )
+    const { host } = define(App as any).render()
+
+    await nextTick()
+
+    expect(host.querySelector('div')?.style.display).toBe('none')
+    expect(onBeforeAppear).not.toHaveBeenCalled()
+    expect(onAppear).not.toHaveBeenCalled()
+  })
+
+  test('direct child with initial shown v-show should trigger appear hooks once', async () => {
+    const { data, onBeforeAppear, onAppear } = createAppearTestState(true)
+    const App = compile(
+      `<template>
+        <Transition
+          appear
+          @before-appear="data.onBeforeAppear"
+          @appear="data.onAppear"
+        >
+          <div v-show="data.show">foo</div>
+        </Transition>
+      </template>`,
+      data,
+    )
+    const { host } = define(App as any).render()
+
+    await nextTick()
+
+    expect(host.querySelector('div')?.style.display).toBe('')
+    expect(onBeforeAppear).toHaveBeenCalledTimes(1)
+    expect(onAppear).toHaveBeenCalledTimes(1)
+  })
+
+  test('direct slot child with initial hidden v-show should not trigger appear hooks', async () => {
+    const { data, onBeforeAppear, onAppear } = createAppearTestState(false)
+    const Child = compile(`<template><slot /></template>`, data)
+    const App = compile(
+      `<template>
+        <Transition
+          appear
+          @before-appear="data.onBeforeAppear"
+          @appear="data.onAppear"
+        >
+          <components.Child>
+            <div v-show="data.show">foo</div>
+          </components.Child>
+        </Transition>
+      </template>`,
+      data,
+      { Child },
+    )
+    const { host } = define(App as any).render()
+
+    await nextTick()
+
+    expect(host.querySelector('div')?.style.display).toBe('none')
+    expect(onBeforeAppear).not.toHaveBeenCalled()
+    expect(onAppear).not.toHaveBeenCalled()
+  })
+
+  test('direct slot child with initial shown v-show should trigger appear hooks once', async () => {
+    const { data, onBeforeAppear, onAppear } = createAppearTestState(true)
+    const Child = compile(`<template><slot /></template>`, data)
+    const App = compile(
+      `<template>
+        <Transition
+          appear
+          @before-appear="data.onBeforeAppear"
+          @appear="data.onAppear"
+        >
+          <components.Child>
+            <div v-show="data.show">foo</div>
+          </components.Child>
+        </Transition>
+      </template>`,
+      data,
+      { Child },
+    )
+    const { host } = define(App as any).render()
+
+    await nextTick()
+
+    expect(host.querySelector('div')?.style.display).toBe('')
+    expect(onBeforeAppear).toHaveBeenCalledTimes(1)
+    expect(onAppear).toHaveBeenCalledTimes(1)
+  })
+
+  test('forwarded slot child with initial hidden v-show should not trigger appear hooks', async () => {
+    const { data, onBeforeAppear, onAppear } = createAppearTestState(false)
+    const Inner = compile(`<template><slot /></template>`, data)
+    const Child = compile(
+      `<template><components.Inner><slot /></components.Inner></template>`,
+      data,
+      { Inner },
+    )
+    const App = compile(
+      `<template>
+        <Transition
+          appear
+          @before-appear="data.onBeforeAppear"
+          @appear="data.onAppear"
+        >
+          <components.Child>
+            <div v-show="data.show">foo</div>
+          </components.Child>
+        </Transition>
+      </template>`,
+      data,
+      { Child, Inner },
+    )
+    const { host } = define(App as any).render()
+
+    await nextTick()
+
+    expect(host.querySelector('div')?.style.display).toBe('none')
+    expect(onBeforeAppear).not.toHaveBeenCalled()
+    expect(onAppear).not.toHaveBeenCalled()
+  })
+
+  test('forwarded slot child with initial shown v-show should trigger appear hooks once', async () => {
+    const { data, onBeforeAppear, onAppear } = createAppearTestState(true)
+    const Inner = compile(`<template><slot /></template>`, data)
+    const Child = compile(
+      `<template><components.Inner><slot /></components.Inner></template>`,
+      data,
+      { Inner },
+    )
+    const App = compile(
+      `<template>
+        <Transition
+          appear
+          @before-appear="data.onBeforeAppear"
+          @appear="data.onAppear"
+        >
+          <components.Child>
+            <div v-show="data.show">foo</div>
+          </components.Child>
+        </Transition>
+      </template>`,
+      data,
+      { Child, Inner },
+    )
+    const { host } = define(App as any).render()
+
+    await nextTick()
+
+    expect(host.querySelector('div')?.style.display).toBe('')
+    expect(onBeforeAppear).toHaveBeenCalledTimes(1)
+    expect(onAppear).toHaveBeenCalledTimes(1)
+  })
+
+  test('slotted component with dynamic fragment root and initial hidden v-show should not trigger appear hooks', async () => {
+    const { data, onBeforeAppear, onAppear } = createAppearTestState(false, {
+      ok: true,
+    })
+    const Child = compile(`<template><slot /></template>`, data)
+    const Inner = compile(
+      `<template><div v-if="data.ok">foo</div><span v-else>foo</span></template>`,
+      data,
+    )
+    const App = compile(
+      `<template>
+        <Transition
+          appear
+          @before-appear="data.onBeforeAppear"
+          @appear="data.onAppear"
+        >
+          <components.Child>
+            <components.Inner v-show="data.show" />
+          </components.Child>
+        </Transition>
+      </template>`,
+      data,
+      { Child, Inner },
+    )
+    const { host } = define(App as any).render()
+
+    await nextTick()
+
+    expect(host.querySelector('div')?.style.display).toBe('none')
+    expect(onBeforeAppear).not.toHaveBeenCalled()
+    expect(onAppear).not.toHaveBeenCalled()
+  })
 })

+ 54 - 1
packages/runtime-vapor/__tests__/hydration.spec.ts

@@ -3274,7 +3274,60 @@ describe('Vapor Mode hydration', () => {
         data,
       )
       expect(formatHtml(container.innerHTML)).toMatchInlineSnapshot(
-        `"<div style="display:none;" class="v-enter-from v-enter-active v-leave-from v-leave-active">foo</div>"`,
+        `"<div style="display:none;" class="v-enter-from v-enter-active">foo</div>"`,
+      )
+      expect(`mismatch`).not.toHaveBeenWarned()
+    })
+
+    test('transition appear with slotted v-show', async () => {
+      const data = ref(false)
+      const { container } = await testHydration(
+        `<template>
+          <transition appear>
+            <components.Child>
+              <div v-show="data">foo</div>
+            </components.Child>
+          </transition>
+        </template>`,
+        {
+          Child: `<template><slot /></template>`,
+        },
+        data,
+      )
+      expect(formatHtml(container.innerHTML)).toMatchInlineSnapshot(
+        `
+      	"
+      	<!--[--><div style="display:none;" class="v-enter-from v-enter-active">foo</div><!--]-->
+      	"
+      `,
+      )
+      expect(`mismatch`).not.toHaveBeenWarned()
+    })
+
+    test('transition appear with forwarded slotted v-show', async () => {
+      const data = ref(false)
+      const { container } = await testHydration(
+        `<template>
+          <transition appear>
+            <components.Parent>
+              <div v-show="data">foo</div>
+            </components.Parent>
+          </transition>
+        </template>`,
+        {
+          Parent: `<template><components.Child><slot /></components.Child></template>`,
+          Child: `<template><slot /></template>`,
+        },
+        data,
+      )
+      expect(formatHtml(container.innerHTML)).toMatchInlineSnapshot(
+        `
+      	"
+      	<!--[-->
+      	<!--[--><div style="display:none;" class="v-enter-from v-enter-active">foo</div><!--]-->
+      	<!--]-->
+      	"
+      `,
       )
       expect(`mismatch`).not.toHaveBeenWarned()
     })

+ 88 - 21
packages/runtime-vapor/src/components/Transition.ts

@@ -16,6 +16,7 @@ import {
   isAsyncWrapper,
   isTemplateNode,
   leaveCbKey,
+  onBeforeMount,
   queuePostFlushCb,
   resolveTransitionProps,
   useTransitionState,
@@ -46,6 +47,7 @@ import {
   isHydrating,
   setCurrentHydrationNode,
 } from '../dom/hydration'
+import { type PendingVShow, setCurrentPendingVShows } from '../directives/vShow'
 import { isInteropEnabled } from '../vdomInteropState'
 
 const displayName = 'VaporTransition'
@@ -70,22 +72,38 @@ export const ensureTransitionHooksRegistered = (): void => {
 const hydrateTransitionImpl = () => {
   if (!currentHydrationNode || !isTemplateNode(currentHydrationNode)) return
   // replace <template> node with inner child
-  const {
-    content: { firstChild },
-    parentNode,
-  } = currentHydrationNode
+  const { content, parentNode } = currentHydrationNode
+  const { firstChild } = content
   if (firstChild) {
-    parentNode!.replaceChild(firstChild, currentHydrationNode)
+    let transitionEl: Element | undefined
+    // firstChild may be a fragment anchor comment (e.g. <!--[--> from slotted
+    // content), but appear hooks still need to target the actual element.
+    for (
+      let node: ChildNode | null = firstChild;
+      node;
+      node = node.nextSibling
+    ) {
+      if (node instanceof Element) {
+        transitionEl = node
+        break
+      }
+    }
+
+    parentNode!.insertBefore(content, currentHydrationNode)
+    parentNode!.removeChild(currentHydrationNode)
     setCurrentHydrationNode(firstChild)
 
-    if (firstChild instanceof HTMLElement || firstChild instanceof SVGElement) {
-      const originalDisplay = firstChild.style.display
-      firstChild.style.display = 'none'
+    if (
+      transitionEl instanceof HTMLElement ||
+      transitionEl instanceof SVGElement
+    ) {
+      const originalDisplay = transitionEl.style.display
+      transitionEl.style.display = 'none'
 
       return (hooks: TransitionHooks) => {
-        hooks.beforeEnter(firstChild)
-        firstChild.style.display = originalDisplay
-        queuePostFlushCb(() => hooks.enter(firstChild))
+        hooks.beforeEnter(transitionEl)
+        transitionEl.style.display = originalDisplay
+        queuePostFlushCb(() => hooks.enter(transitionEl))
       }
     }
   }
@@ -107,19 +125,31 @@ export const VaporTransition: FunctionalVaporComponent<TransitionProps> =
     ensureTransitionHooksRegistered()
 
     const performAppear = isHydrating ? hydrateTransitionImpl() : undefined
+    const state = useTransitionState()
+
+    let resolvedProps: BaseTransitionProps<Element>
+    renderEffect(() => (resolvedProps = resolveTransitionProps(props)))
 
-    const children = (slots.default && slots.default()) as any as Block
+    let pendingVShows: PendingVShow[] | undefined
+    let children: Block
+    if (!isHydrating && resolvedProps!.appear) {
+      const prev = setCurrentPendingVShows((pendingVShows = []))
+      try {
+        children = (slots.default && slots.default()) as any as Block
+      } finally {
+        setCurrentPendingVShows(prev)
+      }
+    } else {
+      children = (slots.default && slots.default()) as any as Block
+    }
     if (!children) return []
 
     const instance = currentInstance! as VaporComponentInstance
     const { mode } = props
     checkTransitionMode(mode)
 
-    let resolvedProps: BaseTransitionProps<Element>
-    renderEffect(() => (resolvedProps = resolveTransitionProps(props)))
-
-    const hooks = applyTransitionHooksImpl(children, {
-      state: useTransitionState(),
+    const { hooks, root } = applyResolvedTransitionHooks(children, {
+      state,
       // use proxy to keep props reference stable
       props: new Proxy({} as BaseTransitionProps<Element>, {
         get(_, key) {
@@ -129,6 +159,30 @@ export const VaporTransition: FunctionalVaporComponent<TransitionProps> =
       instance: instance,
     } as VaporTransitionHooks)
 
+    if (pendingVShows) {
+      if (root) {
+        // Keep compiler-injected persisted for direct v-show children, and
+        // additionally treat slot/component roots as persisted when their
+        // deferred v-show target resolves to the same transition root.
+        hooks.persisted =
+          hooks.persisted ||
+          pendingVShows.some(
+            pending =>
+              pending.target === root ||
+              resolveTransitionBlock(pending.target) === root,
+          )
+      }
+
+      onBeforeMount(() => {
+        // Flush the deferred initial v-show writes right before mount so the
+        // DOM is still not inserted, but transition hooks are already ready.
+        for (const pending of pendingVShows) {
+          pending.setDisplay()
+        }
+        pendingVShows.length = 0
+      })
+    }
+
     if (resolvedProps!.appear && performAppear) {
       performAppear(hooks)
     }
@@ -254,20 +308,30 @@ function applyTransitionHooksImpl(
   block: Block,
   hooks: VaporTransitionHooks,
 ): VaporTransitionHooks {
+  return applyResolvedTransitionHooks(block, hooks).hooks
+}
+
+function applyResolvedTransitionHooks(
+  block: Block,
+  hooks: VaporTransitionHooks,
+): {
+  hooks: VaporTransitionHooks
+  root?: ResolvedTransitionBlock
+} {
   // filter out comment nodes
   if (isArray(block)) {
     block = block.filter(b => !(b instanceof Comment))
     if (block.length === 1) {
       block = block[0]
     } else if (block.length === 0) {
-      return hooks
+      return { hooks }
     }
   }
 
   // delegate to TransitionGroup's apply logic for list children
   if (hooks.applyGroup && block instanceof ForFragment) {
     hooks.applyGroup(block, hooks.props, hooks.state, hooks.instance)
-    return hooks
+    return { hooks }
   }
 
   const fragments: VaporFragment[] = []
@@ -279,7 +343,7 @@ function applyTransitionHooksImpl(
     if (__DEV__ && fragments.length === 0) {
       warn('Transition component has no valid child element')
     }
-    return hooks
+    return { hooks }
   }
 
   const { props, instance, state, delayedLeave } = hooks
@@ -294,7 +358,10 @@ function applyTransitionHooksImpl(
   child.$transition = resolvedHooks
   fragments.forEach(f => (f.$transition = resolvedHooks))
 
-  return resolvedHooks
+  return {
+    hooks: resolvedHooks,
+    root: child,
+  }
 }
 
 function applyTransitionLeaveHooksImpl(

+ 31 - 1
packages/runtime-vapor/src/directives/vShow.ts

@@ -13,6 +13,23 @@ import { isArray } from '@vue/shared'
 import { isHydrating, logMismatchError } from '../dom/hydration'
 import { DynamicFragment, VaporFragment, isFragment } from '../fragment'
 
+export interface PendingVShow {
+  target: Block
+  setDisplay: () => void
+}
+
+export let currentPendingVShows: PendingVShow[] | null = null
+
+export function setCurrentPendingVShows(
+  pending: PendingVShow[] | null,
+): PendingVShow[] | null {
+  try {
+    return currentPendingVShows
+  } finally {
+    currentPendingVShows = pending
+  }
+}
+
 export function applyVShow(target: Block, source: () => any): void {
   if (isVaporComponent(target)) {
     return applyVShow(target.block, source)
@@ -36,7 +53,20 @@ export function applyVShow(target: Block, source: () => any): void {
     }
   }
 
-  renderEffect(() => setDisplay(target, source()))
+  renderEffect(() => {
+    const value = source()
+    if (currentPendingVShows) {
+      // Inside Transition appear, target.$transition is not assigned yet.
+      // Defer the initial setDisplay until Transition beforeMount so it can
+      // enter through the transition-aware branch.
+      currentPendingVShows.push({
+        target,
+        setDisplay: () => setDisplay(target, value),
+      })
+      return
+    }
+    setDisplay(target, value)
+  })
 }
 
 function setDisplay(target: Block, value: unknown): void {