From: edison Date: Wed, 14 Jan 2026 01:28:29 +0000 (+0800) Subject: fix(keep-alive): fix caching nested dynamic fragments (#14307) X-Git-Tag: v3.6.0-beta.4~23 X-Git-Url: http://git.ipfire.org/cgi-bin/gitweb.cgi?a=commitdiff_plain;h=bd9aa970232ca0591d4c1bfb496b9427a6e777e2;p=thirdparty%2Fvuejs%2Fcore.git fix(keep-alive): fix caching nested dynamic fragments (#14307) --- diff --git a/packages/runtime-vapor/__tests__/components/KeepAlive.spec.ts b/packages/runtime-vapor/__tests__/components/KeepAlive.spec.ts index 25a2390954..023eb7b9e0 100644 --- a/packages/runtime-vapor/__tests__/components/KeepAlive.spec.ts +++ b/packages/runtime-vapor/__tests__/components/KeepAlive.spec.ts @@ -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(`
one
`) + assertHookCalls(oneHooks, [1, 1, 1, 0, 0]) + assertHookCalls(twoHooks, [0, 0, 0, 0, 0]) + + viewRef.value = 'two' + await nextTick() + expect(html()).toBe(`
two
`) + assertHookCalls(oneHooks, [1, 1, 1, 1, 0]) + assertHookCalls(twoHooks, [1, 1, 1, 0, 0]) + + viewRef.value = 'one' + await nextTick() + expect(html()).toBe(`
one
`) + 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(``) + 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(`
one
`) + // 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(``) + 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(`
one
`) + 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(`
two
`) + 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(`
one
`) + 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(``) + 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(`
one
`) + 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('
async one
')() + }, + }), + ), + }) + + 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('
async one
') + 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('') + 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('
async one
') + 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('
comp
')() + }, + }) + + 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('
comp
') + expect(compHooks.mounted).toHaveBeenCalledTimes(1) + expect(compHooks.activated).toHaveBeenCalledTimes(1) + + // Toggle inner v-if off + innerIf.value = false + await nextTick() + expect(html()).toBe('') + 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('
comp
') + expect(compHooks.activated).toHaveBeenCalledTimes(2) + expect(compHooks.mounted).toHaveBeenCalledTimes(1) + + // Toggle outer v-if off + outerIf.value = false + await nextTick() + expect(html()).toBe('') + 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('
comp
') + expect(compHooks.activated).toHaveBeenCalledTimes(3) + expect(compHooks.mounted).toHaveBeenCalledTimes(1) + }) + async function assertNameMatch(props: LooseRawProps) { const outerRef = ref(true) const viewRef = ref('one') diff --git a/packages/runtime-vapor/src/component.ts b/packages/runtime-vapor/src/component.ts index 7a669740e4..efe6970b0a 100644 --- a/packages/runtime-vapor/src/component.ts +++ b/packages/runtime-vapor/src/component.ts @@ -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 } diff --git a/packages/runtime-vapor/src/components/KeepAlive.ts b/packages/runtime-vapor/src/components/KeepAlive.ts index df595c541b..4649a0b7a9 100644 --- a/packages/runtime-vapor/src/components/KeepAlive.ts +++ b/packages/runtime-vapor/src/components/KeepAlive.ts @@ -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) } } diff --git a/packages/runtime-vapor/src/hmr.ts b/packages/runtime-vapor/src/hmr.ts index 521cc6ae6c..aa35299a61 100644 --- a/packages/runtime-vapor/src/hmr.ts +++ b/packages/runtime-vapor/src/hmr.ts @@ -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