]> git.ipfire.org Git - thirdparty/vuejs/core.git/commitdiff
fix(keep-alive): fix caching nested dynamic fragments (#14307)
authoredison <daiwei521@126.com>
Wed, 14 Jan 2026 01:28:29 +0000 (09:28 +0800)
committerGitHub <noreply@github.com>
Wed, 14 Jan 2026 01:28:29 +0000 (09:28 +0800)
packages/runtime-vapor/__tests__/components/KeepAlive.spec.ts
packages/runtime-vapor/src/component.ts
packages/runtime-vapor/src/components/KeepAlive.ts
packages/runtime-vapor/src/hmr.ts

index 25a23909544ed88a784e83bc7040c2d1ec1a8a18..023eb7b9e0a3fc7778b8d4fb25fdfb38dddf0eea 100644 (file)
@@ -486,6 +486,241 @@ describe('VaporKeepAlive', () => {
     assertHookCalls(twoHooks, [1, 1, 4, 4, 0]) // should remain inactive
   })
 
+  test('should cache components in nested DynamicFragment (v-if > dynamic component)', async () => {
+    const outerIf = ref(true)
+    const viewRef = ref('one')
+
+    const { html } = define({
+      setup() {
+        return createComponent(VaporKeepAlive, null, {
+          default: () =>
+            createIf(
+              () => outerIf.value,
+              () => createDynamicComponent(() => views[viewRef.value]),
+            ),
+        })
+      },
+    }).render()
+
+    expect(html()).toBe(`<div>one</div><!--dynamic-component--><!--if-->`)
+    assertHookCalls(oneHooks, [1, 1, 1, 0, 0])
+    assertHookCalls(twoHooks, [0, 0, 0, 0, 0])
+
+    viewRef.value = 'two'
+    await nextTick()
+    expect(html()).toBe(`<div>two</div><!--dynamic-component--><!--if-->`)
+    assertHookCalls(oneHooks, [1, 1, 1, 1, 0])
+    assertHookCalls(twoHooks, [1, 1, 1, 0, 0])
+
+    viewRef.value = 'one'
+    await nextTick()
+    expect(html()).toBe(`<div>one</div><!--dynamic-component--><!--if-->`)
+    assertHookCalls(oneHooks, [1, 1, 2, 1, 0])
+    assertHookCalls(twoHooks, [1, 1, 1, 1, 0])
+
+    // Toggle v-if off
+    outerIf.value = false
+    await nextTick()
+    expect(html()).toBe(`<!--if-->`)
+    assertHookCalls(oneHooks, [1, 1, 2, 2, 0])
+    assertHookCalls(twoHooks, [1, 1, 1, 1, 0])
+
+    // Toggle v-if back on
+    outerIf.value = true
+    await nextTick()
+    expect(html()).toBe(`<div>one</div><!--dynamic-component--><!--if-->`)
+    // one should be reactivated from cache
+    assertHookCalls(oneHooks, [1, 1, 3, 2, 0])
+    assertHookCalls(twoHooks, [1, 1, 1, 1, 0])
+  })
+
+  test('should cache components in nested DynamicFragment with initial false v-if', async () => {
+    const outerIf = ref(false)
+    const viewRef = ref('one')
+
+    const { html } = define({
+      setup() {
+        return createComponent(VaporKeepAlive, null, {
+          default: () =>
+            createIf(
+              () => outerIf.value,
+              () => createDynamicComponent(() => views[viewRef.value]),
+            ),
+        })
+      },
+    }).render()
+
+    // Initially v-if is false, nothing rendered
+    expect(html()).toBe(`<!--if-->`)
+    assertHookCalls(oneHooks, [0, 0, 0, 0, 0])
+    assertHookCalls(twoHooks, [0, 0, 0, 0, 0])
+
+    // Toggle v-if on - component should mount and activate
+    outerIf.value = true
+    await nextTick()
+    expect(html()).toBe(`<div>one</div><!--dynamic-component--><!--if-->`)
+    assertHookCalls(oneHooks, [1, 1, 1, 0, 0])
+    assertHookCalls(twoHooks, [0, 0, 0, 0, 0])
+
+    // Switch to component two
+    viewRef.value = 'two'
+    await nextTick()
+    expect(html()).toBe(`<div>two</div><!--dynamic-component--><!--if-->`)
+    assertHookCalls(oneHooks, [1, 1, 1, 1, 0])
+    assertHookCalls(twoHooks, [1, 1, 1, 0, 0])
+
+    // Switch back to one - should be reactivated from cache
+    viewRef.value = 'one'
+    await nextTick()
+    expect(html()).toBe(`<div>one</div><!--dynamic-component--><!--if-->`)
+    assertHookCalls(oneHooks, [1, 1, 2, 1, 0])
+    assertHookCalls(twoHooks, [1, 1, 1, 1, 0])
+
+    // Toggle v-if off
+    outerIf.value = false
+    await nextTick()
+    expect(html()).toBe(`<!--if-->`)
+    assertHookCalls(oneHooks, [1, 1, 2, 2, 0])
+    assertHookCalls(twoHooks, [1, 1, 1, 1, 0])
+
+    // Toggle v-if back on - should reactivate from cache
+    outerIf.value = true
+    await nextTick()
+    expect(html()).toBe(`<div>one</div><!--dynamic-component--><!--if-->`)
+    assertHookCalls(oneHooks, [1, 1, 3, 2, 0])
+    assertHookCalls(twoHooks, [1, 1, 1, 1, 0])
+  })
+
+  test('should cache async components in nested v-if', async () => {
+    const asyncOneHooks = {
+      mounted: vi.fn(),
+      activated: vi.fn(),
+      deactivated: vi.fn(),
+      unmounted: vi.fn(),
+    }
+
+    const AsyncOne = defineVaporAsyncComponent({
+      loader: () =>
+        Promise.resolve(
+          defineVaporComponent({
+            name: 'AsyncOne',
+            setup() {
+              onMounted(asyncOneHooks.mounted)
+              onActivated(asyncOneHooks.activated)
+              onDeactivated(asyncOneHooks.deactivated)
+              onUnmounted(asyncOneHooks.unmounted)
+              return template('<div>async one</div>')()
+            },
+          }),
+        ),
+    })
+
+    const outerIf = ref(true)
+
+    const { html } = define({
+      setup() {
+        return createComponent(VaporKeepAlive, null, {
+          default: () =>
+            createIf(
+              () => outerIf.value,
+              () => createComponent(AsyncOne),
+            ),
+        })
+      },
+    }).render()
+
+    await timeout()
+    await nextTick()
+    expect(html()).toBe('<div>async one</div><!--async component--><!--if-->')
+    expect(asyncOneHooks.mounted).toHaveBeenCalledTimes(1)
+    expect(asyncOneHooks.activated).toHaveBeenCalledTimes(1)
+
+    // Toggle v-if off - should deactivate, not unmount
+    outerIf.value = false
+    await nextTick()
+    expect(html()).toBe('<!--if-->')
+    expect(asyncOneHooks.deactivated).toHaveBeenCalledTimes(1)
+    expect(asyncOneHooks.unmounted).toHaveBeenCalledTimes(0)
+
+    // Toggle back on - should reactivate from cache
+    outerIf.value = true
+    await nextTick()
+    expect(html()).toBe('<div>async one</div><!--async component--><!--if-->')
+    expect(asyncOneHooks.activated).toHaveBeenCalledTimes(2)
+    expect(asyncOneHooks.mounted).toHaveBeenCalledTimes(1) // not re-mounted
+  })
+
+  test('should cache components in deeply nested v-if (v-if > v-if > component)', async () => {
+    const compHooks = {
+      mounted: vi.fn(),
+      activated: vi.fn(),
+      deactivated: vi.fn(),
+      unmounted: vi.fn(),
+    }
+
+    const Comp = defineVaporComponent({
+      name: 'Comp',
+      setup() {
+        onMounted(compHooks.mounted)
+        onActivated(compHooks.activated)
+        onDeactivated(compHooks.deactivated)
+        onUnmounted(compHooks.unmounted)
+        return template('<div>comp</div>')()
+      },
+    })
+
+    const outerIf = ref(true)
+    const innerIf = ref(true)
+
+    const { html } = define({
+      setup() {
+        return createComponent(VaporKeepAlive, null, {
+          default: () =>
+            createIf(
+              () => outerIf.value,
+              () =>
+                createIf(
+                  () => innerIf.value,
+                  () => createComponent(Comp),
+                ),
+            ),
+        })
+      },
+    }).render()
+
+    expect(html()).toBe('<div>comp</div><!--if--><!--if-->')
+    expect(compHooks.mounted).toHaveBeenCalledTimes(1)
+    expect(compHooks.activated).toHaveBeenCalledTimes(1)
+
+    // Toggle inner v-if off
+    innerIf.value = false
+    await nextTick()
+    expect(html()).toBe('<!--if--><!--if-->')
+    expect(compHooks.deactivated).toHaveBeenCalledTimes(1)
+    expect(compHooks.unmounted).toHaveBeenCalledTimes(0)
+
+    // Toggle inner v-if back on - should reactivate
+    innerIf.value = true
+    await nextTick()
+    expect(html()).toBe('<div>comp</div><!--if--><!--if-->')
+    expect(compHooks.activated).toHaveBeenCalledTimes(2)
+    expect(compHooks.mounted).toHaveBeenCalledTimes(1)
+
+    // Toggle outer v-if off
+    outerIf.value = false
+    await nextTick()
+    expect(html()).toBe('<!--if-->')
+    expect(compHooks.deactivated).toHaveBeenCalledTimes(2)
+    expect(compHooks.unmounted).toHaveBeenCalledTimes(0)
+
+    // Toggle outer v-if back on - should reactivate
+    outerIf.value = true
+    await nextTick()
+    expect(html()).toBe('<div>comp</div><!--if--><!--if-->')
+    expect(compHooks.activated).toHaveBeenCalledTimes(3)
+    expect(compHooks.mounted).toHaveBeenCalledTimes(1)
+  })
+
   async function assertNameMatch(props: LooseRawProps) {
     const outerRef = ref(true)
     const viewRef = ref('one')
index 7a669740e4a8ffb92bbf1e5db151fcd30103588e..efe6970b0afae6d2a5a679a69771135f8a3413b2 100644 (file)
@@ -922,13 +922,15 @@ export function unmountComponent(
   instance: VaporComponentInstance,
   parentNode?: ParentNode,
 ): void {
+  // Skip unmount for kept-alive components - deactivate if called from remove()
   if (
-    parentNode &&
+    instance.shapeFlag! & ShapeFlags.COMPONENT_SHOULD_KEEP_ALIVE &&
     instance.parent &&
-    instance.parent.vapor &&
-    instance.shapeFlag! & ShapeFlags.COMPONENT_SHOULD_KEEP_ALIVE
+    instance.parent.vapor
   ) {
-    ;(instance.parent as KeepAliveInstance)!.ctx.deactivate(instance)
+    if (parentNode) {
+      ;(instance.parent as KeepAliveInstance)!.ctx.deactivate(instance)
+    }
     return
   }
 
index df595c541b92a3eb15cde6f7b1de16a588ad337c..4649a0b7a9c6636d76a0bd3910513231443765bf 100644 (file)
@@ -79,6 +79,22 @@ const KeepAliveImpl: ObjectVaporComponent = defineVaporComponent({
       ;(keepAliveInstance as any).__v_cache = cache
     }
 
+    // Clear cache and shapeFlags before HMR rerender so cached components
+    // can be properly unmounted
+    if (__DEV__) {
+      const rerender = keepAliveInstance.hmrRerender
+      keepAliveInstance.hmrRerender = () => {
+        cache.forEach(cached => resetCachedShapeFlag(cached))
+        cache.clear()
+        keys.clear()
+        keptAliveScopes.forEach(scope => scope.stop())
+        keptAliveScopes.clear()
+        storageContainer.innerHTML = ''
+        current = undefined
+        rerender!()
+      }
+    }
+
     keepAliveInstance.ctx = {
       getStorageContainer: () => storageContainer,
       getCachedComponent: comp => cache.get(comp),
@@ -117,6 +133,19 @@ const KeepAliveImpl: ObjectVaporComponent = defineVaporComponent({
     const cacheBlock = () => {
       // TODO suspense
       const block = keepAliveInstance.block!
+      // Skip caching during out-in transition leaving phase.
+      // The correct component will be cached after renderBranch completes
+      // via the Fragment's onUpdated hook.
+      if (isDynamicFragment(block)) {
+        const transition = block.$transition
+        if (
+          transition &&
+          transition.mode === 'out-in' &&
+          transition.state.isLeaving
+        ) {
+          return
+        }
+      }
       const [innerBlock, interop] = getInnerBlock(block)
       if (!innerBlock || !shouldCache(innerBlock, props, interop)) return
       innerCacheBlock(
@@ -157,11 +186,12 @@ const KeepAliveImpl: ObjectVaporComponent = defineVaporComponent({
     const pruneCacheEntry = (key: CacheKey) => {
       const cached = cache.get(key)!
 
-      resetCachedShapeFlag(cached)
-
       // don't unmount if the instance is the current one
-      if (cached !== current) {
+      if (cached && (!current || cached !== current)) {
+        resetCachedShapeFlag(cached)
         remove(cached)
+      } else if (current) {
+        resetCachedShapeFlag(current)
       }
       cache.delete(key)
       keys.delete(key)
@@ -230,9 +260,21 @@ const KeepAliveImpl: ObjectVaporComponent = defineVaporComponent({
           }
         },
       )
-      ;(frag.onBeforeMount || (frag.onBeforeMount = [])).push(() =>
-        processFragment(frag),
-      )
+      ;(frag.onBeforeMount || (frag.onBeforeMount = [])).push(() => {
+        processFragment(frag)
+        // recursively inject hooks to nested DynamicFragments.
+        // this is necessary for cases like v-if > dynamic component where
+        // v-if starts as false - the nested DynamicFragment doesn't exist
+        // during initial setup, so we must inject hooks when v-if becomes true.
+        processChildren(frag.nodes)
+      })
+      // This ensures caching happens after renderBranch completes,
+      // since Vue's onUpdated fires before the deferred rendering finishes.
+      ;(frag.onUpdated || (frag.onUpdated = [])).push(() => {
+        if (frag.$transition && frag.$transition.mode === 'out-in') {
+          cacheBlock()
+        }
+      })
       frag.getScope = key => {
         const scope = keptAliveScopes.get(key)
         if (scope) {
@@ -256,21 +298,34 @@ const KeepAliveImpl: ObjectVaporComponent = defineVaporComponent({
       }
     }
 
-    // process shapeFlag
+    // recursively inject hooks to nested DynamicFragments and handle AsyncWrapper
+    const processChildren = (block: Block): void => {
+      // handle async wrapper
+      if (isVaporComponent(block) && isAsyncWrapper(block)) {
+        watchAsyncResolve(block)
+        // block.block is a DynamicFragment
+        processChildren(block.block)
+      } else if (isDynamicFragment(block)) {
+        // avoid injecting hooks multiple times
+        if (!block.getScope) {
+          // DynamicFragment triggers processFragment via onBeforeMount hook,
+          // which correctly handles shapeFlag marking for inner components.
+          injectKeepAliveHooks(block)
+          if (block.nodes) processFragment(block)
+        }
+        processChildren(block.nodes)
+      }
+    }
+
     if (isVaporComponent(children)) {
       children.shapeFlag! |= ShapeFlags.COMPONENT_SHOULD_KEEP_ALIVE
-      if (isAsyncWrapper(children)) {
-        injectKeepAliveHooks(children.block as DynamicFragment)
-        watchAsyncResolve(children)
-      }
-    } else if (isInteropFragment(children)) {
-      children.vnode!.shapeFlag! |= ShapeFlags.COMPONENT_SHOULD_KEEP_ALIVE
-    } else if (isDynamicFragment(children)) {
-      processFragment(children)
-      injectKeepAliveHooks(children)
-      if (isVaporComponent(children.nodes) && isAsyncWrapper(children.nodes)) {
-        injectKeepAliveHooks(children.nodes.block as DynamicFragment)
-        watchAsyncResolve(children.nodes)
+      processChildren(children)
+    } else if (isFragment(children)) {
+      // vdom interop
+      if (children.vnode) {
+        children.vnode!.shapeFlag! |= ShapeFlags.COMPONENT_SHOULD_KEEP_ALIVE
+      } else {
+        processChildren(children)
       }
     }
 
index 521cc6ae6c8dca32cd7d9ec1e74120918e4e7547..aa35299a616b02713a267ed497e25392f5da5aa8 100644 (file)
@@ -34,7 +34,8 @@ export function hmrReload(
   instance: VaporComponentInstance,
   newComp: VaporComponent,
 ): void {
-  // if parent is KeepAlive, we need to rerender it
+  // If parent is KeepAlive, rerender it so new component goes through
+  // KeepAlive's slot rendering flow to receive activated hooks properly
   if (instance.parent && isKeepAlive(instance.parent)) {
     instance.parent.hmrRerender!()
     return