Przeglądaj źródła

fix(transition): handle dynamic slot update (#14634)

edison 1 miesiąc temu
rodzic
commit
74acb7dd0a

+ 157 - 0
packages/runtime-vapor/__tests__/components/Transition.spec.ts

@@ -118,6 +118,15 @@ describe('Transition', () => {
     expect(resolved.$key).toBe(child.uid)
   })
 
+  test('allows empty transition content', async () => {
+    const App = compile(`<template><Transition /></template>`, ref({}))
+    const { host } = define(App as any).render()
+
+    await nextTick()
+
+    expect(host.innerHTML).toBe('')
+  })
+
   test('direct child with initial hidden v-show should not trigger appear hooks', async () => {
     const { data, onBeforeAppear, onAppear } = createAppearTestState(false)
     const App = compile(
@@ -314,4 +323,152 @@ describe('Transition', () => {
     expect(onBeforeAppear).not.toHaveBeenCalled()
     expect(onAppear).not.toHaveBeenCalled()
   })
+
+  test('dynamic slot branch swaps preserve persisted hooks for slot-root v-show', async () => {
+    const data = ref({
+      branch: true,
+      show: true,
+    })
+    const Child = compile(`<template><slot /></template>`, data)
+    const App = compile(
+      `<template>
+        <Transition appear>
+          <template #default v-if="data.branch">
+            <components.Child>
+              <div v-show="data.show">foo</div>
+            </components.Child>
+          </template>
+          <template #default v-else>
+            <components.Child>
+              <div v-show="data.show">bar</div>
+            </components.Child>
+          </template>
+        </Transition>
+      </template>`,
+      data,
+      { Child },
+    )
+    const { host, instance } = define(App as any).render()
+    const transitionFragment = (instance!.block as any).block
+    const getTransitionOwner = () =>
+      transitionFragment.nodes?.$transition
+        ? transitionFragment.nodes
+        : transitionFragment.nodes?.block
+
+    await nextTick()
+
+    expect(getTransitionOwner()?.$transition?.persisted).toBe(true)
+
+    data.value.branch = false
+    await nextTick()
+
+    expect(host.textContent).toContain('bar')
+    expect(getTransitionOwner()?.$transition?.persisted).toBe(true)
+  })
+
+  test('dynamic default slot source should trigger enter hooks when toggled on', async () => {
+    const onBeforeEnter = vi.fn()
+    const onEnter = vi.fn()
+    const data = ref({
+      show: false,
+      onBeforeEnter,
+      onEnter,
+    })
+    const App = compile(
+      `<template>
+        <button @click="data.show = !data.show">toggle</button>
+        <Transition
+          @before-enter="data.onBeforeEnter"
+          @enter="data.onEnter"
+        >
+          <template #default v-if="data.show">
+            <div>foo</div>
+          </template>
+        </Transition>
+      </template>`,
+      data,
+    )
+    const { host } = define(App as any).render()
+
+    host.querySelector('button')!.click()
+    await nextTick()
+
+    expect(host.innerHTML).toContain(
+      '<div class="v-enter-from v-enter-active">foo</div>',
+    )
+    expect(onBeforeEnter).toHaveBeenCalledTimes(1)
+    expect(onEnter).toHaveBeenCalledTimes(1)
+  })
+
+  test('dynamic default slot source should trigger leave hooks when toggled off', async () => {
+    const onBeforeLeave = vi.fn()
+    const onLeave = vi.fn()
+    const data = ref({
+      show: true,
+      onBeforeLeave,
+      onLeave,
+    })
+    const App = compile(
+      `<template>
+        <button @click="data.show = !data.show">toggle</button>
+        <Transition
+          @before-leave="data.onBeforeLeave"
+          @leave="data.onLeave"
+        >
+          <template #default v-if="data.show">
+            <div>foo</div>
+          </template>
+        </Transition>
+      </template>`,
+      data,
+    )
+    const { host } = define(App as any).render()
+
+    host.querySelector('button')!.click()
+    await nextTick()
+
+    expect(host.innerHTML).toContain(
+      '<div class="v-leave-from v-leave-active">foo</div>',
+    )
+    expect(onBeforeLeave).toHaveBeenCalledTimes(1)
+    expect(onLeave).toHaveBeenCalledTimes(1)
+  })
+
+  test('dynamic default slot source should respect reactive mode changes', async () => {
+    const onLeave = vi.fn((_: Element, done: () => void) => setTimeout(done, 0))
+    const data = ref({
+      mode: 'default',
+      show: true,
+      onLeave,
+    })
+    const App = compile(
+      `<template>
+        <Transition :mode="data.mode" @leave="data.onLeave">
+          <template #default v-if="data.show">
+            <div>A</div>
+          </template>
+          <template #default v-else>
+            <div>B</div>
+          </template>
+        </Transition>
+      </template>`,
+      data,
+    )
+    const { host } = define(App as any).render()
+
+    data.value.mode = 'out-in'
+    await nextTick()
+
+    data.value.show = false
+    await nextTick()
+
+    expect(host.textContent).toContain('A')
+    expect(host.textContent).not.toContain('B')
+
+    await new Promise(r => setTimeout(r, 0))
+    await nextTick()
+
+    expect(host.textContent).toContain('B')
+    expect(onLeave).toHaveBeenCalledTimes(1)
+  })
 })

+ 103 - 54
packages/runtime-vapor/src/components/Transition.ts

@@ -22,6 +22,7 @@ import {
   useTransitionState,
   warn,
 } from '@vue/runtime-dom'
+import { computed } from '@vue/reactivity'
 import type {
   Block,
   TransitionBlock,
@@ -37,7 +38,7 @@ import {
 import { isArray } from '@vue/shared'
 import { renderEffect } from '../renderEffect'
 import {
-  type DynamicFragment,
+  DynamicFragment,
   ForFragment,
   type VaporFragment,
   isFragment,
@@ -126,67 +127,65 @@ export const VaporTransition: FunctionalVaporComponent<TransitionProps> =
 
     const performAppear = isHydrating ? hydrateTransitionImpl() : undefined
     const state = useTransitionState()
-
-    let resolvedProps: BaseTransitionProps<Element>
-    renderEffect(() => (resolvedProps = resolveTransitionProps(props)))
-
-    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)
-
-    const { hooks, root } = applyResolvedTransitionHooks(children, {
-      state,
-      // use proxy to keep props reference stable
-      props: new Proxy({} as BaseTransitionProps<Element>, {
-        get(_, key) {
-          return resolvedProps[key as keyof BaseTransitionProps<Element>]
-        },
-      }),
-      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,
+    __DEV__ && checkTransitionMode(mode)
+
+    const resolvedProps = computed(() => resolveTransitionProps(props))
+    const propsProxy = new Proxy({} as BaseTransitionProps<Element>, {
+      get(_, key) {
+        return resolvedProps.value[key as keyof BaseTransitionProps<Element>]
+      },
+    })
+
+    const shouldCaptureVShow = !isHydrating && !!props.appear
+    const shouldPerformAppear = !!props.appear && !!performAppear
+    // Dynamic slot sources can add/remove the default slot after setup, so
+    // Transition needs a DynamicFragment to drive enter/leave on updates.
+    if (instance.rawSlots.$) {
+      const frag = new DynamicFragment('transition')
+      let isMounted = false
+      renderEffect(() => {
+        if (!frag.$transition) {
+          frag.$transition = resolveTransitionHooks(
+            frag,
+            propsProxy,
+            state,
+            instance,
           )
-      }
-
-      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()
+        } else {
+          // DynamicFragment.update() reads the fragment hook's mode directly,
+          // so keep it in sync when Transition mode changes reactively.
+          frag.$transition.mode = resolvedProps.value.mode
         }
-        pendingVShows.length = 0
+        const [, pendingVShows] = capturePendingVShows(
+          shouldCaptureVShow && !isMounted,
+          () => frag.update(slots.default),
+        )
+        applyPendingVShows(
+          frag.$transition!,
+          resolveTransitionBlock(frag.nodes),
+          pendingVShows,
+        )
+        if (!isMounted && shouldPerformAppear) performAppear(frag.$transition!)
+        isMounted = true
       })
+      return frag
     }
 
-    if (resolvedProps!.appear && performAppear) {
-      performAppear(hooks)
-    }
+    const [children, pendingVShows] = capturePendingVShows(
+      shouldCaptureVShow,
+      () => ((slots.default && slots.default()) || []) as any as Block,
+    )
 
+    const { hooks, root } = applyResolvedTransitionHooks(children, {
+      state,
+      // use proxy to keep props reference stable
+      props: propsProxy,
+      instance: instance,
+    } as VaporTransitionHooks)
+    applyPendingVShows(hooks, root, pendingVShows)
+    if (shouldPerformAppear) performAppear(hooks)
     return children
   })
 
@@ -354,6 +353,9 @@ function applyResolvedTransitionHooks(
     instance,
     hooks => (resolvedHooks = hooks as VaporTransitionHooks),
   )
+  // Dynamic slot updates replace the active hook object. Preserve any
+  // runtime-derived persisted state for slot/component-root v-show.
+  resolvedHooks.persisted = resolvedHooks.persisted || hooks.persisted
   resolvedHooks.delayedLeave = delayedLeave
   child.$transition = resolvedHooks
   fragments.forEach(f => (f.$transition = resolvedHooks))
@@ -534,3 +536,50 @@ export function getTransitionElementFromVNode(
     return getTransitionElementFromVNode(children[0])
   }
 }
+
+function capturePendingVShows<T>(
+  enabled: boolean,
+  render: () => T,
+): [block: T, pendingVShows: PendingVShow[] | undefined] {
+  if (!enabled) {
+    return [render(), undefined]
+  }
+
+  const pendingVShows: PendingVShow[] = []
+  const prev = setCurrentPendingVShows(pendingVShows)
+  try {
+    return [render(), pendingVShows]
+  } finally {
+    setCurrentPendingVShows(prev)
+  }
+}
+
+function applyPendingVShows(
+  hooks: VaporTransitionHooks,
+  root: ResolvedTransitionBlock | undefined,
+  pendingVShows: PendingVShow[] | undefined,
+): void {
+  if (!pendingVShows) return
+
+  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
+  })
+}