Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
206 changes: 206 additions & 0 deletions packages/runtime-core/__tests__/components/KeepAlive.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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))

Expand Down Expand Up @@ -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(`<main><span>Comp A</span></main>`)
expect(mountedA).toHaveBeenCalledTimes(1)
expect(mountedB).toHaveBeenCalledTimes(0)

switchRoute()
await nextTick()

expect(serializeInner(root)).toBe(`<main><span>Comp B</span></main>`)
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(`<main>A</main>`)

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(`<main>C</main>`)
})

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(`<main><span>Comp A</span></main>`)
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(`<main><span>Comp B</span></main>`)
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(`<main>A</main>`)
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(`<main>C</main>`)
expect(renders).toBe(2)
})

async function assertNameMatch(props: KeepAliveProps) {
const outerRef = ref(true)
const viewRef = ref('one')
Expand Down
6 changes: 6 additions & 0 deletions packages/runtime-core/src/component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -520,6 +520,11 @@ export interface ComponentInternalInstance {
isMounted: boolean
isUnmounted: boolean
isDeactivated: boolean
/**
* KeepAlive deferred update replay job.
* @internal
*/
keepAliveReplayJob: SchedulerJob | null
/**
* @internal
*/
Expand Down Expand Up @@ -684,6 +689,7 @@ export function createComponentInstance(
isMounted: false,
isUnmounted: false,
isDeactivated: false,
keepAliveReplayJob: null,
bc: null,
c: null,
bm: null,
Expand Down
17 changes: 17 additions & 0 deletions packages/runtime-core/src/components/KeepAlive.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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(
Expand All @@ -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) {
Expand All @@ -168,6 +184,7 @@ const KeepAliveImpl: ComponentOptions = {

sharedContext.deactivate = (vnode: VNode) => {
const instance = vnode.component!
setKeepAliveBranchActive(instance, false)
invalidateMount(instance.m)
invalidateMount(instance.a)

Expand Down
67 changes: 67 additions & 0 deletions packages/runtime-core/src/renderer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,12 @@ export type RootRenderFunction<HostElement = RendererElement> = (
namespace?: ElementNamespace,
) => void

// Tracks component updates that are deferred while a KeepAlive branch is inactive.
const deferredKeepAliveBranchUpdates = new WeakMap<
ComponentInternalInstance,
Set<ComponentInternalInstance>
>()

export interface RendererOptions<
HostNode = RendererNode,
HostElement = RendererElement,
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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<ComponentInternalInstance> | 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++)
Expand Down
Loading