From 145abe989c9561b5d03c3df08e0eefd99defde26 Mon Sep 17 00:00:00 2001 From: daiwei Date: Fri, 10 Apr 2026 17:30:29 +0800 Subject: [PATCH 1/3] fix(keep-alive): defer keep-alive branch updates while inactive close #12017 close https://github.com/vuejs/router/issues/626 When a component update and a KeepAlive branch deactivation happen in the same flush, the deactivated branch can still finish its queued child update. In nested KeepAlive / keyed dynamic component cases, this can mount the next child twice: once in the deactivated cached branch and once in the newly active branch. Fix this by tracking deferred updates for inactive KeepAlive branches in the renderer and replaying them after the branch is activated again. This keeps the change scoped to KeepAlive branch activation state, preserves nested KeepAlive boundaries, and ensures re-activation applies the latest pending state. --- .../__tests__/components/KeepAlive.spec.ts | 90 +++++++++++++++++++ .../runtime-core/src/components/KeepAlive.ts | 15 ++++ packages/runtime-core/src/renderer.ts | 45 ++++++++++ 3 files changed, 150 insertions(+) diff --git a/packages/runtime-core/__tests__/components/KeepAlive.spec.ts b/packages/runtime-core/__tests__/components/KeepAlive.spec.ts index 3309ca0b8b1..61b1810df63 100644 --- a/packages/runtime-core/__tests__/components/KeepAlive.spec.ts +++ b/packages/runtime-core/__tests__/components/KeepAlive.spec.ts @@ -341,6 +341,96 @@ 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
`) + }) + async function assertNameMatch(props: KeepAliveProps) { const outerRef = ref(true) const viewRef = ref('one') diff --git a/packages/runtime-core/src/components/KeepAlive.ts b/packages/runtime-core/src/components/KeepAlive.ts index 55eaf862443..408c9cb86bd 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 { 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,17 @@ const KeepAliveImpl: ComponentOptions = { vnode.slotScopeIds, optimized, ) + if (updates) { + // Replay deferred child updates after the branch is active again. + queuePostFlushCb(() => { + for (const pending of updates) { + if (!pending.isUnmounted) { + pending.update() + } + } + updates.clear() + }) + } queuePostRenderEffect(() => { instance.isDeactivated = false if (instance.a) { @@ -168,6 +182,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..1c1acb5fd50 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,10 @@ function baseCreateRenderer( } else { let { next, bu, u, parent, vnode } = instance + if (deferKeepAliveBranchUpdate(instance)) { + return + } + if (__FEATURE_SUSPENSE__) { const nonHydratedAsyncRoot = locateNonHydratedAsyncRoot(instance) // we are trying to update some async comp before hydration @@ -2596,6 +2606,41 @@ 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) + return true + } + // Nested KeepAlive roots manage their own inactive branches. + if (isKeepAlive(current.vnode)) { + break + } + current = current.parent + } + return false +} + +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++) From 4b5680a01fb7b9f1ee5e404a7bfe9e450f7c585c Mon Sep 17 00:00:00 2001 From: daiwei Date: Fri, 10 Apr 2026 17:43:34 +0800 Subject: [PATCH 2/3] fix(runtime-core): avoid replaying keep-alive updates into inactive branches --- .../__tests__/components/KeepAlive.spec.ts | 69 +++++++++++++++++++ .../runtime-core/src/components/KeepAlive.ts | 11 ++- 2 files changed, 77 insertions(+), 3 deletions(-) diff --git a/packages/runtime-core/__tests__/components/KeepAlive.spec.ts b/packages/runtime-core/__tests__/components/KeepAlive.spec.ts index 61b1810df63..c868a70e33e 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)) @@ -431,6 +432,74 @@ describe('KeepAlive', () => { 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) + }) + async function assertNameMatch(props: KeepAliveProps) { const outerRef = ref(true) const viewRef = ref('one') diff --git a/packages/runtime-core/src/components/KeepAlive.ts b/packages/runtime-core/src/components/KeepAlive.ts index 408c9cb86bd..edc7491fe81 100644 --- a/packages/runtime-core/src/components/KeepAlive.ts +++ b/packages/runtime-core/src/components/KeepAlive.ts @@ -43,7 +43,7 @@ import { queuePostRenderEffect, setKeepAliveBranchActive, } from '../renderer' -import { queuePostFlushCb } from '../scheduler' +import { type SchedulerJob, queueJob, queuePostFlushCb } from '../scheduler' import { setTransitionHooks } from './BaseTransition' import type { ComponentRenderContext } from '../componentPublicInstance' import { devtoolsComponentAdded } from '../devtools' @@ -153,11 +153,16 @@ const KeepAliveImpl: ComponentOptions = { optimized, ) if (updates) { - // Replay deferred child updates after the branch is active again. + // Replay deferred child updates through the scheduler after the branch + // is active again so parent jobs can still flip the branch back to + // inactive before child updates run. queuePostFlushCb(() => { for (const pending of updates) { if (!pending.isUnmounted) { - pending.update() + const job = (() => pending.update()) as SchedulerJob + job.id = pending.uid + job.i = pending + queueJob(job) } } updates.clear() From 2160fc0c873820b25fce971ca8b31becdb4143dc Mon Sep 17 00:00:00 2001 From: daiwei Date: Fri, 10 Apr 2026 21:20:43 +0800 Subject: [PATCH 3/3] chore: improve --- .../__tests__/components/KeepAlive.spec.ts | 47 +++++++++++++++++++ packages/runtime-core/src/component.ts | 6 +++ .../runtime-core/src/components/KeepAlive.ts | 15 +++--- packages/runtime-core/src/renderer.ts | 22 +++++++++ 4 files changed, 81 insertions(+), 9 deletions(-) diff --git a/packages/runtime-core/__tests__/components/KeepAlive.spec.ts b/packages/runtime-core/__tests__/components/KeepAlive.spec.ts index c868a70e33e..74e7bc28dc6 100644 --- a/packages/runtime-core/__tests__/components/KeepAlive.spec.ts +++ b/packages/runtime-core/__tests__/components/KeepAlive.spec.ts @@ -500,6 +500,53 @@ describe('KeepAlive', () => { 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 edc7491fe81..ce3cc0f12e1 100644 --- a/packages/runtime-core/src/components/KeepAlive.ts +++ b/packages/runtime-core/src/components/KeepAlive.ts @@ -43,7 +43,7 @@ import { queuePostRenderEffect, setKeepAliveBranchActive, } from '../renderer' -import { type SchedulerJob, queueJob, queuePostFlushCb } from '../scheduler' +import { queueJob, queuePostFlushCb } from '../scheduler' import { setTransitionHooks } from './BaseTransition' import type { ComponentRenderContext } from '../componentPublicInstance' import { devtoolsComponentAdded } from '../devtools' @@ -153,16 +153,13 @@ const KeepAliveImpl: ComponentOptions = { optimized, ) if (updates) { - // Replay deferred child updates through the scheduler after the branch - // is active again so parent jobs can still flip the branch back to - // inactive before child updates run. + // 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.isUnmounted) { - const job = (() => pending.update()) as SchedulerJob - job.id = pending.uid - job.i = pending - queueJob(job) + if (pending.keepAliveReplayJob) { + queueJob(pending.keepAliveReplayJob) } } updates.clear() diff --git a/packages/runtime-core/src/renderer.ts b/packages/runtime-core/src/renderer.ts index 1c1acb5fd50..4ff6bc5c7f2 100644 --- a/packages/runtime-core/src/renderer.ts +++ b/packages/runtime-core/src/renderer.ts @@ -1474,6 +1474,7 @@ function baseCreateRenderer( if (deferKeepAliveBranchUpdate(instance)) { return } + instance.keepAliveReplayJob = null if (__FEATURE_SUSPENSE__) { const nonHydratedAsyncRoot = locateNonHydratedAsyncRoot(instance) @@ -2614,6 +2615,7 @@ export function deferKeepAliveBranchUpdate( const updates = deferredKeepAliveBranchUpdates.get(current) if (updates) { updates.add(instance) + instance.keepAliveReplayJob ||= createKeepAliveReplayJob(instance) return true } // Nested KeepAlive roots manage their own inactive branches. @@ -2625,6 +2627,26 @@ export function deferKeepAliveBranchUpdate( 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,