test: tests for keep-alive

This commit is contained in:
Evan You 2019-10-30 21:41:28 -04:00
parent a42d165285
commit 5fcb81050a
6 changed files with 324 additions and 63 deletions

View File

@ -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<any>(null)
const App = {
render() {
return h(KeepAlive, null, {
default: () => h(toggle.value ? one : two, { ref: instanceRef })
})
}
}
render(h(App), root)
expect(serializeInner(root)).toBe(`<div>one</div>`)
instanceRef.value.msg = 'changed'
await nextTick()
expect(serializeInner(root)).toBe(`<div>changed</div>`)
toggle.value = false
await nextTick()
expect(serializeInner(root)).toBe(`<div>two</div>`)
toggle.value = true
await nextTick()
expect(serializeInner(root)).toBe(`<div>changed</div>`)
})
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(`<div>one</div>`)
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(`<div>two</div>`)
assertHookCalls(one, [1, 1, 1, 1, 0])
assertHookCalls(two, [1, 1, 1, 0, 0])
toggle2.value = true
await nextTick()
expect(serializeInner(root)).toBe(`<div>one</div>`)
assertHookCalls(one, [1, 1, 2, 1, 0])
assertHookCalls(two, [1, 1, 1, 1, 0])
toggle2.value = false
await nextTick()
expect(serializeInner(root)).toBe(`<div>two</div>`)
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(`<div>two</div>`)
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(`<div>two</div>`)
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(`<div>two</div>`)
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(`<div>two</div>`)
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(`<div>two</div>`)
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(`<div>two</div>`)
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
})
})

View File

@ -9,32 +9,38 @@ import { callWithAsyncErrorHandling, ErrorTypeStrings } from './errorHandling'
import { warn } from './warning' import { warn } from './warning'
import { capitalize } from '@vue/shared' import { capitalize } from '@vue/shared'
import { pauseTracking, resumeTracking, DebuggerEvent } from '@vue/reactivity' import { pauseTracking, resumeTracking, DebuggerEvent } from '@vue/reactivity'
import { registerKeepAliveHook } from './keepAlive'
export { onActivated, onDeactivated } from './keepAlive'
export function injectHook( export function injectHook(
type: LifecycleHooks, type: LifecycleHooks,
hook: Function, hook: Function & { __weh?: Function },
target: ComponentInternalInstance | null = currentInstance, target: ComponentInternalInstance | null = currentInstance,
prepend: boolean = false prepend: boolean = false
) { ) {
if (target) { if (target) {
const hooks = target[type] || (target[type] = []) const hooks = target[type] || (target[type] = [])
const wrappedHook = (...args: unknown[]) => { // cache the error handling wrapper for injected hooks so the same hook
if (target.isUnmounted) { // can be properly deduped by the scheduler. "__weh" stands for "with error
return // handling".
} const wrappedHook =
// disable tracking inside all lifecycle hooks hook.__weh ||
// since they can potentially be called inside effects. (hook.__weh = (...args: unknown[]) => {
pauseTracking() if (target.isUnmounted) {
// Set currentInstance during hook invocation. return
// This assumes the hook does not synchronously trigger other hooks, which }
// can only be false when the user does something really funky. // disable tracking inside all lifecycle hooks
setCurrentInstance(target) // since they can potentially be called inside effects.
const res = callWithAsyncErrorHandling(hook, target, type, args) pauseTracking()
setCurrentInstance(null) // Set currentInstance during hook invocation.
resumeTracking() // This assumes the hook does not synchronously trigger other hooks, which
return res // 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) { if (prepend) {
hooks.unshift(wrappedHook) hooks.unshift(wrappedHook)
} else { } else {
@ -84,17 +90,3 @@ export type ErrorCapturedHook = (
export const onErrorCaptured = createHook<ErrorCapturedHook>( export const onErrorCaptured = createHook<ErrorCapturedHook>(
LifecycleHooks.ERROR_CAPTURED 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)
}

View File

@ -863,6 +863,7 @@ export function createRenderer<
setupRenderEffect( setupRenderEffect(
instance, instance,
parentComponent,
parentSuspense, parentSuspense,
initialVNode, initialVNode,
container, container,
@ -877,6 +878,7 @@ export function createRenderer<
function setupRenderEffect( function setupRenderEffect(
instance: ComponentInternalInstance, instance: ComponentInternalInstance,
parentComponent: ComponentInternalInstance | null,
parentSuspense: HostSuspenseBoundary | null, parentSuspense: HostSuspenseBoundary | null,
initialVNode: HostVNode, initialVNode: HostVNode,
container: HostElement, container: HostElement,
@ -898,6 +900,10 @@ export function createRenderer<
if (instance.m !== null) { if (instance.m !== null) {
queuePostRenderEffect(instance.m, parentSuspense) queuePostRenderEffect(instance.m, parentSuspense)
} }
// activated hook for keep-alive roots.
if (instance.a !== null) {
queuePostRenderEffect(instance.a, parentSuspense)
}
mounted = true mounted = true
} else { } else {
// updateComponent // updateComponent
@ -1450,7 +1456,7 @@ export function createRenderer<
parentSuspense: HostSuspenseBoundary | null, parentSuspense: HostSuspenseBoundary | null,
doRemove?: boolean doRemove?: boolean
) { ) {
const { bum, effects, update, subTree, um } = instance const { bum, effects, update, subTree, um, da, isDeactivated } = instance
// beforeUnmount hook // beforeUnmount hook
if (bum !== null) { if (bum !== null) {
invokeHooks(bum) invokeHooks(bum)
@ -1470,6 +1476,10 @@ export function createRenderer<
if (um !== null) { if (um !== null) {
queuePostRenderEffect(um, parentSuspense) queuePostRenderEffect(um, parentSuspense)
} }
// deactivated hook
if (da !== null && !isDeactivated) {
queuePostRenderEffect(da, parentSuspense)
}
queuePostFlushCb(() => { queuePostFlushCb(() => {
instance.isUnmounted = true instance.isUnmounted = true
}) })

View File

@ -9,7 +9,7 @@ import {
} from './component' } from './component'
import { VNode, cloneVNode, isVNode } from './vnode' import { VNode, cloneVNode, isVNode } from './vnode'
import { warn } from './warning' import { warn } from './warning'
import { onBeforeUnmount, injectHook } from './apiLifecycle' import { onBeforeUnmount, injectHook, onUnmounted } from './apiLifecycle'
import { isString, isArray } from '@vue/shared' import { isString, isArray } from '@vue/shared'
import { watch } from './apiWatch' import { watch } from './apiWatch'
import { ShapeFlags } from './shapeFlags' import { ShapeFlags } from './shapeFlags'
@ -203,47 +203,67 @@ function matches(pattern: MatchPattern, name: string): boolean {
return false return false
} }
export function registerKeepAliveHook( export function onActivated(
hook: Function, 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, type: LifecycleHooks,
target: ComponentInternalInstance | null = currentInstance target: ComponentInternalInstance | null = currentInstance
) { ) {
// When registering an activated/deactivated hook, instead of registering it // cache the deactivate branch check wrapper for injected hooks so the same
// on the target instance, we walk up the parent chain and register it on // hook can be properly deduped by the scheduler. "__wdc" stands for "with
// every ancestor instance that is a keep-alive root. This avoids the need // deactivation check".
// to walk the entire component tree when invoking these hooks, and more const wrappedHook =
// importantly, avoids the need to track child components in arrays. 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) { if (target) {
let current = target let current = target.parent
while (current.parent) { while (current && current.parent) {
if (current.parent.type === KeepAlive) { if (current.parent.type === KeepAlive) {
register(hook, type, target, current) injectToKeepAliveRoot(wrappedHook, type, target, current)
} }
current = current.parent current = current.parent
} }
} }
} }
function register( function injectToKeepAliveRoot(
hook: Function, hook: Function,
type: LifecycleHooks, type: LifecycleHooks,
target: ComponentInternalInstance, target: ComponentInternalInstance,
keepAliveRoot: ComponentInternalInstance keepAliveRoot: ComponentInternalInstance
) { ) {
const wrappedHook = () => { injectHook(type, hook, keepAliveRoot, true /* prepend */)
// only fire the hook if the target instance is NOT in a deactivated branch. onUnmounted(() => {
let current: ComponentInternalInstance | null = target
while (current) {
if (current.isDeactivated) {
return
}
current = current.parent
}
hook()
}
injectHook(type, wrappedHook, keepAliveRoot, true)
onBeforeUnmount(() => {
const hooks = keepAliveRoot[type]! const hooks = keepAliveRoot[type]!
hooks.splice(hooks.indexOf(wrappedHook), 1) hooks.splice(hooks.indexOf(hook), 1)
}, target) }, target)
} }

View File

@ -6,6 +6,7 @@ const postFlushCbs: Function[] = []
const p = Promise.resolve() const p = Promise.resolve()
let isFlushing = false let isFlushing = false
let isFlushPending = false
export function nextTick(fn?: () => void): Promise<void> { export function nextTick(fn?: () => void): Promise<void> {
return fn ? p.then(fn) : p return fn ? p.then(fn) : p
@ -14,9 +15,7 @@ export function nextTick(fn?: () => void): Promise<void> {
export function queueJob(job: () => void) { export function queueJob(job: () => void) {
if (!queue.includes(job)) { if (!queue.includes(job)) {
queue.push(job) queue.push(job)
if (!isFlushing) { queueFlush()
nextTick(flushJobs)
}
} }
} }
@ -26,8 +25,12 @@ export function queuePostFlushCb(cb: Function | Function[]) {
} else { } else {
postFlushCbs.push(...cb) postFlushCbs.push(...cb)
} }
queueFlush()
}
if (!isFlushing) { function queueFlush() {
if (!isFlushing && !isFlushPending) {
isFlushPending = true
nextTick(flushJobs) nextTick(flushJobs)
} }
} }
@ -48,6 +51,7 @@ const RECURSION_LIMIT = 100
type JobCountMap = Map<Function, number> type JobCountMap = Map<Function, number>
function flushJobs(seenJobs?: JobCountMap) { function flushJobs(seenJobs?: JobCountMap) {
isFlushPending = false
isFlushing = true isFlushing = true
let job let job
if (__DEV__) { if (__DEV__) {
@ -77,7 +81,7 @@ function flushJobs(seenJobs?: JobCountMap) {
isFlushing = false isFlushing = false
// some postFlushCb queued jobs! // some postFlushCb queued jobs!
// keep flushing until it drains. // keep flushing until it drains.
if (queue.length) { if (queue.length || postFlushCbs.length) {
flushJobs(seenJobs) flushJobs(seenJobs)
} }
} }

View File

@ -203,6 +203,7 @@ export interface SuspenseBoundary<
instance: ComponentInternalInstance, instance: ComponentInternalInstance,
setupRenderEffect: ( setupRenderEffect: (
instance: ComponentInternalInstance, instance: ComponentInternalInstance,
parentComponent: ComponentInternalInstance | null,
parentSuspense: SuspenseBoundary<HostNode, HostElement> | null, parentSuspense: SuspenseBoundary<HostNode, HostElement> | null,
initialVNode: VNode<HostNode, HostElement>, initialVNode: VNode<HostNode, HostElement>,
container: HostElement, container: HostElement,
@ -402,6 +403,7 @@ function createSuspenseBoundary<HostNode, HostElement>(
handleSetupResult(instance, asyncSetupResult, suspense) handleSetupResult(instance, asyncSetupResult, suspense)
setupRenderEffect( setupRenderEffect(
instance, instance,
parentComponent,
suspense, suspense,
vnode, vnode,
// component may have been moved before resolve // component may have been moved before resolve