diff --git a/packages/runtime-core/__tests__/components/KeepAlive.spec.ts b/packages/runtime-core/__tests__/components/KeepAlive.spec.ts index 3309ca0b8b1..74e7bc28dc6 100644 --- a/packages/runtime-core/__tests__/components/KeepAlive.spec.ts +++ b/packages/runtime-core/__tests__/components/KeepAlive.spec.ts @@ -26,6 +26,7 @@ import { shallowRef, } from '@vue/runtime-test' import type { KeepAliveProps } from '../../src/components/KeepAlive' +import { queuePostFlushCb } from '../../src/scheduler' const timeout = (n: number = 0) => new Promise(r => setTimeout(r, n)) @@ -341,6 +342,211 @@ describe('KeepAlive', () => { assertHookCalls(two, [1, 1, 4, 4, 0]) // should remain inactive }) + test('should not mount nested dynamic component twice when parent key changes', async () => { + const mountedA = vi.fn() + const mountedB = vi.fn() + + const A = defineComponent({ + name: 'A', + setup() { + onMounted(mountedA) + return () => h('span', 'Comp A') + }, + }) + + const B = defineComponent({ + name: 'B', + setup() { + onMounted(mountedB) + return () => h('span', 'Comp B') + }, + }) + + const switchRoute = () => { + comp.value = B + } + const comp = shallowRef(A) + const HomeView = defineComponent({ + name: 'HomeView', + setup() { + return () => h('main', [h(KeepAlive, null, [h(comp.value)])]) + }, + }) + + const App = defineComponent({ + setup() { + return () => + h(KeepAlive, null, [ + h(HomeView, { + key: (comp.value as ComponentOptions).name, + }), + ]) + }, + }) + + render(h(App), root) + expect(serializeInner(root)).toBe(`
Comp A
`) + expect(mountedA).toHaveBeenCalledTimes(1) + expect(mountedB).toHaveBeenCalledTimes(0) + + switchRoute() + await nextTick() + + expect(serializeInner(root)).toBe(`
Comp B
`) + expect(mountedA).toHaveBeenCalledTimes(1) + expect(mountedB).toHaveBeenCalledTimes(1) + }) + + test('should apply the latest deferred update when re-activating a branch', async () => { + const visible = ref(true) + const value = ref('A') + + const Home = defineComponent({ + name: 'Home', + setup() { + return () => h('main', value.value) + }, + }) + + const App = defineComponent({ + setup() { + return () => h(KeepAlive, null, [visible.value ? h(Home) : null]) + }, + }) + + render(h(App), root) + expect(serializeInner(root)).toBe(`
A
`) + + visible.value = false + await nextTick() + expect(serializeInner(root)).toBe(``) + + value.value = 'B' + await nextTick() + value.value = 'C' + await nextTick() + expect(serializeInner(root)).toBe(``) + + visible.value = true + await nextTick() + expect(serializeInner(root)).toBe(`
C
`) + }) + + test('should keep deferred branch updates pending when re-activation is immediately reversed', async () => { + const mountedA = vi.fn() + const mountedB = vi.fn() + const visible = ref(true) + + const A = defineComponent({ + name: 'A', + setup() { + onMounted(mountedA) + return () => h('span', 'Comp A') + }, + }) + + const B = defineComponent({ + name: 'B', + setup() { + onMounted(mountedB) + return () => h('span', 'Comp B') + }, + }) + + const comp = shallowRef(A) + const Home = defineComponent({ + name: 'Home', + setup() { + return () => h('main', [h(KeepAlive, null, [h(comp.value)])]) + }, + }) + + const App = defineComponent({ + setup() { + return () => h(KeepAlive, null, [visible.value ? h(Home) : null]) + }, + }) + + render(h(App), root) + expect(serializeInner(root)).toBe(`
Comp A
`) + expect(mountedA).toHaveBeenCalledTimes(1) + expect(mountedB).toHaveBeenCalledTimes(0) + + visible.value = false + await nextTick() + expect(serializeInner(root)).toBe(``) + + comp.value = B + await nextTick() + expect(serializeInner(root)).toBe(``) + expect(mountedB).toHaveBeenCalledTimes(0) + + const deactivateAfterActivate = vi.fn(() => { + visible.value = false + }) as any + deactivateAfterActivate.id = -1 + + visible.value = true + queuePostFlushCb(deactivateAfterActivate) + await nextTick() + + expect(serializeInner(root)).toBe(``) + expect(deactivateAfterActivate).toHaveBeenCalledTimes(1) + expect(mountedB).toHaveBeenCalledTimes(0) + + visible.value = true + await nextTick() + expect(serializeInner(root)).toBe(`
Comp B
`) + expect(mountedB).toHaveBeenCalledTimes(1) + }) + + test('should not replay a deferred update when a newer child job is already queued', async () => { + let renders = 0 + const visible = ref(true) + const value = ref('A') + + const Home = defineComponent({ + name: 'Home', + setup() { + return () => { + renders++ + return h('main', value.value) + } + }, + }) + + const App = defineComponent({ + setup() { + return () => h(KeepAlive, null, [visible.value ? h(Home) : null]) + }, + }) + + render(h(App), root) + expect(serializeInner(root)).toBe(`
A
`) + expect(renders).toBe(1) + + visible.value = false + await nextTick() + expect(serializeInner(root)).toBe(``) + + value.value = 'B' + await nextTick() + expect(renders).toBe(1) + + const queueNewerUpdate = vi.fn(() => { + value.value = 'C' + }) as any + queueNewerUpdate.id = -1 + + visible.value = true + queuePostFlushCb(queueNewerUpdate) + await nextTick() + + expect(queueNewerUpdate).toHaveBeenCalledTimes(1) + expect(serializeInner(root)).toBe(`
C
`) + expect(renders).toBe(2) + }) + async function assertNameMatch(props: KeepAliveProps) { const outerRef = ref(true) const viewRef = ref('one') diff --git a/packages/runtime-core/src/component.ts b/packages/runtime-core/src/component.ts index c46741bee80..3a6a25e1a98 100644 --- a/packages/runtime-core/src/component.ts +++ b/packages/runtime-core/src/component.ts @@ -520,6 +520,11 @@ export interface ComponentInternalInstance { isMounted: boolean isUnmounted: boolean isDeactivated: boolean + /** + * KeepAlive deferred update replay job. + * @internal + */ + keepAliveReplayJob: SchedulerJob | null /** * @internal */ @@ -684,6 +689,7 @@ export function createComponentInstance( isMounted: false, isUnmounted: false, isDeactivated: false, + keepAliveReplayJob: null, bc: null, c: null, bm: null, diff --git a/packages/runtime-core/src/components/KeepAlive.ts b/packages/runtime-core/src/components/KeepAlive.ts index 55eaf862443..ce3cc0f12e1 100644 --- a/packages/runtime-core/src/components/KeepAlive.ts +++ b/packages/runtime-core/src/components/KeepAlive.ts @@ -41,7 +41,9 @@ import { type RendererNode, invalidateMount, queuePostRenderEffect, + setKeepAliveBranchActive, } from '../renderer' +import { queueJob, queuePostFlushCb } from '../scheduler' import { setTransitionHooks } from './BaseTransition' import type { ComponentRenderContext } from '../componentPublicInstance' import { devtoolsComponentAdded } from '../devtools' @@ -136,6 +138,7 @@ const KeepAliveImpl: ComponentOptions = { optimized, ) => { const instance = vnode.component! + const updates = setKeepAliveBranchActive(instance, true) move(vnode, container, anchor, MoveType.ENTER, parentSuspense) // in case props have changed patch( @@ -149,6 +152,19 @@ const KeepAliveImpl: ComponentOptions = { vnode.slotScopeIds, optimized, ) + if (updates) { + // Replay deferred child updates in a later scheduler turn so parent + // jobs can deactivate the branch again first. The replay job also + // bails if a normal update for the same instance is already queued. + queuePostFlushCb(() => { + for (const pending of updates) { + if (pending.keepAliveReplayJob) { + queueJob(pending.keepAliveReplayJob) + } + } + updates.clear() + }) + } queuePostRenderEffect(() => { instance.isDeactivated = false if (instance.a) { @@ -168,6 +184,7 @@ const KeepAliveImpl: ComponentOptions = { sharedContext.deactivate = (vnode: VNode) => { const instance = vnode.component! + setKeepAliveBranchActive(instance, false) invalidateMount(instance.m) invalidateMount(instance.a) diff --git a/packages/runtime-core/src/renderer.ts b/packages/runtime-core/src/renderer.ts index f97ca24f143..4ff6bc5c7f2 100644 --- a/packages/runtime-core/src/renderer.ts +++ b/packages/runtime-core/src/renderer.ts @@ -110,6 +110,12 @@ export type RootRenderFunction = ( namespace?: ElementNamespace, ) => void +// Tracks component updates that are deferred while a KeepAlive branch is inactive. +const deferredKeepAliveBranchUpdates = new WeakMap< + ComponentInternalInstance, + Set +>() + export interface RendererOptions< HostNode = RendererNode, HostElement = RendererElement, @@ -1465,6 +1471,11 @@ function baseCreateRenderer( } else { let { next, bu, u, parent, vnode } = instance + if (deferKeepAliveBranchUpdate(instance)) { + return + } + instance.keepAliveReplayJob = null + if (__FEATURE_SUSPENSE__) { const nonHydratedAsyncRoot = locateNonHydratedAsyncRoot(instance) // we are trying to update some async comp before hydration @@ -2596,6 +2607,62 @@ function locateNonHydratedAsyncRoot( } } +export function deferKeepAliveBranchUpdate( + instance: ComponentInternalInstance, +): boolean { + let current: ComponentInternalInstance | null = instance + while (current) { + const updates = deferredKeepAliveBranchUpdates.get(current) + if (updates) { + updates.add(instance) + instance.keepAliveReplayJob ||= createKeepAliveReplayJob(instance) + return true + } + // Nested KeepAlive roots manage their own inactive branches. + if (isKeepAlive(current.vnode)) { + break + } + current = current.parent + } + return false +} + +function createKeepAliveReplayJob( + instance: ComponentInternalInstance, +): SchedulerJob { + const job = (() => { + if (instance.isUnmounted || instance.keepAliveReplayJob !== job) { + return + } + if (instance.job.flags! & SchedulerJobFlags.QUEUED) { + return + } + if (!deferKeepAliveBranchUpdate(instance)) { + instance.keepAliveReplayJob = null + instance.update() + } + }) as SchedulerJob + job.id = instance.uid + job.i = instance + return job +} + +export function setKeepAliveBranchActive( + instance: ComponentInternalInstance, + active: boolean, +): Set | undefined { + if (active) { + const updates = deferredKeepAliveBranchUpdates.get(instance) + deferredKeepAliveBranchUpdates.delete(instance) + return updates + } + + // Child updates will be collected under this inactive KeepAlive root. + if (!deferredKeepAliveBranchUpdates.has(instance)) { + deferredKeepAliveBranchUpdates.set(instance, new Set()) + } +} + export function invalidateMount(hooks: LifecycleHook): void { if (hooks) { for (let i = 0; i < hooks.length; i++)