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++)