test: tests for keep-alive
This commit is contained in:
parent
a42d165285
commit
5fcb81050a
233
packages/runtime-core/__tests__/keepAlive.spec.ts
Normal file
233
packages/runtime-core/__tests__/keepAlive.spec.ts
Normal 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
|
||||
})
|
||||
})
|
@ -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<ErrorCapturedHook>(
|
||||
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)
|
||||
}
|
||||
|
@ -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
|
||||
})
|
||||
|
@ -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)
|
||||
}
|
||||
|
@ -6,6 +6,7 @@ const postFlushCbs: Function[] = []
|
||||
const p = Promise.resolve()
|
||||
|
||||
let isFlushing = false
|
||||
let isFlushPending = false
|
||||
|
||||
export function nextTick(fn?: () => void): Promise<void> {
|
||||
return fn ? p.then(fn) : p
|
||||
@ -14,9 +15,7 @@ export function nextTick(fn?: () => void): Promise<void> {
|
||||
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, number>
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
@ -203,6 +203,7 @@ export interface SuspenseBoundary<
|
||||
instance: ComponentInternalInstance,
|
||||
setupRenderEffect: (
|
||||
instance: ComponentInternalInstance,
|
||||
parentComponent: ComponentInternalInstance | null,
|
||||
parentSuspense: SuspenseBoundary<HostNode, HostElement> | null,
|
||||
initialVNode: VNode<HostNode, HostElement>,
|
||||
container: HostElement,
|
||||
@ -402,6 +403,7 @@ function createSuspenseBoundary<HostNode, HostElement>(
|
||||
handleSetupResult(instance, asyncSetupResult, suspense)
|
||||
setupRenderEffect(
|
||||
instance,
|
||||
parentComponent,
|
||||
suspense,
|
||||
vnode,
|
||||
// component may have been moved before resolve
|
||||
|
Loading…
x
Reference in New Issue
Block a user