From 4631f5323b1755451d952397a84395a096aeb4ce Mon Sep 17 00:00:00 2001 From: Evan You Date: Wed, 30 Oct 2019 23:32:29 -0400 Subject: [PATCH] test: more tests for keep-alive --- .../runtime-core/__tests__/keepAlive.spec.ts | 328 +++++++++++++++++- packages/runtime-core/src/createRenderer.ts | 12 +- packages/runtime-core/src/keepAlive.ts | 20 +- 3 files changed, 340 insertions(+), 20 deletions(-) diff --git a/packages/runtime-core/__tests__/keepAlive.spec.ts b/packages/runtime-core/__tests__/keepAlive.spec.ts index 54d8ce5d..d7af1b33 100644 --- a/packages/runtime-core/__tests__/keepAlive.spec.ts +++ b/packages/runtime-core/__tests__/keepAlive.spec.ts @@ -9,15 +9,18 @@ import { serializeInner, nextTick } from '@vue/runtime-test' +import { KeepAliveProps } from '../src/keepAlive' describe('keep-alive', () => { let one: ComponentOptions let two: ComponentOptions + let views: Record let root: TestElement beforeEach(() => { root = nodeOps.createElement('div') one = { + name: 'one', data: () => ({ msg: 'one' }), render() { return h('div', this.msg) @@ -29,6 +32,7 @@ describe('keep-alive', () => { unmounted: jest.fn() } two = { + name: 'two', data: () => ({ msg: 'two' }), render() { return h('div', this.msg) @@ -39,6 +43,10 @@ describe('keep-alive', () => { deactivated: jest.fn(), unmounted: jest.fn() } + views = { + one, + two + } }) function assertHookCalls(component: any, callCounts: number[]) { @@ -52,12 +60,12 @@ describe('keep-alive', () => { } test('should preserve state', async () => { - const toggle = ref(true) + const viewRef = ref('one') const instanceRef = ref(null) const App = { render() { return h(KeepAlive, null, { - default: () => h(toggle.value ? one : two, { ref: instanceRef }) + default: () => h(views[viewRef.value], { ref: instanceRef }) }) } } @@ -66,22 +74,20 @@ describe('keep-alive', () => { instanceRef.value.msg = 'changed' await nextTick() expect(serializeInner(root)).toBe(`
changed
`) - toggle.value = false + viewRef.value = 'two' await nextTick() expect(serializeInner(root)).toBe(`
two
`) - toggle.value = true + viewRef.value = 'one' await nextTick() expect(serializeInner(root)).toBe(`
changed
`) }) test('should call correct lifecycle hooks', async () => { - const toggle1 = ref(true) - const toggle2 = ref(true) + const toggle = ref(true) + const viewRef = ref('one') const App = { render() { - return toggle1.value - ? h(KeepAlive, () => h(toggle2.value ? one : two)) - : null + return toggle.value ? h(KeepAlive, () => h(views[viewRef.value])) : null } } render(h(App), root) @@ -91,26 +97,26 @@ describe('keep-alive', () => { assertHookCalls(two, [0, 0, 0, 0, 0]) // toggle kept-alive component - toggle2.value = false + viewRef.value = 'two' await nextTick() expect(serializeInner(root)).toBe(`
two
`) assertHookCalls(one, [1, 1, 1, 1, 0]) assertHookCalls(two, [1, 1, 1, 0, 0]) - toggle2.value = true + viewRef.value = 'one' await nextTick() expect(serializeInner(root)).toBe(`
one
`) assertHookCalls(one, [1, 1, 2, 1, 0]) assertHookCalls(two, [1, 1, 1, 1, 0]) - toggle2.value = false + viewRef.value = 'two' await nextTick() expect(serializeInner(root)).toBe(`
two
`) assertHookCalls(one, [1, 1, 2, 2, 0]) assertHookCalls(two, [1, 1, 2, 1, 0]) // teardown keep-alive, should unmount all components including cached - toggle1.value = false + toggle.value = false await nextTick() expect(serializeInner(root)).toBe(``) assertHookCalls(one, [1, 1, 2, 2, 1]) @@ -230,4 +236,300 @@ describe('keep-alive', () => { assertHookCalls(one, [1, 1, 4, 3, 0]) assertHookCalls(two, [1, 1, 4, 4, 0]) // should remain inactive }) + + async function assertNameMatch(props: KeepAliveProps) { + const outerRef = ref(true) + const viewRef = ref('one') + const App = { + render() { + return outerRef.value + ? h(KeepAlive, props, () => h(views[viewRef.value])) + : null + } + } + render(h(App), root) + + expect(serializeInner(root)).toBe(`
one
`) + assertHookCalls(one, [1, 1, 1, 0, 0]) + assertHookCalls(two, [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]) + + viewRef.value = 'one' + await nextTick() + expect(serializeInner(root)).toBe(`
one
`) + assertHookCalls(one, [1, 1, 2, 1, 0]) + assertHookCalls(two, [1, 1, 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]) + + // teardown + outerRef.value = false + await nextTick() + expect(serializeInner(root)).toBe(``) + assertHookCalls(one, [1, 1, 2, 2, 1]) + assertHookCalls(two, [2, 2, 0, 0, 2]) + } + + describe('props', () => { + test('include (string)', async () => { + await assertNameMatch({ include: 'one' }) + }) + + test('include (regex)', async () => { + await assertNameMatch({ include: /^one$/ }) + }) + + test('include (array)', async () => { + await assertNameMatch({ include: ['one'] }) + }) + + test('exclude (string)', async () => { + await assertNameMatch({ exclude: 'two' }) + }) + + test('exclude (regex)', async () => { + await assertNameMatch({ exclude: /^two$/ }) + }) + + test('exclude (array)', async () => { + await assertNameMatch({ exclude: ['two'] }) + }) + + test('include + exclude', async () => { + await assertNameMatch({ include: 'one,two', exclude: 'two' }) + }) + + test('max', async () => { + const spyA = jest.fn() + const spyB = jest.fn() + const spyC = jest.fn() + const spyAD = jest.fn() + const spyBD = jest.fn() + const spyCD = jest.fn() + + function assertCount(calls: number[]) { + expect([ + spyA.mock.calls.length, + spyAD.mock.calls.length, + spyB.mock.calls.length, + spyBD.mock.calls.length, + spyC.mock.calls.length, + spyCD.mock.calls.length + ]).toEqual(calls) + } + + const viewRef = ref('a') + const views: Record = { + a: { + render: () => `one`, + created: spyA, + unmounted: spyAD + }, + b: { + render: () => `two`, + created: spyB, + unmounted: spyBD + }, + c: { + render: () => `three`, + created: spyC, + unmounted: spyCD + } + } + + const App = { + render() { + return h(KeepAlive, { max: 2 }, () => { + return h(views[viewRef.value]) + }) + } + } + render(h(App), root) + assertCount([1, 0, 0, 0, 0, 0]) + + viewRef.value = 'b' + await nextTick() + assertCount([1, 0, 1, 0, 0, 0]) + + viewRef.value = 'c' + await nextTick() + // should prune A because max cache reached + assertCount([1, 1, 1, 0, 1, 0]) + + viewRef.value = 'b' + await nextTick() + // B should be reused, and made latest + assertCount([1, 1, 1, 0, 1, 0]) + + viewRef.value = 'a' + await nextTick() + // C should be pruned because B was used last so C is the oldest cached + assertCount([2, 1, 1, 0, 1, 1]) + }) + }) + + describe('cache invalidation', () => { + function setup() { + const viewRef = ref('one') + const includeRef = ref('one,two') + const App = { + render() { + return h( + KeepAlive, + { + include: includeRef.value + }, + () => h(views[viewRef.value]) + ) + } + } + render(h(App), root) + return { viewRef, includeRef } + } + + test('on include/exclude change', async () => { + const { viewRef, includeRef } = setup() + + viewRef.value = 'two' + await nextTick() + assertHookCalls(one, [1, 1, 1, 1, 0]) + assertHookCalls(two, [1, 1, 1, 0, 0]) + + includeRef.value = 'two' + await nextTick() + assertHookCalls(one, [1, 1, 1, 1, 1]) + assertHookCalls(two, [1, 1, 1, 0, 0]) + + viewRef.value = 'one' + await nextTick() + assertHookCalls(one, [2, 2, 1, 1, 1]) + assertHookCalls(two, [1, 1, 1, 1, 0]) + }) + + test('on include/exclude change + view switch', async () => { + const { viewRef, includeRef } = setup() + + viewRef.value = 'two' + await nextTick() + assertHookCalls(one, [1, 1, 1, 1, 0]) + assertHookCalls(two, [1, 1, 1, 0, 0]) + + includeRef.value = 'one' + viewRef.value = 'one' + await nextTick() + assertHookCalls(one, [1, 1, 2, 1, 0]) + // two should be pruned + assertHookCalls(two, [1, 1, 1, 1, 1]) + }) + + test('should not prune current active instance', async () => { + const { viewRef, includeRef } = setup() + + includeRef.value = 'two' + await nextTick() + assertHookCalls(one, [1, 1, 1, 0, 0]) + assertHookCalls(two, [0, 0, 0, 0, 0]) + + viewRef.value = 'two' + await nextTick() + assertHookCalls(one, [1, 1, 1, 0, 1]) + assertHookCalls(two, [1, 1, 1, 0, 0]) + }) + + async function assertAnonymous(include: boolean) { + const one = { + name: 'one', + created: jest.fn(), + render: () => 'one' + } + + const two = { + // anonymous + created: jest.fn(), + render: () => 'two' + } + + const views: any = { one, two } + const viewRef = ref('one') + + const App = { + render() { + return h( + KeepAlive, + { + include: include ? 'one' : undefined + }, + () => h(views[viewRef.value]) + ) + } + } + render(h(App), root) + + function assert(oneCreateCount: number, twoCreateCount: number) { + expect(one.created.mock.calls.length).toBe(oneCreateCount) + expect(two.created.mock.calls.length).toBe(twoCreateCount) + } + + assert(1, 0) + + viewRef.value = 'two' + await nextTick() + assert(1, 1) + + viewRef.value = 'one' + await nextTick() + assert(1, 1) + + viewRef.value = 'two' + await nextTick() + // two should be re-created if include is specified, since it's not matched + // otherwise it should be cached. + assert(1, include ? 2 : 1) + } + + // 2.x #6938 + test('should not cache anonymous component when include is specified', async () => { + await assertAnonymous(true) + }) + + test('should cache anonymous components if include is not specified', async () => { + await assertAnonymous(false) + }) + + // 2.x #7105 + test('should not destroy active instance when pruning cache', async () => { + const Foo = { + render: () => 'foo', + unmounted: jest.fn() + } + const includeRef = ref(['foo']) + const App = { + render() { + return h( + KeepAlive, + { + include: includeRef.value + }, + () => h(Foo) + ) + } + } + render(h(App), root) + // condition: a render where a previous component is reused + includeRef.value = ['foo', 'bar'] + await nextTick() + includeRef.value = [] + await nextTick() + expect(Foo.unmounted).not.toHaveBeenCalled() + }) + }) }) diff --git a/packages/runtime-core/src/createRenderer.ts b/packages/runtime-core/src/createRenderer.ts index 25d6e3ad..7cd7f23d 100644 --- a/packages/runtime-core/src/createRenderer.ts +++ b/packages/runtime-core/src/createRenderer.ts @@ -901,7 +901,11 @@ export function createRenderer< queuePostRenderEffect(instance.m, parentSuspense) } // activated hook for keep-alive roots. - if (instance.a !== null) { + if ( + instance.a !== null && + instance.vnode.shapeFlag & + ShapeFlags.STATEFUL_COMPONENT_SHOULD_KEEP_ALIVE + ) { queuePostRenderEffect(instance.a, parentSuspense) } mounted = true @@ -1477,7 +1481,11 @@ export function createRenderer< queuePostRenderEffect(um, parentSuspense) } // deactivated hook - if (da !== null && !isDeactivated) { + if ( + da !== null && + !isDeactivated && + instance.vnode.shapeFlag & ShapeFlags.STATEFUL_COMPONENT_SHOULD_KEEP_ALIVE + ) { queuePostRenderEffect(da, parentSuspense) } queuePostFlushCb(() => { diff --git a/packages/runtime-core/src/keepAlive.ts b/packages/runtime-core/src/keepAlive.ts index 5704a0b3..f86b9263 100644 --- a/packages/runtime-core/src/keepAlive.ts +++ b/packages/runtime-core/src/keepAlive.ts @@ -22,7 +22,7 @@ import { type MatchPattern = string | RegExp | string[] | RegExp[] -interface KeepAliveProps { +export interface KeepAliveProps { include?: MatchPattern exclude?: MatchPattern max?: number | string @@ -62,16 +62,22 @@ export const KeepAlive = { sink.activate = (vnode, container, anchor) => { move(vnode, container, anchor) queuePostRenderEffect(() => { - vnode.component!.isDeactivated = false - invokeHooks(vnode.component!.a!) + const component = vnode.component! + component.isDeactivated = false + if (component.a !== null) { + invokeHooks(component.a) + } }, parentSuspense) } sink.deactivate = (vnode: VNode) => { move(vnode, storageContainer, null) queuePostRenderEffect(() => { - invokeHooks(vnode.component!.da!) - vnode.component!.isDeactivated = true + const component = vnode.component! + if (component.da !== null) { + invokeHooks(component.da) + } + component.isDeactivated = true }, parentSuspense) } @@ -94,6 +100,10 @@ export const KeepAlive = { 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. + current.shapeFlag = ShapeFlags.STATEFUL_COMPONENT } cache.delete(key) keys.delete(key)