From 5fcb81050a6c83ee1f329f14d4fc7099ddb676d9 Mon Sep 17 00:00:00 2001 From: Evan You Date: Wed, 30 Oct 2019 21:41:28 -0400 Subject: [PATCH] test: tests for keep-alive --- .../runtime-core/__tests__/keepAlive.spec.ts | 233 ++++++++++++++++++ packages/runtime-core/src/apiLifecycle.ts | 56 ++--- packages/runtime-core/src/createRenderer.ts | 12 +- packages/runtime-core/src/keepAlive.ts | 70 ++++-- packages/runtime-core/src/scheduler.ts | 14 +- packages/runtime-core/src/suspense.ts | 2 + 6 files changed, 324 insertions(+), 63 deletions(-) create mode 100644 packages/runtime-core/__tests__/keepAlive.spec.ts diff --git a/packages/runtime-core/__tests__/keepAlive.spec.ts b/packages/runtime-core/__tests__/keepAlive.spec.ts new file mode 100644 index 00000000..54d8ce5d --- /dev/null +++ b/packages/runtime-core/__tests__/keepAlive.spec.ts @@ -0,0 +1,233 @@ +import { ComponentOptions } from '../src/component' +import { + h, + TestElement, + nodeOps, + render, + ref, + KeepAlive, + serializeInner, + nextTick +} from '@vue/runtime-test' + +describe('keep-alive', () => { + let one: ComponentOptions + let two: ComponentOptions + let root: TestElement + + beforeEach(() => { + root = nodeOps.createElement('div') + one = { + data: () => ({ msg: 'one' }), + render() { + return h('div', this.msg) + }, + created: jest.fn(), + mounted: jest.fn(), + activated: jest.fn(), + deactivated: jest.fn(), + unmounted: jest.fn() + } + two = { + data: () => ({ msg: 'two' }), + render() { + return h('div', this.msg) + }, + created: jest.fn(), + mounted: jest.fn(), + activated: jest.fn(), + deactivated: jest.fn(), + unmounted: jest.fn() + } + }) + + function assertHookCalls(component: any, callCounts: number[]) { + expect([ + component.created.mock.calls.length, + component.mounted.mock.calls.length, + component.activated.mock.calls.length, + component.deactivated.mock.calls.length, + component.unmounted.mock.calls.length + ]).toEqual(callCounts) + } + + test('should preserve state', async () => { + const toggle = ref(true) + const instanceRef = ref(null) + const App = { + render() { + return h(KeepAlive, null, { + default: () => h(toggle.value ? one : two, { ref: instanceRef }) + }) + } + } + render(h(App), root) + expect(serializeInner(root)).toBe(`
one
`) + instanceRef.value.msg = 'changed' + await nextTick() + expect(serializeInner(root)).toBe(`
changed
`) + toggle.value = false + await nextTick() + expect(serializeInner(root)).toBe(`
two
`) + toggle.value = true + await nextTick() + expect(serializeInner(root)).toBe(`
changed
`) + }) + + test('should call correct lifecycle hooks', async () => { + const toggle1 = ref(true) + const toggle2 = ref(true) + const App = { + render() { + return toggle1.value + ? h(KeepAlive, () => h(toggle2.value ? one : two)) + : null + } + } + render(h(App), root) + + expect(serializeInner(root)).toBe(`
one
`) + assertHookCalls(one, [1, 1, 1, 0, 0]) + assertHookCalls(two, [0, 0, 0, 0, 0]) + + // toggle kept-alive component + toggle2.value = false + await nextTick() + expect(serializeInner(root)).toBe(`
two
`) + assertHookCalls(one, [1, 1, 1, 1, 0]) + assertHookCalls(two, [1, 1, 1, 0, 0]) + + toggle2.value = true + await nextTick() + expect(serializeInner(root)).toBe(`
one
`) + assertHookCalls(one, [1, 1, 2, 1, 0]) + assertHookCalls(two, [1, 1, 1, 1, 0]) + + toggle2.value = false + 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 + await nextTick() + expect(serializeInner(root)).toBe(``) + assertHookCalls(one, [1, 1, 2, 2, 1]) + assertHookCalls(two, [1, 1, 2, 2, 1]) + }) + + test('should call lifecycle hooks on nested components', async () => { + one.render = () => h(two) + + const toggle = ref(true) + const App = { + render() { + return h(KeepAlive, () => (toggle.value ? h(one) : null)) + } + } + render(h(App), root) + + expect(serializeInner(root)).toBe(`
two
`) + assertHookCalls(one, [1, 1, 1, 0, 0]) + assertHookCalls(two, [1, 1, 1, 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]) + + toggle.value = true + await nextTick() + expect(serializeInner(root)).toBe(`
two
`) + assertHookCalls(one, [1, 1, 2, 1, 0]) + assertHookCalls(two, [1, 1, 2, 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]) + }) + + test('should call correct hooks for nested keep-alive', async () => { + const toggle2 = ref(true) + one.render = () => h(KeepAlive, () => (toggle2.value ? h(two) : null)) + + const toggle1 = ref(true) + const App = { + render() { + return h(KeepAlive, () => (toggle1.value ? h(one) : null)) + } + } + render(h(App), root) + + expect(serializeInner(root)).toBe(`
two
`) + assertHookCalls(one, [1, 1, 1, 0, 0]) + assertHookCalls(two, [1, 1, 1, 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]) + + toggle1.value = true + await nextTick() + expect(serializeInner(root)).toBe(`
two
`) + assertHookCalls(one, [1, 1, 2, 1, 0]) + assertHookCalls(two, [1, 1, 2, 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]) + + toggle2.value = true + await nextTick() + expect(serializeInner(root)).toBe(`
two
`) + assertHookCalls(one, [1, 1, 2, 1, 0]) + assertHookCalls(two, [1, 1, 3, 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]) + + // 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 + + 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 + + toggle1.value = true + await nextTick() + expect(serializeInner(root)).toBe(`
two
`) + assertHookCalls(one, [1, 1, 3, 2, 0]) + assertHookCalls(two, [1, 1, 4, 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]) + + 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 + }) +}) diff --git a/packages/runtime-core/src/apiLifecycle.ts b/packages/runtime-core/src/apiLifecycle.ts index 50f796f5..18de3e2c 100644 --- a/packages/runtime-core/src/apiLifecycle.ts +++ b/packages/runtime-core/src/apiLifecycle.ts @@ -9,32 +9,38 @@ import { callWithAsyncErrorHandling, ErrorTypeStrings } from './errorHandling' import { warn } from './warning' import { capitalize } from '@vue/shared' import { pauseTracking, resumeTracking, DebuggerEvent } from '@vue/reactivity' -import { registerKeepAliveHook } from './keepAlive' + +export { onActivated, onDeactivated } from './keepAlive' export function injectHook( type: LifecycleHooks, - hook: Function, + hook: Function & { __weh?: Function }, target: ComponentInternalInstance | null = currentInstance, prepend: boolean = false ) { if (target) { const hooks = target[type] || (target[type] = []) - const wrappedHook = (...args: unknown[]) => { - if (target.isUnmounted) { - return - } - // disable tracking inside all lifecycle hooks - // since they can potentially be called inside effects. - pauseTracking() - // Set currentInstance during hook invocation. - // This assumes the hook does not synchronously trigger other hooks, which - // can only be false when the user does something really funky. - setCurrentInstance(target) - const res = callWithAsyncErrorHandling(hook, target, type, args) - setCurrentInstance(null) - resumeTracking() - return res - } + // cache the error handling wrapper for injected hooks so the same hook + // can be properly deduped by the scheduler. "__weh" stands for "with error + // handling". + const wrappedHook = + hook.__weh || + (hook.__weh = (...args: unknown[]) => { + if (target.isUnmounted) { + return + } + // disable tracking inside all lifecycle hooks + // since they can potentially be called inside effects. + pauseTracking() + // Set currentInstance during hook invocation. + // This assumes the hook does not synchronously trigger other hooks, which + // can only be false when the user does something really funky. + setCurrentInstance(target) + const res = callWithAsyncErrorHandling(hook, target, type, args) + setCurrentInstance(null) + resumeTracking() + return res + }) if (prepend) { hooks.unshift(wrappedHook) } else { @@ -84,17 +90,3 @@ export type ErrorCapturedHook = ( export const onErrorCaptured = createHook( LifecycleHooks.ERROR_CAPTURED ) - -export function onActivated( - hook: Function, - target?: ComponentInternalInstance | null -) { - registerKeepAliveHook(hook, LifecycleHooks.ACTIVATED, target) -} - -export function onDeactivated( - hook: Function, - target?: ComponentInternalInstance | null -) { - registerKeepAliveHook(hook, LifecycleHooks.DEACTIVATED, target) -} diff --git a/packages/runtime-core/src/createRenderer.ts b/packages/runtime-core/src/createRenderer.ts index e49728e8..25d6e3ad 100644 --- a/packages/runtime-core/src/createRenderer.ts +++ b/packages/runtime-core/src/createRenderer.ts @@ -863,6 +863,7 @@ export function createRenderer< setupRenderEffect( instance, + parentComponent, parentSuspense, initialVNode, container, @@ -877,6 +878,7 @@ export function createRenderer< function setupRenderEffect( instance: ComponentInternalInstance, + parentComponent: ComponentInternalInstance | null, parentSuspense: HostSuspenseBoundary | null, initialVNode: HostVNode, container: HostElement, @@ -898,6 +900,10 @@ export function createRenderer< if (instance.m !== null) { queuePostRenderEffect(instance.m, parentSuspense) } + // activated hook for keep-alive roots. + if (instance.a !== null) { + queuePostRenderEffect(instance.a, parentSuspense) + } mounted = true } else { // updateComponent @@ -1450,7 +1456,7 @@ export function createRenderer< parentSuspense: HostSuspenseBoundary | null, doRemove?: boolean ) { - const { bum, effects, update, subTree, um } = instance + const { bum, effects, update, subTree, um, da, isDeactivated } = instance // beforeUnmount hook if (bum !== null) { invokeHooks(bum) @@ -1470,6 +1476,10 @@ export function createRenderer< if (um !== null) { queuePostRenderEffect(um, parentSuspense) } + // deactivated hook + if (da !== null && !isDeactivated) { + queuePostRenderEffect(da, parentSuspense) + } queuePostFlushCb(() => { instance.isUnmounted = true }) diff --git a/packages/runtime-core/src/keepAlive.ts b/packages/runtime-core/src/keepAlive.ts index 93d8fa47..5704a0b3 100644 --- a/packages/runtime-core/src/keepAlive.ts +++ b/packages/runtime-core/src/keepAlive.ts @@ -9,7 +9,7 @@ import { } from './component' import { VNode, cloneVNode, isVNode } from './vnode' import { warn } from './warning' -import { onBeforeUnmount, injectHook } from './apiLifecycle' +import { onBeforeUnmount, injectHook, onUnmounted } from './apiLifecycle' import { isString, isArray } from '@vue/shared' import { watch } from './apiWatch' import { ShapeFlags } from './shapeFlags' @@ -203,47 +203,67 @@ function matches(pattern: MatchPattern, name: string): boolean { return false } -export function registerKeepAliveHook( +export function onActivated( hook: Function, + target?: ComponentInternalInstance | null +) { + registerKeepAliveHook(hook, LifecycleHooks.ACTIVATED, target) +} + +export function onDeactivated( + hook: Function, + target?: ComponentInternalInstance | null +) { + registerKeepAliveHook(hook, LifecycleHooks.DEACTIVATED, target) +} + +function registerKeepAliveHook( + hook: Function & { __wdc?: Function }, type: LifecycleHooks, target: ComponentInternalInstance | null = currentInstance ) { - // When registering an activated/deactivated hook, instead of registering it - // on the target instance, we walk up the parent chain and register it on - // every ancestor instance that is a keep-alive root. This avoids the need - // to walk the entire component tree when invoking these hooks, and more - // importantly, avoids the need to track child components in arrays. + // 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 = + hook.__wdc || + (hook.__wdc = () => { + // only fire the hook if the target instance is NOT in a deactivated branch. + let current: ComponentInternalInstance | null = target + while (current) { + if (current.isDeactivated) { + return + } + current = current.parent + } + hook() + }) + 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. + // This avoids the need to walk the entire component tree when invoking these + // hooks, and more importantly, avoids the need to track child components in + // arrays. if (target) { - let current = target - while (current.parent) { + let current = target.parent + while (current && current.parent) { if (current.parent.type === KeepAlive) { - register(hook, type, target, current) + injectToKeepAliveRoot(wrappedHook, type, target, current) } current = current.parent } } } -function register( +function injectToKeepAliveRoot( hook: Function, type: LifecycleHooks, target: ComponentInternalInstance, keepAliveRoot: ComponentInternalInstance ) { - const wrappedHook = () => { - // only fire the hook if the target instance is NOT in a deactivated branch. - let current: ComponentInternalInstance | null = target - while (current) { - if (current.isDeactivated) { - return - } - current = current.parent - } - hook() - } - injectHook(type, wrappedHook, keepAliveRoot, true) - onBeforeUnmount(() => { + injectHook(type, hook, keepAliveRoot, true /* prepend */) + onUnmounted(() => { const hooks = keepAliveRoot[type]! - hooks.splice(hooks.indexOf(wrappedHook), 1) + hooks.splice(hooks.indexOf(hook), 1) }, target) } diff --git a/packages/runtime-core/src/scheduler.ts b/packages/runtime-core/src/scheduler.ts index 9a0c4081..e291d412 100644 --- a/packages/runtime-core/src/scheduler.ts +++ b/packages/runtime-core/src/scheduler.ts @@ -6,6 +6,7 @@ const postFlushCbs: Function[] = [] const p = Promise.resolve() let isFlushing = false +let isFlushPending = false export function nextTick(fn?: () => void): Promise { return fn ? p.then(fn) : p @@ -14,9 +15,7 @@ export function nextTick(fn?: () => void): Promise { export function queueJob(job: () => void) { if (!queue.includes(job)) { queue.push(job) - if (!isFlushing) { - nextTick(flushJobs) - } + queueFlush() } } @@ -26,8 +25,12 @@ export function queuePostFlushCb(cb: Function | Function[]) { } else { postFlushCbs.push(...cb) } + queueFlush() +} - if (!isFlushing) { +function queueFlush() { + if (!isFlushing && !isFlushPending) { + isFlushPending = true nextTick(flushJobs) } } @@ -48,6 +51,7 @@ const RECURSION_LIMIT = 100 type JobCountMap = Map function flushJobs(seenJobs?: JobCountMap) { + isFlushPending = false isFlushing = true let job if (__DEV__) { @@ -77,7 +81,7 @@ function flushJobs(seenJobs?: JobCountMap) { isFlushing = false // some postFlushCb queued jobs! // keep flushing until it drains. - if (queue.length) { + if (queue.length || postFlushCbs.length) { flushJobs(seenJobs) } } diff --git a/packages/runtime-core/src/suspense.ts b/packages/runtime-core/src/suspense.ts index bda5ae07..a8d323c0 100644 --- a/packages/runtime-core/src/suspense.ts +++ b/packages/runtime-core/src/suspense.ts @@ -203,6 +203,7 @@ export interface SuspenseBoundary< instance: ComponentInternalInstance, setupRenderEffect: ( instance: ComponentInternalInstance, + parentComponent: ComponentInternalInstance | null, parentSuspense: SuspenseBoundary | null, initialVNode: VNode, container: HostElement, @@ -402,6 +403,7 @@ function createSuspenseBoundary( handleSetupResult(instance, asyncSetupResult, suspense) setupRenderEffect( instance, + parentComponent, suspense, vnode, // component may have been moved before resolve