diff --git a/packages/runtime-core/__tests__/componentProxy.spec.ts b/packages/runtime-core/__tests__/componentProxy.spec.ts index aa951c3a..4c46875c 100644 --- a/packages/runtime-core/__tests__/componentProxy.spec.ts +++ b/packages/runtime-core/__tests__/componentProxy.spec.ts @@ -111,7 +111,7 @@ describe('component: proxy', () => { expect(`Attempting to mutate public property "$data"`).toHaveBeenWarned() }) - it('user', async () => { + it('sink', async () => { const app = createApp() let instance: ComponentInternalInstance let instanceProxy: any @@ -127,6 +127,6 @@ describe('component: proxy', () => { app.mount(Comp, nodeOps.createElement('div')) instanceProxy.foo = 1 expect(instanceProxy.foo).toBe(1) - expect(instance!.user.foo).toBe(1) + expect(instance!.sink.foo).toBe(1) }) }) diff --git a/packages/runtime-core/__tests__/vnode.spec.ts b/packages/runtime-core/__tests__/vnode.spec.ts index c2aaf21e..cf203dfa 100644 --- a/packages/runtime-core/__tests__/vnode.spec.ts +++ b/packages/runtime-core/__tests__/vnode.spec.ts @@ -132,7 +132,7 @@ describe('vnode', () => { mounted.el = {} const normalized = normalizeVNode(mounted) expect(normalized).not.toBe(mounted) - expect(normalized).toEqual({ ...mounted, el: null }) + expect(normalized).toEqual(mounted) // primitive types expect(normalizeVNode('foo')).toMatchObject({ type: Text, children: `foo` }) @@ -158,20 +158,6 @@ describe('vnode', () => { expect(cloned2).toEqual(node2) expect(cloneVNode(node2)).toEqual(node2) expect(cloneVNode(node2)).toEqual(cloned2) - - // should reset mounted state - const node3 = createVNode('div', { foo: 1 }, [node1]) - node3.el = {} - node3.anchor = {} - node3.component = {} as any - node3.suspense = {} as any - expect(cloneVNode(node3)).toEqual({ - ...node3, - el: null, - anchor: null, - component: null, - suspense: null - }) }) describe('mergeProps', () => { diff --git a/packages/runtime-core/src/apiLifecycle.ts b/packages/runtime-core/src/apiLifecycle.ts index 9e8709f2..50f796f5 100644 --- a/packages/runtime-core/src/apiLifecycle.ts +++ b/packages/runtime-core/src/apiLifecycle.ts @@ -9,14 +9,17 @@ 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' -function injectHook( +export function injectHook( type: LifecycleHooks, hook: Function, - target: ComponentInternalInstance | null + target: ComponentInternalInstance | null = currentInstance, + prepend: boolean = false ) { if (target) { - ;(target[type] || (target[type] = [])).push((...args: unknown[]) => { + const hooks = target[type] || (target[type] = []) + const wrappedHook = (...args: unknown[]) => { if (target.isUnmounted) { return } @@ -31,7 +34,12 @@ function injectHook( setCurrentInstance(null) resumeTracking() return res - }) + } + if (prepend) { + hooks.unshift(wrappedHook) + } else { + hooks.push(wrappedHook) + } } else if (__DEV__) { const apiName = `on${capitalize( ErrorTypeStrings[type].replace(/ hook$/, '') @@ -48,7 +56,7 @@ function injectHook( } } -const createHook = any>( +export const createHook = any>( lifecycle: LifecycleHooks ) => (hook: T, target: ComponentInternalInstance | null = currentInstance) => injectHook(lifecycle, hook, target) @@ -76,3 +84,17 @@ 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/apiOptions.ts b/packages/runtime-core/src/apiOptions.ts index 9bf408b29..4054e3d3 100644 --- a/packages/runtime-core/src/apiOptions.ts +++ b/packages/runtime-core/src/apiOptions.ts @@ -26,6 +26,8 @@ import { onRenderTracked, onBeforeUnmount, onUnmounted, + onActivated, + onDeactivated, onRenderTriggered, DebuggerHook, ErrorCapturedHook @@ -226,8 +228,8 @@ export function applyOptions( mounted, beforeUpdate, updated, - // TODO activated - // TODO deactivated + activated, + deactivated, beforeUnmount, unmounted, renderTracked, @@ -377,6 +379,12 @@ export function applyOptions( if (updated) { onUpdated(updated.bind(ctx)) } + if (activated) { + onActivated(activated.bind(ctx)) + } + if (deactivated) { + onDeactivated(deactivated.bind(ctx)) + } if (errorCaptured) { onErrorCaptured(errorCaptured.bind(ctx)) } diff --git a/packages/runtime-core/src/component.ts b/packages/runtime-core/src/component.ts index 6125030b..2bfab7bb 100644 --- a/packages/runtime-core/src/component.ts +++ b/packages/runtime-core/src/component.ts @@ -42,6 +42,7 @@ export interface FunctionalComponent

{ } export type Component = ComponentOptions | FunctionalComponent +export { ComponentOptions } type LifecycleHook = Function[] | null @@ -89,13 +90,10 @@ export interface ComponentInternalInstance { // after initialized (e.g. inline handlers) renderCache: (Function | VNode)[] | null + // assets for fast resolution components: Record directives: Record - asyncDep: Promise | null - asyncResult: unknown - asyncResolved: boolean - // the rest are only for stateful components renderContext: Data data: Data @@ -108,11 +106,17 @@ export interface ComponentInternalInstance { refs: Data emit: Emit - // user namespace - user: { [key: string]: any } + // suspense related + asyncDep: Promise | null + asyncResult: unknown + asyncResolved: boolean + + // storage for any extra properties + sink: { [key: string]: any } // lifecycle isUnmounted: boolean + isDeactivated: boolean [LifecycleHooks.BEFORE_CREATE]: LifecycleHook [LifecycleHooks.CREATED]: LifecycleHook [LifecycleHooks.BEFORE_MOUNT]: LifecycleHook @@ -173,11 +177,13 @@ export function createComponentInstance( asyncResolved: false, // user namespace for storing whatever the user assigns to `this` - user: {}, + // can also be used as a wildcard storage for ad-hoc injections internally + sink: {}, // lifecycle hooks // not using enums here because it results in computed properties isUnmounted: false, + isDeactivated: false, bc: null, c: null, bm: null, diff --git a/packages/runtime-core/src/componentProxy.ts b/packages/runtime-core/src/componentProxy.ts index e39022c1..6cc1fcf8 100644 --- a/packages/runtime-core/src/componentProxy.ts +++ b/packages/runtime-core/src/componentProxy.ts @@ -73,7 +73,7 @@ export const PublicInstanceProxyHandlers: ProxyHandler = { propsProxy, accessCache, type, - user + sink } = target // fast path for unscopables when using `with` block if (__RUNTIME_COMPILE__ && (key as any) === Symbol.unscopables) { @@ -128,8 +128,8 @@ export const PublicInstanceProxyHandlers: ProxyHandler = { return instanceWatch.bind(target) } } - if (hasOwn(user, key)) { - return user[key] + if (hasOwn(sink, key)) { + return sink[key] } else if (__DEV__ && currentRenderingInstance != null) { warn( `Property ${JSON.stringify(key)} was accessed during render ` + @@ -157,7 +157,7 @@ export const PublicInstanceProxyHandlers: ProxyHandler = { warn(`Attempting to mutate prop "${key}". Props are readonly.`, target) return false } else { - target.user[key] = value + target.sink[key] = value } return true } diff --git a/packages/runtime-core/src/createRenderer.ts b/packages/runtime-core/src/createRenderer.ts index 14ebb90f..4151b3d7 100644 --- a/packages/runtime-core/src/createRenderer.ts +++ b/packages/runtime-core/src/createRenderer.ts @@ -51,6 +51,7 @@ import { queueEffectWithSuspense } from './suspense' import { ErrorCodes, callWithErrorHandling } from './errorHandling' +import { KeepAliveSink } from './keepAlive' export interface RendererOptions { patchProp( @@ -131,7 +132,7 @@ function isSameType(n1: VNode, n2: VNode): boolean { return n1.type === n2.type && n1.key === n2.key } -function invokeHooks(hooks: Function[], arg?: DebuggerEvent) { +export function invokeHooks(hooks: Function[], arg?: DebuggerEvent) { for (let i = 0; i < hooks.length; i++) { hooks[i](arg) } @@ -755,14 +756,22 @@ export function createRenderer< optimized: boolean ) { if (n1 == null) { - mountComponent( - n2, - container, - anchor, - parentComponent, - parentSuspense, - isSVG - ) + if (n2.shapeFlag & ShapeFlags.STATEFUL_COMPONENT_KEPT_ALIVE) { + ;(parentComponent!.sink as KeepAliveSink).activate( + n2, + container, + anchor + ) + } else { + mountComponent( + n2, + container, + anchor, + parentComponent, + parentSuspense, + isSVG + ) + } } else { const instance = (n2.component = n1.component)! @@ -816,8 +825,17 @@ export function createRenderer< pushWarningContext(initialVNode) } + const Comp = initialVNode.type as Component + + // inject renderer internals for keepAlive + if ((Comp as any).__isKeepAlive) { + const sink = instance.sink as KeepAliveSink + sink.renderer = internals + sink.parentSuspense = parentSuspense + } + // resolve props and slots for setup context - const propsOptions = (initialVNode.type as Component).props + const propsOptions = Comp.props resolveProps(instance, initialVNode.props, propsOptions) resolveSlots(instance, initialVNode.children) @@ -1381,7 +1399,11 @@ export function createRenderer< } if (shapeFlag & ShapeFlags.COMPONENT) { - unmountComponent(vnode.component!, parentSuspense, doRemove) + if (shapeFlag & ShapeFlags.STATEFUL_COMPONENT_SHOULD_KEEP_ALIVE) { + ;(parentComponent!.sink as KeepAliveSink).deactivate(vnode) + } else { + unmountComponent(vnode.component!, parentSuspense, doRemove) + } return } diff --git a/packages/runtime-core/src/index.ts b/packages/runtime-core/src/index.ts index 220cc45f..0361a173 100644 --- a/packages/runtime-core/src/index.ts +++ b/packages/runtime-core/src/index.ts @@ -20,6 +20,8 @@ export { } from './vnode' // VNode type symbols export { Text, Comment, Fragment, Portal, Suspense } from './vnode' +// Internal Components +export { KeepAlive } from './keepAlive' // VNode flags export { PublicShapeFlags as ShapeFlags } from './shapeFlags' export { PublicPatchFlags as PatchFlags } from '@vue/shared' diff --git a/packages/runtime-core/src/keepAlive.ts b/packages/runtime-core/src/keepAlive.ts new file mode 100644 index 00000000..93d8fa47 --- /dev/null +++ b/packages/runtime-core/src/keepAlive.ts @@ -0,0 +1,249 @@ +import { + Component, + getCurrentInstance, + FunctionalComponent, + SetupContext, + ComponentInternalInstance, + LifecycleHooks, + currentInstance +} from './component' +import { VNode, cloneVNode, isVNode } from './vnode' +import { warn } from './warning' +import { onBeforeUnmount, injectHook } from './apiLifecycle' +import { isString, isArray } from '@vue/shared' +import { watch } from './apiWatch' +import { ShapeFlags } from './shapeFlags' +import { SuspenseBoundary } from './suspense' +import { + RendererInternals, + queuePostRenderEffect, + invokeHooks +} from './createRenderer' + +type MatchPattern = string | RegExp | string[] | RegExp[] + +interface KeepAliveProps { + include?: MatchPattern + exclude?: MatchPattern + max?: number | string +} + +type CacheKey = string | number | Component +type Cache = Map +type Keys = Set + +export interface KeepAliveSink { + renderer: RendererInternals + parentSuspense: SuspenseBoundary | null + activate: (vnode: VNode, container: object, anchor: object | null) => void + deactivate: (vnode: VNode) => void +} + +export const KeepAlive = { + name: `KeepAlive`, + __isKeepAlive: true, + setup(props: KeepAliveProps, { slots }: SetupContext) { + const cache: Cache = new Map() + const keys: Keys = new Set() + let current: VNode | null = null + + const instance = getCurrentInstance()! + const sink = instance.sink as KeepAliveSink + const { + renderer: { + move, + unmount: _unmount, + options: { createElement } + }, + parentSuspense + } = sink + const storageContainer = createElement('div') + + sink.activate = (vnode, container, anchor) => { + move(vnode, container, anchor) + queuePostRenderEffect(() => { + vnode.component!.isDeactivated = false + invokeHooks(vnode.component!.a!) + }, parentSuspense) + } + + sink.deactivate = (vnode: VNode) => { + move(vnode, storageContainer, null) + queuePostRenderEffect(() => { + invokeHooks(vnode.component!.da!) + vnode.component!.isDeactivated = true + }, parentSuspense) + } + + function unmount(vnode: VNode) { + // reset the shapeFlag so it can be properly unmounted + vnode.shapeFlag = ShapeFlags.STATEFUL_COMPONENT + _unmount(vnode, instance, parentSuspense) + } + + function pruneCache(filter?: (name: string) => boolean) { + cache.forEach((vnode, key) => { + const name = getName(vnode.type) + if (name && (!filter || !filter(name))) { + pruneCacheEntry(key) + } + }) + } + + function pruneCacheEntry(key: CacheKey) { + const cached = cache.get(key) as VNode + if (!current || cached.type !== current.type) { + unmount(cached) + } + cache.delete(key) + keys.delete(key) + } + + watch( + () => [props.include, props.exclude], + ([include, exclude]) => { + include && pruneCache(name => matches(include, name)) + exclude && pruneCache(name => matches(exclude, name)) + }, + { lazy: true } + ) + + onBeforeUnmount(() => { + cache.forEach(unmount) + }) + + return () => { + if (!slots.default) { + return + } + + const children = slots.default() + let vnode = children[0] + if (children.length > 1) { + if (__DEV__) { + warn(`KeepAlive should contain exactly one component child.`) + } + current = null + return children + } else if ( + !isVNode(vnode) || + !(vnode.shapeFlag & ShapeFlags.STATEFUL_COMPONENT) + ) { + current = null + return vnode + } + + const comp = vnode.type as Component + const name = getName(comp) + const { include, exclude, max } = props + + if ( + (include && (!name || !matches(include, name))) || + (exclude && name && matches(exclude, name)) + ) { + return vnode + } + + const key = vnode.key == null ? comp : vnode.key + const cached = cache.get(key) + + // clone vnode if it's reused because we are going to mutate it + if (vnode.el) { + vnode = cloneVNode(vnode) + } + cache.set(key, vnode) + + if (cached) { + // copy over mounted state + vnode.el = cached.el + vnode.anchor = cached.anchor + vnode.component = cached.component + // avoid vnode being mounted as fresh + vnode.shapeFlag |= ShapeFlags.STATEFUL_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(Array.from(keys)[0]) + } + } + // avoid vnode being unmounted + vnode.shapeFlag |= ShapeFlags.STATEFUL_COMPONENT_SHOULD_KEEP_ALIVE + + current = vnode + return vnode + } + } +} + +if (__DEV__) { + ;(KeepAlive as any).props = { + include: [String, RegExp, Array], + exclude: [String, RegExp, Array], + max: [String, Number] + } +} + +function getName(comp: Component): string | void { + return (comp as FunctionalComponent).displayName || comp.name +} + +function matches(pattern: MatchPattern, name: string): boolean { + if (isArray(pattern)) { + return (pattern as any).some((p: string | RegExp) => matches(p, name)) + } else if (isString(pattern)) { + return pattern.split(',').indexOf(name) > -1 + } else if (pattern.test) { + return pattern.test(name) + } + /* istanbul ignore next */ + return false +} + +export function registerKeepAliveHook( + hook: 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. + if (target) { + let current = target + while (current.parent) { + if (current.parent.type === KeepAlive) { + register(hook, type, target, current) + } + current = current.parent + } + } +} + +function register( + 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(() => { + const hooks = keepAliveRoot[type]! + hooks.splice(hooks.indexOf(wrappedHook), 1) + }, target) +} diff --git a/packages/runtime-core/src/shapeFlags.ts b/packages/runtime-core/src/shapeFlags.ts index baa2328b..624e827e 100644 --- a/packages/runtime-core/src/shapeFlags.ts +++ b/packages/runtime-core/src/shapeFlags.ts @@ -8,6 +8,8 @@ export const enum ShapeFlags { ARRAY_CHILDREN = 1 << 4, SLOTS_CHILDREN = 1 << 5, SUSPENSE = 1 << 6, + STATEFUL_COMPONENT_SHOULD_KEEP_ALIVE = 1 << 7, + STATEFUL_COMPONENT_KEPT_ALIVE = 1 << 8, COMPONENT = ShapeFlags.STATEFUL_COMPONENT | ShapeFlags.FUNCTIONAL_COMPONENT } diff --git a/packages/runtime-core/src/vnode.ts b/packages/runtime-core/src/vnode.ts index 882ba059..26391645 100644 --- a/packages/runtime-core/src/vnode.ts +++ b/packages/runtime-core/src/vnode.ts @@ -264,13 +264,14 @@ export function cloneVNode( appContext: vnode.appContext, dirs: vnode.dirs, - // these should be set to null since they should only be present on - // mounted VNodes. If they are somehow not null, this means we have - // encountered an already-mounted vnode being used again. - component: null, - suspense: null, - el: null, - anchor: null + // These should technically only be non-null on mounted VNodes. However, + // they *should* be copied for kept-alive vnodes. So we just always copy + // them since them being non-null during a mount doesn't affect the logic as + // they will simply be overwritten. + component: vnode.component, + suspense: vnode.suspense, + el: vnode.el, + anchor: vnode.anchor } }