Skip to content
Draft
Show file tree
Hide file tree
Changes from 1 commit
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
90 changes: 90 additions & 0 deletions packages/runtime-core/__tests__/components/KeepAlive.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(`<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>`)
})

async function assertNameMatch(props: KeepAliveProps) {
const outerRef = ref(true)
const viewRef = ref('one')
Expand Down
15 changes: 15 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 { 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,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) {
Expand All @@ -168,6 +182,7 @@ const KeepAliveImpl: ComponentOptions = {

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

Expand Down
45 changes: 45 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,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
Expand Down Expand Up @@ -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<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