diff --git a/packages/runtime-core/__tests__/components/KeepAlive.spec.ts b/packages/runtime-core/__tests__/components/KeepAlive.spec.ts
index 1cc7fe01eff..3c5835dfd41 100644
--- a/packages/runtime-core/__tests__/components/KeepAlive.spec.ts
+++ b/packages/runtime-core/__tests__/components/KeepAlive.spec.ts
@@ -16,7 +16,11 @@ import {
cloneVNode,
provide
} from '@vue/runtime-test'
-import { KeepAliveProps } from '../../src/components/KeepAlive'
+import {
+ KeepAliveProps,
+ KeepAliveCache,
+ Cache
+} from '../../src/components/KeepAlive'
describe('KeepAlive', () => {
let one: ComponentOptions
@@ -34,7 +38,9 @@ describe('KeepAlive', () => {
},
created: jest.fn(),
mounted: jest.fn(),
+ beforeActivate: jest.fn(),
activated: jest.fn(),
+ beforeDeactivate: jest.fn(),
deactivated: jest.fn(),
unmounted: jest.fn()
}
@@ -46,7 +52,9 @@ describe('KeepAlive', () => {
},
created: jest.fn(),
mounted: jest.fn(),
+ beforeActivate: jest.fn(),
activated: jest.fn(),
+ beforeDeactivate: jest.fn(),
deactivated: jest.fn(),
unmounted: jest.fn()
}
@@ -60,7 +68,9 @@ describe('KeepAlive', () => {
expect([
component.created.mock.calls.length,
component.mounted.mock.calls.length,
+ component.beforeActivate.mock.calls.length,
component.activated.mock.calls.length,
+ component.beforeDeactivate.mock.calls.length,
component.deactivated.mock.calls.length,
component.unmounted.mock.calls.length
]).toEqual(callCounts)
@@ -100,34 +110,34 @@ describe('KeepAlive', () => {
render(h(App), root)
expect(serializeInner(root)).toBe(`
one
`)
- assertHookCalls(one, [1, 1, 1, 0, 0])
- assertHookCalls(two, [0, 0, 0, 0, 0])
+ assertHookCalls(one, [1, 1, 1, 1, 0, 0, 0])
+ assertHookCalls(two, [0, 0, 0, 0, 0, 0, 0])
// toggle kept-alive component
viewRef.value = 'two'
await nextTick()
expect(serializeInner(root)).toBe(`two
`)
- assertHookCalls(one, [1, 1, 1, 1, 0])
- assertHookCalls(two, [1, 1, 1, 0, 0])
+ assertHookCalls(one, [1, 1, 1, 1, 1, 1, 0])
+ assertHookCalls(two, [1, 1, 1, 1, 0, 0, 0])
viewRef.value = 'one'
await nextTick()
expect(serializeInner(root)).toBe(`one
`)
- assertHookCalls(one, [1, 1, 2, 1, 0])
- assertHookCalls(two, [1, 1, 1, 1, 0])
+ assertHookCalls(one, [1, 1, 2, 2, 1, 1, 0])
+ assertHookCalls(two, [1, 1, 1, 1, 1, 1, 0])
viewRef.value = 'two'
await nextTick()
expect(serializeInner(root)).toBe(`two
`)
- assertHookCalls(one, [1, 1, 2, 2, 0])
- assertHookCalls(two, [1, 1, 2, 1, 0])
+ assertHookCalls(one, [1, 1, 2, 2, 2, 2, 0])
+ assertHookCalls(two, [1, 1, 2, 2, 1, 1, 0])
// teardown keep-alive, should unmount all components including cached
toggle.value = false
await nextTick()
expect(serializeInner(root)).toBe(``)
- assertHookCalls(one, [1, 1, 2, 2, 1])
- assertHookCalls(two, [1, 1, 2, 2, 1])
+ assertHookCalls(one, [1, 1, 2, 2, 2, 2, 1])
+ assertHookCalls(two, [1, 1, 2, 2, 2, 2, 1])
})
test('should call correct lifecycle hooks when toggle the KeepAlive first', async () => {
@@ -141,35 +151,35 @@ describe('KeepAlive', () => {
render(h(App), root)
expect(serializeInner(root)).toBe(`one
`)
- assertHookCalls(one, [1, 1, 1, 0, 0])
- assertHookCalls(two, [0, 0, 0, 0, 0])
+ assertHookCalls(one, [1, 1, 1, 1, 0, 0, 0])
+ assertHookCalls(two, [0, 0, 0, 0, 0, 0, 0])
// should unmount 'one' component when toggle the KeepAlive first
toggle.value = false
await nextTick()
expect(serializeInner(root)).toBe(``)
- assertHookCalls(one, [1, 1, 1, 1, 1])
- assertHookCalls(two, [0, 0, 0, 0, 0])
+ assertHookCalls(one, [1, 1, 1, 1, 1, 1, 1])
+ assertHookCalls(two, [0, 0, 0, 0, 0, 0, 0])
toggle.value = true
await nextTick()
expect(serializeInner(root)).toBe(`one
`)
- assertHookCalls(one, [2, 2, 2, 1, 1])
- assertHookCalls(two, [0, 0, 0, 0, 0])
+ assertHookCalls(one, [2, 2, 2, 2, 1, 1, 1])
+ assertHookCalls(two, [0, 0, 0, 0, 0, 0, 0])
// 1. the first time toggle kept-alive component
viewRef.value = 'two'
await nextTick()
expect(serializeInner(root)).toBe(`two
`)
- assertHookCalls(one, [2, 2, 2, 2, 1])
- assertHookCalls(two, [1, 1, 1, 0, 0])
+ assertHookCalls(one, [2, 2, 2, 2, 2, 2, 1])
+ assertHookCalls(two, [1, 1, 1, 1, 0, 0, 0])
// 2. should unmount all components including cached
toggle.value = false
await nextTick()
expect(serializeInner(root)).toBe(``)
- assertHookCalls(one, [2, 2, 2, 2, 2])
- assertHookCalls(two, [1, 1, 1, 1, 1])
+ assertHookCalls(one, [2, 2, 2, 2, 2, 2, 2])
+ assertHookCalls(two, [1, 1, 1, 1, 1, 1, 1])
})
test('should call lifecycle hooks on nested components', async () => {
@@ -184,26 +194,26 @@ describe('KeepAlive', () => {
render(h(App), root)
expect(serializeInner(root)).toBe(`two
`)
- assertHookCalls(one, [1, 1, 1, 0, 0])
- assertHookCalls(two, [1, 1, 1, 0, 0])
+ assertHookCalls(one, [1, 1, 1, 1, 0, 0, 0])
+ assertHookCalls(two, [1, 1, 1, 1, 0, 0, 0])
toggle.value = false
await nextTick()
expect(serializeInner(root)).toBe(``)
- assertHookCalls(one, [1, 1, 1, 1, 0])
- assertHookCalls(two, [1, 1, 1, 1, 0])
+ assertHookCalls(one, [1, 1, 1, 1, 1, 1, 0])
+ assertHookCalls(two, [1, 1, 1, 1, 1, 1, 0])
toggle.value = true
await nextTick()
expect(serializeInner(root)).toBe(`two
`)
- assertHookCalls(one, [1, 1, 2, 1, 0])
- assertHookCalls(two, [1, 1, 2, 1, 0])
+ assertHookCalls(one, [1, 1, 2, 2, 1, 1, 0])
+ assertHookCalls(two, [1, 1, 2, 2, 1, 1, 0])
toggle.value = false
await nextTick()
expect(serializeInner(root)).toBe(``)
- assertHookCalls(one, [1, 1, 2, 2, 0])
- assertHookCalls(two, [1, 1, 2, 2, 0])
+ assertHookCalls(one, [1, 1, 2, 2, 2, 2, 0])
+ assertHookCalls(two, [1, 1, 2, 2, 2, 2, 0])
})
// #1742
@@ -249,113 +259,124 @@ describe('KeepAlive', () => {
render(h(App), root)
expect(serializeInner(root)).toBe(`two
`)
- assertHookCalls(one, [1, 1, 1, 0, 0])
- assertHookCalls(two, [1, 1, 1, 0, 0])
+ assertHookCalls(one, [1, 1, 1, 1, 0, 0, 0])
+ assertHookCalls(two, [1, 1, 1, 1, 0, 0, 0])
toggle1.value = false
await nextTick()
expect(serializeInner(root)).toBe(``)
- assertHookCalls(one, [1, 1, 1, 1, 0])
- assertHookCalls(two, [1, 1, 1, 1, 0])
+ assertHookCalls(one, [1, 1, 1, 1, 1, 1, 0])
+ assertHookCalls(two, [1, 1, 1, 1, 1, 1, 0])
toggle1.value = true
await nextTick()
expect(serializeInner(root)).toBe(`two
`)
- assertHookCalls(one, [1, 1, 2, 1, 0])
- assertHookCalls(two, [1, 1, 2, 1, 0])
+ assertHookCalls(one, [1, 1, 2, 2, 1, 1, 0])
+ assertHookCalls(two, [1, 1, 2, 2, 1, 1, 0])
// toggle nested instance
toggle2.value = false
await nextTick()
expect(serializeInner(root)).toBe(``)
- assertHookCalls(one, [1, 1, 2, 1, 0])
- assertHookCalls(two, [1, 1, 2, 2, 0])
+ assertHookCalls(one, [1, 1, 2, 2, 1, 1, 0])
+ assertHookCalls(two, [1, 1, 2, 2, 2, 2, 0])
toggle2.value = true
await nextTick()
expect(serializeInner(root)).toBe(`two
`)
- assertHookCalls(one, [1, 1, 2, 1, 0])
- assertHookCalls(two, [1, 1, 3, 2, 0])
+ assertHookCalls(one, [1, 1, 2, 2, 1, 1, 0])
+ assertHookCalls(two, [1, 1, 3, 3, 2, 2, 0])
toggle1.value = false
await nextTick()
expect(serializeInner(root)).toBe(``)
- assertHookCalls(one, [1, 1, 2, 2, 0])
- assertHookCalls(two, [1, 1, 3, 3, 0])
+ assertHookCalls(one, [1, 1, 2, 2, 2, 2, 0])
+ assertHookCalls(two, [1, 1, 3, 3, 3, 3, 0])
// toggle nested instance when parent is deactivated
toggle2.value = false
await nextTick()
expect(serializeInner(root)).toBe(``)
- assertHookCalls(one, [1, 1, 2, 2, 0])
- assertHookCalls(two, [1, 1, 3, 3, 0]) // should not be affected
+ assertHookCalls(one, [1, 1, 2, 2, 2, 2, 0])
+ assertHookCalls(two, [1, 1, 3, 3, 3, 3, 0]) // should not be affected
toggle2.value = true
await nextTick()
expect(serializeInner(root)).toBe(``)
- assertHookCalls(one, [1, 1, 2, 2, 0])
- assertHookCalls(two, [1, 1, 3, 3, 0]) // should not be affected
+ assertHookCalls(one, [1, 1, 2, 2, 2, 2, 0])
+ assertHookCalls(two, [1, 1, 3, 3, 3, 3, 0]) // should not be affected
toggle1.value = true
await nextTick()
expect(serializeInner(root)).toBe(`two
`)
- assertHookCalls(one, [1, 1, 3, 2, 0])
- assertHookCalls(two, [1, 1, 4, 3, 0])
+ assertHookCalls(one, [1, 1, 3, 3, 2, 2, 0])
+ assertHookCalls(two, [1, 1, 4, 4, 3, 3, 0])
toggle1.value = false
toggle2.value = false
await nextTick()
expect(serializeInner(root)).toBe(``)
- assertHookCalls(one, [1, 1, 3, 3, 0])
- assertHookCalls(two, [1, 1, 4, 4, 0])
+ assertHookCalls(one, [1, 1, 3, 3, 3, 3, 0])
+ assertHookCalls(two, [1, 1, 4, 4, 4, 4, 0])
toggle1.value = true
await nextTick()
expect(serializeInner(root)).toBe(``)
- assertHookCalls(one, [1, 1, 4, 3, 0])
- assertHookCalls(two, [1, 1, 4, 4, 0]) // should remain inactive
+ assertHookCalls(one, [1, 1, 4, 4, 3, 3, 0])
+ assertHookCalls(two, [1, 1, 4, 4, 4, 4, 0]) // should remain inactive
})
- async function assertNameMatch(props: KeepAliveProps) {
+ async function assertNameMatch(
+ props: KeepAliveProps,
+ useKey: boolean = false
+ ) {
+ if (useKey) {
+ delete one.name
+ delete two.name
+ }
const outerRef = ref(true)
const viewRef = ref('one')
const App = {
render() {
return outerRef.value
- ? h(KeepAlive, props, () => h(views[viewRef.value]))
+ ? h(KeepAlive, props, () =>
+ h(views[viewRef.value], {
+ key: useKey ? viewRef.value : undefined
+ })
+ )
: null
}
}
render(h(App), root)
expect(serializeInner(root)).toBe(`one
`)
- assertHookCalls(one, [1, 1, 1, 0, 0])
- assertHookCalls(two, [0, 0, 0, 0, 0])
+ assertHookCalls(one, [1, 1, 1, 1, 0, 0, 0])
+ assertHookCalls(two, [0, 0, 0, 0, 0, 0, 0])
viewRef.value = 'two'
await nextTick()
expect(serializeInner(root)).toBe(`two
`)
- assertHookCalls(one, [1, 1, 1, 1, 0])
- assertHookCalls(two, [1, 1, 0, 0, 0])
+ assertHookCalls(one, [1, 1, 1, 1, 1, 1, 0])
+ assertHookCalls(two, [1, 1, 0, 0, 0, 0, 0])
viewRef.value = 'one'
await nextTick()
expect(serializeInner(root)).toBe(`one
`)
- assertHookCalls(one, [1, 1, 2, 1, 0])
- assertHookCalls(two, [1, 1, 0, 0, 1])
+ assertHookCalls(one, [1, 1, 2, 2, 1, 1, 0])
+ assertHookCalls(two, [1, 1, 0, 0, 0, 0, 1])
viewRef.value = 'two'
await nextTick()
expect(serializeInner(root)).toBe(`two
`)
- assertHookCalls(one, [1, 1, 2, 2, 0])
- assertHookCalls(two, [2, 2, 0, 0, 1])
+ assertHookCalls(one, [1, 1, 2, 2, 2, 2, 0])
+ assertHookCalls(two, [2, 2, 0, 0, 0, 0, 1])
// teardown
outerRef.value = false
await nextTick()
expect(serializeInner(root)).toBe(``)
- assertHookCalls(one, [1, 1, 2, 2, 1])
- assertHookCalls(two, [2, 2, 0, 0, 2])
+ assertHookCalls(one, [1, 1, 2, 2, 2, 2, 1])
+ assertHookCalls(two, [2, 2, 0, 0, 0, 0, 2])
}
describe('props', () => {
@@ -387,13 +408,50 @@ describe('KeepAlive', () => {
await assertNameMatch({ include: 'one,two', exclude: 'two' })
})
+ test('include (string) w/ matchBy key', async () => {
+ await assertNameMatch({ include: 'one', matchBy: 'key' }, true)
+ })
+
+ test('include (regex) w/ matchBy key', async () => {
+ await assertNameMatch({ include: /^one$/, matchBy: 'key' }, true)
+ })
+
+ test('include (array) w/ matchBy key', async () => {
+ await assertNameMatch({ include: ['one'], matchBy: 'key' }, true)
+ })
+
+ test('exclude (string) w/ matchBy key', async () => {
+ await assertNameMatch({ exclude: 'two', matchBy: 'key' }, true)
+ })
+
+ test('exclude (regex) w/ matchBy key', async () => {
+ await assertNameMatch({ exclude: /^two$/, matchBy: 'key' }, true)
+ })
+
+ test('exclude (array) w/ matchBy key', async () => {
+ await assertNameMatch({ exclude: ['two'], matchBy: 'key' }, true)
+ })
+
+ test('include + exclude w/ matchBy key', async () => {
+ await assertNameMatch(
+ { include: 'one,two', exclude: 'two', matchBy: 'key' },
+ true
+ )
+ })
+
test('max', async () => {
const spyAC = jest.fn()
const spyBC = jest.fn()
const spyCC = jest.fn()
+ const spyABA = jest.fn()
+ const spyBBA = jest.fn()
+ const spyCBA = jest.fn()
const spyAA = jest.fn()
const spyBA = jest.fn()
const spyCA = jest.fn()
+ const spyABDA = jest.fn()
+ const spyBBDA = jest.fn()
+ const spyCBDA = jest.fn()
const spyADA = jest.fn()
const spyBDA = jest.fn()
const spyCDA = jest.fn()
@@ -404,15 +462,21 @@ describe('KeepAlive', () => {
function assertCount(calls: number[]) {
expect([
spyAC.mock.calls.length,
+ spyABA.mock.calls.length,
spyAA.mock.calls.length,
+ spyABDA.mock.calls.length,
spyADA.mock.calls.length,
spyAUM.mock.calls.length,
spyBC.mock.calls.length,
+ spyBBA.mock.calls.length,
spyBA.mock.calls.length,
+ spyBBDA.mock.calls.length,
spyBDA.mock.calls.length,
spyBUM.mock.calls.length,
spyCC.mock.calls.length,
+ spyCBA.mock.calls.length,
spyCA.mock.calls.length,
+ spyCBDA.mock.calls.length,
spyCDA.mock.calls.length,
spyCUM.mock.calls.length
]).toEqual(calls)
@@ -423,21 +487,27 @@ describe('KeepAlive', () => {
a: {
render: () => `one`,
created: spyAC,
+ beforeActivate: spyABA,
activated: spyAA,
+ beforeDeactivate: spyABDA,
deactivated: spyADA,
unmounted: spyAUM
},
b: {
render: () => `two`,
created: spyBC,
+ beforeActivate: spyBBA,
activated: spyBA,
+ beforeDeactivate: spyBBDA,
deactivated: spyBDA,
unmounted: spyBUM
},
c: {
render: () => `three`,
created: spyCC,
+ beforeActivate: spyCBA,
activated: spyCA,
+ beforeDeactivate: spyCBDA,
deactivated: spyCDA,
unmounted: spyCUM
}
@@ -451,26 +521,26 @@ describe('KeepAlive', () => {
}
}
render(h(App), root)
- assertCount([1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0])
+ assertCount([1, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0])
viewRef.value = 'b'
await nextTick()
- assertCount([1, 1, 1, 0, 1, 1, 0, 0, 0, 0, 0, 0])
+ assertCount([1, 1, 1, 1, 1, 0, 1, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0])
viewRef.value = 'c'
await nextTick()
// should prune A because max cache reached
- assertCount([1, 1, 1, 1, 1, 1, 1, 0, 1, 1, 0, 0])
+ assertCount([1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 1, 1, 1, 0, 0, 0])
viewRef.value = 'b'
await nextTick()
// B should be reused, and made latest
- assertCount([1, 1, 1, 1, 1, 2, 1, 0, 1, 1, 1, 0])
+ assertCount([1, 1, 1, 1, 1, 1, 1, 2, 2, 1, 1, 0, 1, 1, 1, 1, 1, 0])
viewRef.value = 'a'
await nextTick()
// C should be pruned because B was used last so C is the oldest cached
- assertCount([2, 2, 1, 1, 1, 2, 2, 0, 1, 1, 1, 1])
+ assertCount([2, 2, 2, 1, 1, 1, 1, 2, 2, 2, 2, 0, 1, 1, 1, 1, 1, 1])
})
})
@@ -516,18 +586,18 @@ describe('KeepAlive', () => {
viewRef.value = 'two'
await nextTick()
- assertHookCalls(one, [1, 1, 1, 1, 0])
- assertHookCalls(two, [1, 1, 1, 0, 0])
+ assertHookCalls(one, [1, 1, 1, 1, 1, 1, 0])
+ assertHookCalls(two, [1, 1, 1, 1, 0, 0, 0])
includeRef.value = 'two'
await nextTick()
- assertHookCalls(one, [1, 1, 1, 1, 1])
- assertHookCalls(two, [1, 1, 1, 0, 0])
+ assertHookCalls(one, [1, 1, 1, 1, 1, 1, 1])
+ assertHookCalls(two, [1, 1, 1, 1, 0, 0, 0])
viewRef.value = 'one'
await nextTick()
- assertHookCalls(one, [2, 2, 1, 1, 1])
- assertHookCalls(two, [1, 1, 1, 1, 0])
+ assertHookCalls(one, [2, 2, 1, 1, 1, 1, 1])
+ assertHookCalls(two, [1, 1, 1, 1, 1, 1, 0])
})
test('on exclude change', async () => {
@@ -535,18 +605,18 @@ describe('KeepAlive', () => {
viewRef.value = 'two'
await nextTick()
- assertHookCalls(one, [1, 1, 1, 1, 0])
- assertHookCalls(two, [1, 1, 1, 0, 0])
+ assertHookCalls(one, [1, 1, 1, 1, 1, 1, 0])
+ assertHookCalls(two, [1, 1, 1, 1, 0, 0, 0])
excludeRef.value = 'one'
await nextTick()
- assertHookCalls(one, [1, 1, 1, 1, 1])
- assertHookCalls(two, [1, 1, 1, 0, 0])
+ assertHookCalls(one, [1, 1, 1, 1, 1, 1, 1])
+ assertHookCalls(two, [1, 1, 1, 1, 0, 0, 0])
viewRef.value = 'one'
await nextTick()
- assertHookCalls(one, [2, 2, 1, 1, 1])
- assertHookCalls(two, [1, 1, 1, 1, 0])
+ assertHookCalls(one, [2, 2, 1, 1, 1, 1, 1])
+ assertHookCalls(two, [1, 1, 1, 1, 1, 1, 0])
})
test('on include change + view switch', async () => {
@@ -554,15 +624,15 @@ describe('KeepAlive', () => {
viewRef.value = 'two'
await nextTick()
- assertHookCalls(one, [1, 1, 1, 1, 0])
- assertHookCalls(two, [1, 1, 1, 0, 0])
+ assertHookCalls(one, [1, 1, 1, 1, 1, 1, 0])
+ assertHookCalls(two, [1, 1, 1, 1, 0, 0, 0])
includeRef.value = 'one'
viewRef.value = 'one'
await nextTick()
- assertHookCalls(one, [1, 1, 2, 1, 0])
+ assertHookCalls(one, [1, 1, 2, 2, 1, 1, 0])
// two should be pruned
- assertHookCalls(two, [1, 1, 1, 1, 1])
+ assertHookCalls(two, [1, 1, 1, 1, 1, 1, 1])
})
test('on exclude change + view switch', async () => {
@@ -570,15 +640,15 @@ describe('KeepAlive', () => {
viewRef.value = 'two'
await nextTick()
- assertHookCalls(one, [1, 1, 1, 1, 0])
- assertHookCalls(two, [1, 1, 1, 0, 0])
+ assertHookCalls(one, [1, 1, 1, 1, 1, 1, 0])
+ assertHookCalls(two, [1, 1, 1, 1, 0, 0, 0])
excludeRef.value = 'two'
viewRef.value = 'one'
await nextTick()
- assertHookCalls(one, [1, 1, 2, 1, 0])
+ assertHookCalls(one, [1, 1, 2, 2, 1, 1, 0])
// two should be pruned
- assertHookCalls(two, [1, 1, 1, 1, 1])
+ assertHookCalls(two, [1, 1, 1, 1, 1, 1, 1])
})
test('should not prune current active instance', async () => {
@@ -586,13 +656,13 @@ describe('KeepAlive', () => {
includeRef.value = 'two'
await nextTick()
- assertHookCalls(one, [1, 1, 1, 0, 0])
- assertHookCalls(two, [0, 0, 0, 0, 0])
+ assertHookCalls(one, [1, 1, 1, 1, 0, 0, 0])
+ assertHookCalls(two, [0, 0, 0, 0, 0, 0, 0])
viewRef.value = 'two'
await nextTick()
- assertHookCalls(one, [1, 1, 1, 0, 1])
- assertHookCalls(two, [1, 1, 1, 0, 0])
+ assertHookCalls(one, [1, 1, 1, 1, 0, 0, 1])
+ assertHookCalls(two, [1, 1, 1, 1, 0, 0, 0])
})
async function assertAnonymous(include: boolean) {
@@ -823,4 +893,206 @@ describe('KeepAlive', () => {
await nextTick()
expect(serializeInner(root)).toBe(`changed
`)
})
+
+ test('reuse vnode', async () => {
+ const Comp = {
+ render() {
+ return 'one'
+ },
+ activated: jest.fn()
+ }
+ const reused = h(Comp)
+
+ const App = {
+ render() {
+ return [
+ reused, // `reused.el` will exist, it will be cloned in subsequent rendering
+ h(KeepAlive, null, {
+ // reuse here
+ default: () => h(reused)
+ })
+ ]
+ }
+ }
+
+ render(h(App), root)
+ await nextTick()
+ expect(serializeInner(root)).toBe(`oneone`)
+ expect(Comp.activated).toHaveBeenCalledTimes(1)
+ })
+
+ test('custom caching strategy', async () => {
+ const _cache = new Map()
+ const cache: KeepAliveCache = {
+ get(key) {
+ _cache.get(key)
+ },
+ set(key, value) {
+ _cache.set(key, value)
+ },
+ delete(key) {
+ _cache.delete(key)
+ },
+ forEach(fn) {
+ _cache.forEach(fn)
+ }
+ }
+
+ const viewRef = ref('one')
+ const toggle = ref(true)
+ const instanceRef = ref(null)
+ const App = {
+ render: () => {
+ return toggle.value
+ ? h(
+ KeepAlive,
+ { cache },
+ {
+ default: () => h(views[viewRef.value], { ref: instanceRef })
+ }
+ )
+ : null
+ }
+ }
+
+ render(h(App), root)
+ expect(cache.pruneCacheEntry).toBeDefined()
+ await nextTick()
+ expect(serializeInner(root)).toBe(`one
`)
+ expect(_cache.size).toBe(1)
+ expect([..._cache.keys()]).toEqual([one])
+ assertHookCalls(one, [1, 1, 1, 1, 0, 0, 0])
+ assertHookCalls(two, [0, 0, 0, 0, 0, 0, 0])
+
+ instanceRef.value.msg = 'changed'
+ await nextTick()
+ expect(serializeInner(root)).toBe(`changed
`)
+
+ viewRef.value = 'two'
+ await nextTick()
+ expect(serializeInner(root)).toBe(`two
`)
+ expect(_cache.size).toBe(2)
+ expect([..._cache.keys()]).toEqual([one, two])
+ assertHookCalls(one, [1, 1, 1, 1, 1, 1, 0])
+ assertHookCalls(two, [1, 1, 1, 1, 0, 0, 0])
+
+ // delete the cache manually
+ const itr = _cache.keys()
+ const key1 = itr.next().value
+ cache.pruneCacheEntry!(_cache.get(key1))
+ _cache.delete(key1)
+ await nextTick()
+ assertHookCalls(one, [1, 1, 1, 1, 1, 1, 1])
+
+ viewRef.value = 'one'
+ await nextTick()
+ expect(serializeInner(root)).toBe(`one
`)
+ expect(_cache.size).toBe(2)
+ expect([..._cache.keys()]).toEqual([two, one])
+ assertHookCalls(one, [2, 2, 2, 2, 1, 1, 1])
+ assertHookCalls(two, [1, 1, 1, 1, 1, 1, 0])
+
+ toggle.value = false
+ await nextTick()
+ expect(_cache.size).toBe(0)
+ assertHookCalls(one, [2, 2, 2, 2, 2, 2, 2])
+ assertHookCalls(two, [1, 1, 1, 1, 1, 1, 1])
+ })
+
+ test('warn custom cache with the `max` prop', () => {
+ const _cache = new Map()
+ const cache: KeepAliveCache = {
+ get(key) {
+ _cache.get(key)
+ },
+ set(key, value) {
+ _cache.set(key, value)
+ },
+ delete(key) {
+ _cache.delete(key)
+ },
+ forEach(fn) {
+ _cache.forEach(fn)
+ }
+ }
+ render(
+ h({
+ render: () => {
+ return h(
+ KeepAlive,
+ { cache, max: 10 },
+ {
+ default: () => h(one)
+ }
+ )
+ }
+ }),
+ root
+ )
+
+ expect(
+ 'The `max` prop will be ignored if you provide a custom caching strategy'
+ ).toHaveBeenWarned()
+ })
+
+ test('dynamic key changes', async () => {
+ const cache = new Cache()
+ const dynamicKey = ref(0)
+ const toggle = ref(true)
+ const Comp = {
+ mounted: jest.fn(),
+ beforeActivate: jest.fn(),
+ activated: jest.fn(),
+ beforeDeactivate: jest.fn(),
+ deactivated: jest.fn(),
+ unmounted: jest.fn(),
+ render() {
+ return 'one'
+ }
+ }
+ function assertCount(calls: number[]) {
+ expect([
+ Comp.mounted.mock.calls.length,
+ Comp.beforeActivate.mock.calls.length,
+ Comp.activated.mock.calls.length,
+ Comp.beforeDeactivate.mock.calls.length,
+ Comp.deactivated.mock.calls.length,
+ Comp.unmounted.mock.calls.length
+ ]).toEqual(calls)
+ }
+
+ const App = {
+ render: () => {
+ return toggle.value
+ ? h(
+ KeepAlive,
+ { cache, matchBy: 'key' },
+ {
+ default: () => h(Comp, { key: dynamicKey.value })
+ }
+ )
+ : null
+ }
+ }
+
+ render(h(App), root)
+ await nextTick()
+ expect(serializeInner(root)).toBe(`one`)
+ assertCount([1, 1, 1, 0, 0, 0])
+ expect((cache as any)._cache.size).toBe(1)
+ expect([...(cache as any)._cache.keys()]).toEqual([0])
+
+ dynamicKey.value = 1
+ await nextTick()
+ expect(serializeInner(root)).toBe(`one`)
+ assertCount([2, 2, 2, 1, 1, 0])
+ expect((cache as any)._cache.size).toBe(2)
+ expect([...(cache as any)._cache.keys()]).toEqual([0, 1])
+
+ toggle.value = false
+ await nextTick()
+ expect(serializeInner(root)).toBe(``)
+ assertCount([2, 2, 2, 2, 2, 2])
+ expect((cache as any)._cache.size).toBe(0)
+ })
})
diff --git a/packages/runtime-core/src/apiLifecycle.ts b/packages/runtime-core/src/apiLifecycle.ts
index 4d7b53d36a7..92966c36ced 100644
--- a/packages/runtime-core/src/apiLifecycle.ts
+++ b/packages/runtime-core/src/apiLifecycle.ts
@@ -11,7 +11,12 @@ import { warn } from './warning'
import { toHandlerKey } from '@vue/shared'
import { DebuggerEvent, pauseTracking, resetTracking } from '@vue/reactivity'
-export { onActivated, onDeactivated } from './components/KeepAlive'
+export {
+ onActivated,
+ onDeactivated,
+ onBeforeActivate,
+ onBeforeDeactivate
+} from './components/KeepAlive'
export function injectHook(
type: LifecycleHooks,
diff --git a/packages/runtime-core/src/component.ts b/packages/runtime-core/src/component.ts
index 6e2c1d53d78..9b622354874 100644
--- a/packages/runtime-core/src/component.ts
+++ b/packages/runtime-core/src/component.ts
@@ -157,7 +157,9 @@ export const enum LifecycleHooks {
UPDATED = 'u',
BEFORE_UNMOUNT = 'bum',
UNMOUNTED = 'um',
+ BEFORE_DEACTIVATE = 'bda',
DEACTIVATED = 'da',
+ BEFORE_ACTIVATE = 'ba',
ACTIVATED = 'a',
RENDER_TRIGGERED = 'rtg',
RENDER_TRACKED = 'rtc',
@@ -386,10 +388,18 @@ export interface ComponentInternalInstance {
* @internal
*/
[LifecycleHooks.RENDER_TRIGGERED]: LifecycleHook
+ /**
+ * @internal
+ */
+ [LifecycleHooks.BEFORE_ACTIVATE]: LifecycleHook
/**
* @internal
*/
[LifecycleHooks.ACTIVATED]: LifecycleHook
+ /**
+ * @internal
+ */
+ [LifecycleHooks.BEFORE_DEACTIVATE]: LifecycleHook
/**
* @internal
*/
@@ -477,7 +487,9 @@ export function createComponentInstance(
u: null,
um: null,
bum: null,
+ bda: null,
da: null,
+ ba: null,
a: null,
rtg: null,
rtc: null,
diff --git a/packages/runtime-core/src/componentOptions.ts b/packages/runtime-core/src/componentOptions.ts
index e00206baa00..38594175af0 100644
--- a/packages/runtime-core/src/componentOptions.ts
+++ b/packages/runtime-core/src/componentOptions.ts
@@ -31,7 +31,9 @@ import {
onRenderTracked,
onBeforeUnmount,
onUnmounted,
+ onBeforeActivate,
onActivated,
+ onBeforeDeactivate,
onDeactivated,
onRenderTriggered,
DebuggerHook,
@@ -410,7 +412,9 @@ interface LegacyOptions<
mounted?(): void
beforeUpdate?(): void
updated?(): void
+ beforeActivate?(): void
activated?(): void
+ beforeDeactivate?(): void
deactivated?(): void
/** @deprecated use `beforeUnmount` instead */
beforeDestroy?(): void
@@ -504,7 +508,9 @@ export function applyOptions(
mounted,
beforeUpdate,
updated,
+ beforeActivate,
activated,
+ beforeDeactivate,
deactivated,
beforeDestroy,
beforeUnmount,
@@ -775,9 +781,15 @@ export function applyOptions(
if (updated) {
onUpdated(updated.bind(publicThis))
}
+ if (beforeActivate) {
+ onBeforeActivate(beforeActivate.bind(publicThis))
+ }
if (activated) {
onActivated(activated.bind(publicThis))
}
+ if (beforeDeactivate) {
+ onBeforeDeactivate(beforeDeactivate.bind(publicThis))
+ }
if (deactivated) {
onDeactivated(deactivated.bind(publicThis))
}
diff --git a/packages/runtime-core/src/components/KeepAlive.ts b/packages/runtime-core/src/components/KeepAlive.ts
index cbba10fd755..bc76d535669 100644
--- a/packages/runtime-core/src/components/KeepAlive.ts
+++ b/packages/runtime-core/src/components/KeepAlive.ts
@@ -21,7 +21,8 @@ import {
isArray,
ShapeFlags,
remove,
- invokeArrayFns
+ invokeArrayFns,
+ hasOwn
} from '@vue/shared'
import { watch } from '../apiWatch'
import {
@@ -34,6 +35,7 @@ import {
} from '../renderer'
import { setTransitionHooks } from './BaseTransition'
import { ComponentRenderContext } from '../componentPublicInstance'
+import { isSuspense } from './Suspense'
type MatchPattern = string | RegExp | string[] | RegExp[]
@@ -41,10 +43,11 @@ export interface KeepAliveProps {
include?: MatchPattern
exclude?: MatchPattern
max?: number | string
+ matchBy?: 'name' | 'key'
+ cache?: KeepAliveCache
}
type CacheKey = string | number | ConcreteComponent
-type Cache = Map
type Keys = Set
export interface KeepAliveContext extends ComponentRenderContext {
@@ -62,6 +65,60 @@ export interface KeepAliveContext extends ComponentRenderContext {
export const isKeepAlive = (vnode: VNode): boolean =>
(vnode.type as any).__isKeepAlive
+export interface KeepAliveCache {
+ get(key: CacheKey): VNode | void
+ set(key: CacheKey, value: VNode): void
+ delete(key: CacheKey): void
+ forEach(
+ fn: (value: VNode, key: CacheKey, map: Map) => void,
+ thisArg?: any
+ ): void
+ pruneCacheEntry?: (cached: VNode) => void
+}
+
+export class Cache implements KeepAliveCache {
+ private readonly _cache = new Map()
+ private readonly _keys: Keys = new Set()
+ private readonly _max?: number
+ public pruneCacheEntry!: (cached: VNode) => void
+
+ constructor(readonly max?: string | number) {
+ this._max = parseInt(max as string, 10)
+ }
+
+ get(key: CacheKey) {
+ const { _cache, _keys, _max } = this
+ const cached = _cache.get(key)
+ if (cached) {
+ // make this key the freshest
+ _keys.delete(key)
+ _keys.add(key)
+ } else {
+ _keys.add(key)
+ // prune oldest entry
+ if (_max && _keys.size > _max) {
+ const staleKey = _keys.values().next().value
+ this.pruneCacheEntry(_cache.get(staleKey)!)
+ this.delete(staleKey)
+ }
+ }
+ return cached
+ }
+ set(key: CacheKey, value: VNode) {
+ this._cache.set(key, value)
+ }
+ delete(key: CacheKey) {
+ this._cache.delete(key)
+ this._keys.delete(key)
+ }
+ forEach(
+ fn: (value: VNode, key: CacheKey, map: Map) => void,
+ thisArg?: any
+ ) {
+ this._cache.forEach(fn.bind(thisArg))
+ }
+}
+
const KeepAliveImpl = {
name: `KeepAlive`,
@@ -73,7 +130,12 @@ const KeepAliveImpl = {
props: {
include: [String, RegExp, Array],
exclude: [String, RegExp, Array],
- max: [String, Number]
+ max: [String, Number],
+ matchBy: {
+ type: String,
+ default: 'name'
+ },
+ cache: Object
},
setup(props: KeepAliveProps, { slots }: SetupContext) {
@@ -91,9 +153,30 @@ const KeepAliveImpl = {
return slots.default
}
- const cache: Cache = new Map()
- const keys: Keys = new Set()
+ if (__DEV__ && props.cache && hasOwn(props, 'max')) {
+ warn(
+ 'The `max` prop will be ignored if you provide a custom caching strategy'
+ )
+ }
+
+ const cache = props.cache || new Cache(props.max)
+ cache.pruneCacheEntry = pruneCacheEntry
+
let current: VNode | null = null
+ function pruneCacheEntry(cached: VNode) {
+ if (
+ !current ||
+ cached.type !== current.type ||
+ (props.matchBy === 'key' && cached.key !== current.key)
+ ) {
+ unmount(cached)
+ } else if (current) {
+ // current active instance should no longer be kept-alive.
+ // we can't unmount it now but it might be later, so reset its flag now.
+ resetShapeFlag(current)
+ }
+ }
+
const parentSuspense = instance.suspense
@@ -109,6 +192,12 @@ const KeepAliveImpl = {
sharedContext.activate = (vnode, container, anchor, isSVG, optimized) => {
const instance = vnode.component!
+ if (instance.ba) {
+ const currentState = instance.isDeactivated
+ instance.isDeactivated = false
+ invokeArrayFns(instance.ba)
+ instance.isDeactivated = currentState
+ }
move(vnode, container, anchor, MoveType.ENTER, parentSuspense)
// in case props have changed
patch(
@@ -136,8 +225,14 @@ const KeepAliveImpl = {
sharedContext.deactivate = (vnode: VNode) => {
const instance = vnode.component!
+ if (instance.bda) {
+ invokeKeepAliveHooks(instance.bda)
+ }
move(vnode, storageContainer, null, MoveType.LEAVE, parentSuspense)
queuePostRenderEffect(() => {
+ if (instance.bda) {
+ resetHookState(instance.bda)
+ }
if (instance.da) {
invokeArrayFns(instance.da)
}
@@ -157,29 +252,17 @@ const KeepAliveImpl = {
function pruneCache(filter?: (name: string) => boolean) {
cache.forEach((vnode, key) => {
- const name = getComponentName(vnode.type as ConcreteComponent)
+ const name = getMatchingName(vnode, props.matchBy!)
if (name && (!filter || !filter(name))) {
- pruneCacheEntry(key)
+ cache.delete(key)
+ pruneCacheEntry(vnode)
}
})
}
- function pruneCacheEntry(key: CacheKey) {
- const cached = cache.get(key) as VNode
- if (!current || cached.type !== current.type) {
- unmount(cached)
- } else if (current) {
- // current active instance should no longer be kept-alive.
- // we can't unmount it now but it might be later, so reset its flag now.
- resetShapeFlag(current)
- }
- cache.delete(key)
- keys.delete(key)
- }
-
// prune cache on include/exclude prop change
watch(
- () => [props.include, props.exclude],
+ () => [props.include, props.exclude, props.matchBy],
([include, exclude]) => {
include && pruneCache(name => matches(include, name))
exclude && pruneCache(name => !matches(exclude, name))
@@ -200,10 +283,19 @@ const KeepAliveImpl = {
onUpdated(cacheSubtree)
onBeforeUnmount(() => {
- cache.forEach(cached => {
+ cache.forEach((cached, key) => {
+ cache.delete(key)
+ pruneCacheEntry(cached)
const { subTree, suspense } = instance
const vnode = getInnerChild(subTree)
- if (cached.type === vnode.type) {
+ if (
+ cached.type === vnode.type &&
+ (props.matchBy !== 'key' || cached.key === vnode.key)
+ ) {
+ // invoke its beforeDeactivate hook here
+ if (vnode.component!.bda) {
+ invokeArrayFns(vnode.component!.bda)
+ }
// current instance will be unmounted as part of keep-alive's unmount
resetShapeFlag(vnode)
// but invoke its deactivated hook here
@@ -211,7 +303,6 @@ const KeepAliveImpl = {
da && queuePostRenderEffect(da, suspense)
return
}
- unmount(cached)
})
})
@@ -233,7 +324,7 @@ const KeepAliveImpl = {
} else if (
!isVNode(rawVNode) ||
(!(rawVNode.shapeFlag & ShapeFlags.STATEFUL_COMPONENT) &&
- !(rawVNode.shapeFlag & ShapeFlags.SUSPENSE))
+ !isSuspense(rawVNode.type))
) {
current = null
return rawVNode
@@ -241,8 +332,8 @@ const KeepAliveImpl = {
let vnode = getInnerChild(rawVNode)
const comp = vnode.type as ConcreteComponent
- const name = getComponentName(comp)
- const { include, exclude, max } = props
+ const name = getMatchingName(vnode, props.matchBy!)
+ const { include, exclude } = props
if (
(include && (!name || !matches(include, name))) ||
@@ -258,7 +349,7 @@ const KeepAliveImpl = {
// clone vnode if it's reused because we are going to mutate it
if (vnode.el) {
vnode = cloneVNode(vnode)
- if (rawVNode.shapeFlag & ShapeFlags.SUSPENSE) {
+ if (isSuspense(rawVNode.type)) {
rawVNode.ssContent = vnode
}
}
@@ -279,21 +370,12 @@ const KeepAliveImpl = {
}
// avoid vnode being mounted as fresh
vnode.shapeFlag |= ShapeFlags.COMPONENT_KEPT_ALIVE
- // make this key the freshest
- keys.delete(key)
- keys.add(key)
- } else {
- keys.add(key)
- // prune oldest entry
- if (max && keys.size > parseInt(max as string, 10)) {
- pruneCacheEntry(keys.values().next().value)
- }
}
// avoid vnode being unmounted
vnode.shapeFlag |= ShapeFlags.COMPONENT_SHOULD_KEEP_ALIVE
current = vnode
- return rawVNode
+ return isSuspense(rawVNode.type) ? rawVNode : vnode
}
}
}
@@ -319,6 +401,13 @@ function matches(pattern: MatchPattern, name: string): boolean {
return false
}
+export function onBeforeActivate(
+ hook: Function,
+ target?: ComponentInternalInstance | null
+) {
+ registerKeepAliveHook(hook, LifecycleHooks.BEFORE_ACTIVATE, target)
+}
+
export function onActivated(
hook: Function,
target?: ComponentInternalInstance | null
@@ -326,6 +415,13 @@ export function onActivated(
registerKeepAliveHook(hook, LifecycleHooks.ACTIVATED, target)
}
+export function onBeforeDeactivate(
+ hook: Function,
+ target?: ComponentInternalInstance | null
+) {
+ registerKeepAliveHook(hook, LifecycleHooks.BEFORE_DEACTIVATE, target)
+}
+
export function onDeactivated(
hook: Function,
target?: ComponentInternalInstance | null
@@ -333,6 +429,9 @@ export function onDeactivated(
registerKeepAliveHook(hook, LifecycleHooks.DEACTIVATED, target)
}
+// the beforeActivate/beforeDeactivate hook is called synchronously
+// and cannot be deduped by scheduler, so we need the `__called` flag
+export type WrappedHook = Function & { __called?: boolean }
function registerKeepAliveHook(
hook: Function & { __wdc?: Function },
type: LifecycleHooks,
@@ -341,7 +440,7 @@ function registerKeepAliveHook(
// cache the deactivate branch check wrapper for injected hooks so the same
// hook can be properly deduped by the scheduler. "__wdc" stands for "with
// deactivation check".
- const wrappedHook =
+ const wrappedHook: WrappedHook =
hook.__wdc ||
(hook.__wdc = () => {
// only fire the hook if the target instance is NOT in a deactivated branch.
@@ -354,6 +453,7 @@ function registerKeepAliveHook(
}
hook()
})
+ wrappedHook.__called = false
injectHook(type, wrappedHook, target)
// In addition to registering it on the target instance, we walk up the parent
// chain and register it on all ancestor instances that are keep-alive roots.
@@ -397,5 +497,26 @@ function resetShapeFlag(vnode: VNode) {
}
function getInnerChild(vnode: VNode) {
- return vnode.shapeFlag & ShapeFlags.SUSPENSE ? vnode.ssContent! : vnode
+ return isSuspense(vnode.type) ? vnode.ssContent! : vnode
+}
+
+function getMatchingName(vnode: VNode, matchBy: 'name' | 'key') {
+ if (matchBy === 'name') {
+ return getComponentName(vnode.type as ConcreteComponent)
+ }
+ return String(vnode.key)
+}
+
+export function invokeKeepAliveHooks(hooks: WrappedHook[]) {
+ for (let i = 0; i < hooks.length; i++) {
+ const hook = hooks[i]
+ if (!hook.__called) {
+ hook()
+ hook.__called = true
+ }
+ }
+}
+
+export function resetHookState(hooks: WrappedHook[]) {
+ hooks.forEach((hook: WrappedHook) => (hook.__called = false))
}
diff --git a/packages/runtime-core/src/index.ts b/packages/runtime-core/src/index.ts
index 6026d4c248d..e4050d635f5 100644
--- a/packages/runtime-core/src/index.ts
+++ b/packages/runtime-core/src/index.ts
@@ -33,7 +33,9 @@ export {
onUpdated,
onBeforeUnmount,
onUnmounted,
+ onBeforeActivate,
onActivated,
+ onBeforeDeactivate,
onDeactivated,
onRenderTracked,
onRenderTriggered,
diff --git a/packages/runtime-core/src/renderer.ts b/packages/runtime-core/src/renderer.ts
index 15f86faa438..e7f47ca8750 100644
--- a/packages/runtime-core/src/renderer.ts
+++ b/packages/runtime-core/src/renderer.ts
@@ -66,7 +66,12 @@ import {
SuspenseImpl
} from './components/Suspense'
import { TeleportImpl, TeleportVNode } from './components/Teleport'
-import { isKeepAlive, KeepAliveContext } from './components/KeepAlive'
+import {
+ isKeepAlive,
+ KeepAliveContext,
+ invokeKeepAliveHooks,
+ resetHookState
+} from './components/KeepAlive'
import { registerHMR, unregisterHMR, isHmrUpdating } from './hmr'
import {
ErrorCodes,
@@ -1465,14 +1470,14 @@ function baseCreateRenderer(
}, parentSuspense)
}
// activated hook for keep-alive roots.
- // #1742 activated hook must be accessed after first render
+ // #1742 beforeActivate/activated hook must be accessed after first render
// since the hook may be injected by a child keep-alive
- const { a } = instance
- if (
- a &&
- initialVNode.shapeFlag & ShapeFlags.COMPONENT_SHOULD_KEEP_ALIVE
- ) {
- queuePostRenderEffect(a, parentSuspense)
+ const { ba, a } = instance
+ if (initialVNode.shapeFlag & ShapeFlags.COMPONENT_SHOULD_KEEP_ALIVE) {
+ ba && invokeKeepAliveHooks(ba)
+ a && queuePostRenderEffect(a, parentSuspense)
+ // reset hook state
+ ba && queuePostRenderEffect(() => resetHookState(ba), parentSuspense)
}
instance.isMounted = true