diff --git a/packages-private/vapor-e2e-test/__tests__/keepalive.spec.ts b/packages-private/vapor-e2e-test/__tests__/keepalive.spec.ts new file mode 100644 index 00000000000..7f98a1f3a9b --- /dev/null +++ b/packages-private/vapor-e2e-test/__tests__/keepalive.spec.ts @@ -0,0 +1,87 @@ +import path from 'node:path' +import { + E2E_TIMEOUT, + setupPuppeteer, +} from '../../../packages/vue/__tests__/e2e/e2eUtils' +import connect from 'connect' +import sirv from 'sirv' +const { page, html, click, value, enterValue } = setupPuppeteer() + +describe('vapor keepalive', () => { + let server: any + const port = '8196' + beforeAll(() => { + server = connect() + .use(sirv(path.resolve(import.meta.dirname, '../dist'))) + .listen(port) + process.on('SIGTERM', () => server && server.close()) + }) + + beforeEach(async () => { + const baseUrl = `http://localhost:${port}/keepalive/` + await page().goto(baseUrl) + await page().waitForSelector('#app') + }) + + afterAll(() => { + server.close() + }) + + test( + 'render vdom component', + async () => { + const testSelector = '.render-vdom-component' + const btnShow = `${testSelector} .btn-show` + const btnToggle = `${testSelector} .btn-toggle` + const container = `${testSelector} > div` + const inputSelector = `${testSelector} input` + + let calls = await page().evaluate(() => { + return (window as any).getCalls() + }) + expect(calls).toStrictEqual(['mounted', 'activated']) + + expect(await html(container)).toBe('') + expect(await value(inputSelector)).toBe('vdom') + + // change input value + await enterValue(inputSelector, 'changed') + expect(await value(inputSelector)).toBe('changed') + + // deactivate + await click(btnToggle) + expect(await html(container)).toBe('') + calls = await page().evaluate(() => { + return (window as any).getCalls() + }) + expect(calls).toStrictEqual(['deactivated']) + + // activate + await click(btnToggle) + expect(await html(container)).toBe('') + expect(await value(inputSelector)).toBe('changed') + calls = await page().evaluate(() => { + return (window as any).getCalls() + }) + expect(calls).toStrictEqual(['activated']) + + // unmount keepalive + await click(btnShow) + expect(await html(container)).toBe('') + calls = await page().evaluate(() => { + return (window as any).getCalls() + }) + expect(calls).toStrictEqual(['deactivated', 'unmounted']) + + // mount keepalive + await click(btnShow) + expect(await html(container)).toBe('') + expect(await value(inputSelector)).toBe('vdom') + calls = await page().evaluate(() => { + return (window as any).getCalls() + }) + expect(calls).toStrictEqual(['mounted', 'activated']) + }, + E2E_TIMEOUT, + ) +}) diff --git a/packages-private/vapor-e2e-test/__tests__/vdomInterop.spec.ts b/packages-private/vapor-e2e-test/__tests__/vdomInterop.spec.ts index 360f48085a1..94a4b4af24d 100644 --- a/packages-private/vapor-e2e-test/__tests__/vdomInterop.spec.ts +++ b/packages-private/vapor-e2e-test/__tests__/vdomInterop.spec.ts @@ -6,28 +6,31 @@ import { import connect from 'connect' import sirv from 'sirv' -describe('vdom / vapor interop', () => { - const { page, click, text, enterValue } = setupPuppeteer() - - let server: any - const port = '8193' - beforeAll(() => { - server = connect() - .use(sirv(path.resolve(import.meta.dirname, '../dist'))) - .listen(port) - process.on('SIGTERM', () => server && server.close()) - }) +const { page, click, html, value, text, enterValue } = setupPuppeteer() + +let server: any +const port = '8193' +beforeAll(() => { + server = connect() + .use(sirv(path.resolve(import.meta.dirname, '../dist'))) + .listen(port) + process.on('SIGTERM', () => server && server.close()) +}) - afterAll(() => { - server.close() - }) +afterAll(() => { + server.close() +}) +beforeEach(async () => { + const baseUrl = `http://localhost:${port}/interop/` + await page().goto(baseUrl) + await page().waitForSelector('#app') +}) + +describe('vdom / vapor interop', () => { test( 'should work', async () => { - const baseUrl = `http://localhost:${port}/interop/` - await page().goto(baseUrl) - expect(await text('.vapor > h2')).toContain('Vapor component in VDOM') expect(await text('.vapor-prop')).toContain('hello') @@ -81,4 +84,64 @@ describe('vdom / vapor interop', () => { }, E2E_TIMEOUT, ) + + describe('keepalive', () => { + test( + 'render vapor component', + async () => { + const testSelector = '.render-vapor-component' + const btnShow = `${testSelector} .btn-show` + const btnToggle = `${testSelector} .btn-toggle` + const container = `${testSelector} > div` + const inputSelector = `${testSelector} input` + + let calls = await page().evaluate(() => { + return (window as any).getCalls() + }) + expect(calls).toStrictEqual(['mounted', 'activated']) + + expect(await html(container)).toBe('') + expect(await value(inputSelector)).toBe('vapor') + + // change input value + await enterValue(inputSelector, 'changed') + expect(await value(inputSelector)).toBe('changed') + + // deactivate + await click(btnToggle) + expect(await html(container)).toBe('') + calls = await page().evaluate(() => { + return (window as any).getCalls() + }) + expect(calls).toStrictEqual(['deactivated']) + + // activate + await click(btnToggle) + expect(await html(container)).toBe('') + expect(await value(inputSelector)).toBe('changed') + calls = await page().evaluate(() => { + return (window as any).getCalls() + }) + expect(calls).toStrictEqual(['activated']) + + // unmount keepalive + await click(btnShow) + expect(await html(container)).toBe('') + calls = await page().evaluate(() => { + return (window as any).getCalls() + }) + expect(calls).toStrictEqual(['deactivated', 'unmounted']) + + // mount keepalive + await click(btnShow) + expect(await html(container)).toBe('') + expect(await value(inputSelector)).toBe('vapor') + calls = await page().evaluate(() => { + return (window as any).getCalls() + }) + expect(calls).toStrictEqual(['mounted', 'activated']) + }, + E2E_TIMEOUT, + ) + }) }) diff --git a/packages-private/vapor-e2e-test/index.html b/packages-private/vapor-e2e-test/index.html index 7dc205e5ab0..29ec129cafe 100644 --- a/packages-private/vapor-e2e-test/index.html +++ b/packages-private/vapor-e2e-test/index.html @@ -1,2 +1,3 @@ VDOM / Vapor interop Vapor TodoMVC +Vapor KeepAlive diff --git a/packages-private/vapor-e2e-test/interop/App.vue b/packages-private/vapor-e2e-test/interop/App.vue index 772a6989dd7..10a5a584749 100644 --- a/packages-private/vapor-e2e-test/interop/App.vue +++ b/packages-private/vapor-e2e-test/interop/App.vue @@ -1,9 +1,20 @@ diff --git a/packages-private/vapor-e2e-test/interop/components/SimpleVaporComp.vue b/packages-private/vapor-e2e-test/interop/components/SimpleVaporComp.vue new file mode 100644 index 00000000000..7f8dc4808c3 --- /dev/null +++ b/packages-private/vapor-e2e-test/interop/components/SimpleVaporComp.vue @@ -0,0 +1,20 @@ + + diff --git a/packages-private/vapor-e2e-test/keepalive/App.vue b/packages-private/vapor-e2e-test/keepalive/App.vue new file mode 100644 index 00000000000..c388228eb5b --- /dev/null +++ b/packages-private/vapor-e2e-test/keepalive/App.vue @@ -0,0 +1,26 @@ + + + diff --git a/packages-private/vapor-e2e-test/keepalive/components/VdomComp.vue b/packages-private/vapor-e2e-test/keepalive/components/VdomComp.vue new file mode 100644 index 00000000000..4c539e9de94 --- /dev/null +++ b/packages-private/vapor-e2e-test/keepalive/components/VdomComp.vue @@ -0,0 +1,20 @@ + + diff --git a/packages-private/vapor-e2e-test/keepalive/index.html b/packages-private/vapor-e2e-test/keepalive/index.html new file mode 100644 index 00000000000..79052a023ba --- /dev/null +++ b/packages-private/vapor-e2e-test/keepalive/index.html @@ -0,0 +1,2 @@ + +
diff --git a/packages-private/vapor-e2e-test/keepalive/main.ts b/packages-private/vapor-e2e-test/keepalive/main.ts new file mode 100644 index 00000000000..bc6b49173ac --- /dev/null +++ b/packages-private/vapor-e2e-test/keepalive/main.ts @@ -0,0 +1,4 @@ +import { createVaporApp, vaporInteropPlugin } from 'vue' +import App from './App.vue' + +createVaporApp(App).use(vaporInteropPlugin).mount('#app') diff --git a/packages-private/vapor-e2e-test/vite.config.ts b/packages-private/vapor-e2e-test/vite.config.ts index 1e29a4dbd13..34cbd79985d 100644 --- a/packages-private/vapor-e2e-test/vite.config.ts +++ b/packages-private/vapor-e2e-test/vite.config.ts @@ -14,6 +14,7 @@ export default defineConfig({ input: { interop: resolve(import.meta.dirname, 'interop/index.html'), todomvc: resolve(import.meta.dirname, 'todomvc/index.html'), + keepalive: resolve(import.meta.dirname, 'keepalive/index.html'), }, }, }, diff --git a/packages/compiler-vapor/src/generators/component.ts b/packages/compiler-vapor/src/generators/component.ts index 10705a2c795..4d8a1826e0b 100644 --- a/packages/compiler-vapor/src/generators/component.ts +++ b/packages/compiler-vapor/src/generators/component.ts @@ -39,6 +39,7 @@ import { genEventHandler } from './event' import { genDirectiveModifiers, genDirectivesForElement } from './directive' import { genBlock } from './block' import { genModelHandler } from './vModel' +import { isBuiltInComponent } from '../utils' export function genCreateComponent( operation: CreateComponentIRNode, @@ -92,8 +93,15 @@ export function genCreateComponent( } else if (operation.asset) { return toValidAssetId(operation.tag, 'component') } else { + const { tag } = operation + const builtInTag = isBuiltInComponent(tag) + if (builtInTag) { + // @ts-expect-error + helper(builtInTag) + return `_${builtInTag}` + } return genExpression( - extend(createSimpleExpression(operation.tag, false), { ast: null }), + extend(createSimpleExpression(tag, false), { ast: null }), context, ) } diff --git a/packages/compiler-vapor/src/transforms/transformElement.ts b/packages/compiler-vapor/src/transforms/transformElement.ts index 05153e729af..a22691a7e21 100644 --- a/packages/compiler-vapor/src/transforms/transformElement.ts +++ b/packages/compiler-vapor/src/transforms/transformElement.ts @@ -36,7 +36,7 @@ import { type VaporDirectiveNode, } from '../ir' import { EMPTY_EXPRESSION } from './utils' -import { findProp } from '../utils' +import { findProp, isBuiltInComponent } from '../utils' export const isReservedProp: (key: string) => boolean = /*#__PURE__*/ makeMap( // the leading comma is intentional so empty string "" is also included @@ -122,6 +122,12 @@ function transformComponentElement( asset = false } + const builtInTag = isBuiltInComponent(tag) + if (builtInTag) { + tag = builtInTag + asset = false + } + const dotIndex = tag.indexOf('.') if (dotIndex > 0) { const ns = resolveSetupReference(tag.slice(0, dotIndex), context) diff --git a/packages/compiler-vapor/src/utils.ts b/packages/compiler-vapor/src/utils.ts index 728281914fd..f9cb9d7e630 100644 --- a/packages/compiler-vapor/src/utils.ts +++ b/packages/compiler-vapor/src/utils.ts @@ -88,3 +88,14 @@ export function getLiteralExpressionValue( } return exp.isStatic ? exp.content : null } + +export function isKeepAliveTag(tag: string): boolean { + tag = tag.toLowerCase() + return tag === 'keepalive' || tag === 'vaporkeepalive' +} + +export function isBuiltInComponent(tag: string): string | undefined { + if (isKeepAliveTag(tag)) { + return 'VaporKeepAlive' + } +} diff --git a/packages/runtime-core/src/apiCreateApp.ts b/packages/runtime-core/src/apiCreateApp.ts index a1409a7fe44..f095df7f395 100644 --- a/packages/runtime-core/src/apiCreateApp.ts +++ b/packages/runtime-core/src/apiCreateApp.ts @@ -188,6 +188,13 @@ export interface VaporInteropInterface { unmount(vnode: VNode, doRemove?: boolean): void move(vnode: VNode, container: any, anchor: any): void slot(n1: VNode | null, n2: VNode, container: any, anchor: any): void + activate( + vnode: VNode, + container: any, + anchor: any, + parentComponent: ComponentInternalInstance, + ): void + deactivate(vnode: VNode, container: any): void vdomMount: (component: ConcreteComponent, props?: any, slots?: any) => any vdomUnmount: UnmountComponentFn diff --git a/packages/runtime-core/src/component.ts b/packages/runtime-core/src/component.ts index 67d5a1b8720..78314be69d1 100644 --- a/packages/runtime-core/src/component.ts +++ b/packages/runtime-core/src/component.ts @@ -197,6 +197,10 @@ export interface ComponentInternalOptions { * indicates vapor component */ __vapor?: boolean + /** + * indicates keep-alive component + */ + __isKeepAlive?: boolean /** * @internal */ diff --git a/packages/runtime-core/src/components/KeepAlive.ts b/packages/runtime-core/src/components/KeepAlive.ts index f4244f360e3..5cad49b9678 100644 --- a/packages/runtime-core/src/components/KeepAlive.ts +++ b/packages/runtime-core/src/components/KeepAlive.ts @@ -5,6 +5,7 @@ import { type GenericComponentInstance, type SetupContext, getComponentName, + getCurrentGenericInstance, getCurrentInstance, } from '../component' import { @@ -46,7 +47,7 @@ import { setTransitionHooks } from './BaseTransition' import type { ComponentRenderContext } from '../componentPublicInstance' import { devtoolsComponentAdded } from '../devtools' import { isAsyncWrapper } from '../apiAsyncComponent' -import { isSuspense } from './Suspense' +import { type SuspenseBoundary, isSuspense } from './Suspense' import { LifecycleHooks } from '../enums' type MatchPattern = string | RegExp | (string | RegExp)[] @@ -71,9 +72,11 @@ export interface KeepAliveContext extends ComponentRenderContext { optimized: boolean, ) => void deactivate: (vnode: VNode) => void + getCachedComponent: (vnode: VNode) => VNode + getStorageContainer: () => RendererElement } -export const isKeepAlive = (vnode: VNode): boolean => +export const isKeepAlive = (vnode: any): boolean => (vnode.type as any).__isKeepAlive const KeepAliveImpl: ComponentOptions = { @@ -118,16 +121,21 @@ const KeepAliveImpl: ComponentOptions = { const parentSuspense = keepAliveInstance.suspense + const { renderer } = sharedContext const { - renderer: { - p: patch, - m: move, - um: _unmount, - o: { createElement }, - }, - } = sharedContext + um: _unmount, + o: { createElement }, + } = renderer const storageContainer = createElement('div') + sharedContext.getStorageContainer = () => storageContainer + + sharedContext.getCachedComponent = (vnode: VNode) => { + const key = + vnode.key == null ? (vnode.type as ConcreteComponent) : vnode.key + return cache.get(key)! + } + sharedContext.activate = ( vnode, container, @@ -135,85 +143,26 @@ const KeepAliveImpl: ComponentOptions = { namespace, optimized, ) => { - const instance = vnode.component! - move( + activate( vnode, container, anchor, - MoveType.ENTER, + renderer, keepAliveInstance, parentSuspense, - ) - // in case props have changed - patch( - instance.vnode, - vnode, - container, - anchor, - instance, - parentSuspense, namespace, - vnode.slotScopeIds, optimized, ) - queuePostRenderEffect( - () => { - instance.isDeactivated = false - if (instance.a) { - invokeArrayFns(instance.a) - } - const vnodeHook = vnode.props && vnode.props.onVnodeMounted - if (vnodeHook) { - invokeVNodeHook(vnodeHook, instance.parent, vnode) - } - }, - undefined, - parentSuspense, - ) - - if (__DEV__ || __FEATURE_PROD_DEVTOOLS__) { - // Update components tree - devtoolsComponentAdded(instance) - } } sharedContext.deactivate = (vnode: VNode) => { - const instance = vnode.component! - invalidateMount(instance.m) - invalidateMount(instance.a) - - move( + deactivate( vnode, storageContainer, - null, - MoveType.LEAVE, + renderer, keepAliveInstance, parentSuspense, ) - queuePostRenderEffect( - () => { - if (instance.da) { - invokeArrayFns(instance.da) - } - const vnodeHook = vnode.props && vnode.props.onVnodeUnmounted - if (vnodeHook) { - invokeVNodeHook(vnodeHook, instance.parent, vnode) - } - instance.isDeactivated = true - }, - undefined, - parentSuspense, - ) - - if (__DEV__ || __FEATURE_PROD_DEVTOOLS__) { - // Update components tree - devtoolsComponentAdded(instance) - } - - // for e2e test - if (__DEV__ && __BROWSER__) { - ;(instance as any).__keepAliveStorageContainer = storageContainer - } } function unmount(vnode: VNode) { @@ -415,7 +364,7 @@ export const KeepAlive = (__COMPAT__ } } -function matches(pattern: MatchPattern, name: string): boolean { +export function matches(pattern: MatchPattern, name: string): boolean { if (isArray(pattern)) { return pattern.some((p: string | RegExp) => matches(p, name)) } else if (isString(pattern)) { @@ -430,14 +379,14 @@ function matches(pattern: MatchPattern, name: string): boolean { export function onActivated( hook: Function, - target?: ComponentInternalInstance | null, + target?: GenericComponentInstance | null, ): void { registerKeepAliveHook(hook, LifecycleHooks.ACTIVATED, target) } export function onDeactivated( hook: Function, - target?: ComponentInternalInstance | null, + target?: GenericComponentInstance | null, ): void { registerKeepAliveHook(hook, LifecycleHooks.DEACTIVATED, target) } @@ -445,7 +394,7 @@ export function onDeactivated( function registerKeepAliveHook( hook: Function & { __wdc?: Function }, type: LifecycleHooks, - target: ComponentInternalInstance | null = getCurrentInstance(), + target: GenericComponentInstance | null = getCurrentGenericInstance(), ) { // cache the deactivate branch check wrapper for injected hooks so the same // hook can be properly deduped by the scheduler. "__wdc" stands for "with @@ -471,8 +420,9 @@ function registerKeepAliveHook( // arrays. if (target) { let current = target.parent - while (current && current.parent && current.parent.vnode) { - if (isKeepAlive(current.parent.vnode)) { + while (current && current.parent) { + let parent = current.parent + if (isKeepAlive(parent.vapor ? parent : parent.vnode)) { injectToKeepAliveRoot(wrappedHook, type, target, current) } current = current.parent @@ -483,7 +433,7 @@ function registerKeepAliveHook( function injectToKeepAliveRoot( hook: Function & { __weh?: Function }, type: LifecycleHooks, - target: ComponentInternalInstance, + target: GenericComponentInstance, keepAliveRoot: GenericComponentInstance, ) { // injectHook wraps the original for error handling, so make sure to remove @@ -494,7 +444,7 @@ function injectToKeepAliveRoot( }, target) } -function resetShapeFlag(vnode: VNode) { +export function resetShapeFlag(vnode: any): void { // bitwise operations to remove keep alive flags vnode.shapeFlag &= ~ShapeFlags.COMPONENT_SHOULD_KEEP_ALIVE vnode.shapeFlag &= ~ShapeFlags.COMPONENT_KEPT_ALIVE @@ -503,3 +453,99 @@ function resetShapeFlag(vnode: VNode) { function getInnerChild(vnode: VNode) { return vnode.shapeFlag & ShapeFlags.SUSPENSE ? vnode.ssContent! : vnode } + +/** + * shared between runtime-core and runtime-vapor + */ +export function activate( + vnode: VNode, + container: RendererElement, + anchor: RendererNode | null, + { p: patch, m: move }: RendererInternals, + parentComponent: ComponentInternalInstance | null, + parentSuspense: SuspenseBoundary | null, + namespace?: ElementNamespace, + optimized?: boolean, +): void { + const instance = vnode.component! + move( + vnode, + container, + anchor, + MoveType.ENTER, + parentComponent, + parentSuspense, + ) + // in case props have changed + patch( + instance.vnode, + vnode, + container, + anchor, + instance, + parentSuspense, + namespace, + vnode.slotScopeIds, + optimized, + ) + queuePostRenderEffect( + () => { + instance.isDeactivated = false + if (instance.a) { + invokeArrayFns(instance.a) + } + const vnodeHook = vnode.props && vnode.props.onVnodeMounted + if (vnodeHook) { + invokeVNodeHook(vnodeHook, instance.parent, vnode) + } + }, + undefined, + parentSuspense, + ) + + if (__DEV__ || __FEATURE_PROD_DEVTOOLS__) { + // Update components tree + devtoolsComponentAdded(instance) + } +} + +/** + * shared between runtime-core and runtime-vapor + */ +export function deactivate( + vnode: VNode, + container: RendererElement, + { m: move }: RendererInternals, + parentComponent: ComponentInternalInstance | null, + parentSuspense: SuspenseBoundary | null, +): void { + const instance = vnode.component! + invalidateMount(instance.m) + invalidateMount(instance.a) + + move(vnode, container, null, MoveType.LEAVE, parentComponent, parentSuspense) + queuePostRenderEffect( + () => { + if (instance.da) { + invokeArrayFns(instance.da) + } + const vnodeHook = vnode.props && vnode.props.onVnodeUnmounted + if (vnodeHook) { + invokeVNodeHook(vnodeHook, instance.parent, vnode) + } + instance.isDeactivated = true + }, + undefined, + parentSuspense, + ) + + if (__DEV__ || __FEATURE_PROD_DEVTOOLS__) { + // Update components tree + devtoolsComponentAdded(instance) + } + + // for e2e test + if (__DEV__ && __BROWSER__) { + ;(instance as any).__keepAliveStorageContainer = container + } +} diff --git a/packages/runtime-core/src/index.ts b/packages/runtime-core/src/index.ts index 243bde548c5..b7a4745a7a7 100644 --- a/packages/runtime-core/src/index.ts +++ b/packages/runtime-core/src/index.ts @@ -505,7 +505,7 @@ export { type VaporInteropInterface } from './apiCreateApp' /** * @internal */ -export { type RendererInternals, MoveType } from './renderer' +export { type RendererInternals, MoveType, invalidateMount } from './renderer' /** * @internal */ @@ -558,6 +558,24 @@ export { startMeasure, endMeasure } from './profiling' * @internal */ export { initFeatureFlags } from './featureFlags' +/** + * @internal + */ +export { getComponentName } from './component' +/** + * @internal + */ +export { + matches, + isKeepAlive, + resetShapeFlag, + activate, + deactivate, +} from './components/KeepAlive' +/** + * @internal + */ +export { devtoolsComponentAdded } from './devtools' /** * @internal */ diff --git a/packages/runtime-core/src/renderer.ts b/packages/runtime-core/src/renderer.ts index bad40f14393..21bbfa90cc4 100644 --- a/packages/runtime-core/src/renderer.ts +++ b/packages/runtime-core/src/renderer.ts @@ -1180,12 +1180,21 @@ function baseCreateRenderer( if ((n2.type as ConcreteComponent).__vapor) { if (n1 == null) { - getVaporInterface(parentComponent, n2).mount( - n2, - container, - anchor, - parentComponent, - ) + if (n2.shapeFlag & ShapeFlags.COMPONENT_KEPT_ALIVE) { + getVaporInterface(parentComponent, n2).activate( + n2, + container, + anchor, + parentComponent!, + ) + } else { + getVaporInterface(parentComponent, n2).mount( + n2, + container, + anchor, + parentComponent, + ) + } } else { getVaporInterface(parentComponent, n2).update( n1, @@ -2252,7 +2261,14 @@ function baseCreateRenderer( } if (shapeFlag & ShapeFlags.COMPONENT_SHOULD_KEEP_ALIVE) { - ;(parentComponent!.ctx as KeepAliveContext).deactivate(vnode) + if ((vnode.type as ConcreteComponent).__vapor) { + getVaporInterface(parentComponent!, vnode).deactivate( + vnode, + (parentComponent!.ctx as KeepAliveContext).getStorageContainer(), + ) + } else { + ;(parentComponent!.ctx as KeepAliveContext).deactivate(vnode) + } return } @@ -2523,7 +2539,7 @@ function baseCreateRenderer( const getNextHostNode: NextFn = vnode => { if (vnode.shapeFlag & ShapeFlags.COMPONENT) { if ((vnode.type as ConcreteComponent).__vapor) { - return hostNextSibling((vnode.component! as any).block) + return hostNextSibling(vnode.anchor!) } return getNextHostNode(vnode.component!.subTree) } @@ -2725,9 +2741,11 @@ export function traverseStaticChildren( } function locateNonHydratedAsyncRoot( - instance: ComponentInternalInstance, + instance: GenericComponentInstance, ): ComponentInternalInstance | undefined { - const subComponent = instance.subTree.component + const subComponent = instance.vapor + ? null + : (instance as ComponentInternalInstance).subTree.component if (subComponent) { if (subComponent.asyncDep && !subComponent.asyncResolved) { return subComponent diff --git a/packages/runtime-vapor/__tests__/components/KeepAlive.spec.ts b/packages/runtime-vapor/__tests__/components/KeepAlive.spec.ts new file mode 100644 index 00000000000..754d83ac680 --- /dev/null +++ b/packages/runtime-vapor/__tests__/components/KeepAlive.spec.ts @@ -0,0 +1,1189 @@ +import { + nextTick, + onActivated, + onBeforeMount, + onDeactivated, + onMounted, + onUnmounted, + reactive, + ref, + shallowRef, +} from 'vue' +import type { LooseRawProps, VaporComponent } from '../../src/component' +import { makeRender } from '../_utils' +import { VaporKeepAliveImpl as VaporKeepAlive } from '../../src/components/KeepAlive' +import { + child, + createComponent, + createDynamicComponent, + createIf, + createTemplateRefSetter, + defineVaporComponent, + renderEffect, + setText, + template, +} from '../../src' + +const define = makeRender() + +describe('VaporKeepAlive', () => { + let one: VaporComponent + let two: VaporComponent + let oneTest: VaporComponent + let views: Record + let root: HTMLDivElement + + type HookType = { + beforeMount: any + mounted: any + activated: any + deactivated: any + unmounted: any + } + + let oneHooks = {} as HookType + let oneTestHooks = {} as HookType + let twoHooks = {} as HookType + + beforeEach(() => { + root = document.createElement('div') + oneHooks = { + beforeMount: vi.fn(), + mounted: vi.fn(), + activated: vi.fn(), + deactivated: vi.fn(), + unmounted: vi.fn(), + } + one = defineVaporComponent({ + name: 'one', + setup(_, { expose }) { + onBeforeMount(() => oneHooks.beforeMount()) + onMounted(() => oneHooks.mounted()) + onActivated(() => oneHooks.activated()) + onDeactivated(() => oneHooks.deactivated()) + onUnmounted(() => oneHooks.unmounted()) + + const msg = ref('one') + expose({ setMsg: (m: string) => (msg.value = m) }) + + const n0 = template(`
`)() as any + const x0 = child(n0) as any + renderEffect(() => setText(x0, msg.value)) + return n0 + }, + }) + oneTestHooks = { + beforeMount: vi.fn(), + mounted: vi.fn(), + activated: vi.fn(), + deactivated: vi.fn(), + unmounted: vi.fn(), + } + oneTest = defineVaporComponent({ + name: 'oneTest', + setup() { + onBeforeMount(() => oneTestHooks.beforeMount()) + onMounted(() => oneTestHooks.mounted()) + onActivated(() => oneTestHooks.activated()) + onDeactivated(() => oneTestHooks.deactivated()) + onUnmounted(() => oneTestHooks.unmounted()) + + const msg = ref('oneTest') + const n0 = template(`
`)() as any + const x0 = child(n0) as any + renderEffect(() => setText(x0, msg.value)) + return n0 + }, + }) + twoHooks = { + beforeMount: vi.fn(), + mounted: vi.fn(), + activated: vi.fn(), + deactivated: vi.fn(), + unmounted: vi.fn(), + } + two = defineVaporComponent({ + name: 'two', + setup() { + onBeforeMount(() => twoHooks.beforeMount()) + onMounted(() => twoHooks.mounted()) + onActivated(() => { + twoHooks.activated() + }) + onDeactivated(() => twoHooks.deactivated()) + onUnmounted(() => twoHooks.unmounted()) + + const msg = ref('two') + const n0 = template(`
`)() as any + const x0 = child(n0) as any + renderEffect(() => setText(x0, msg.value)) + return n0 + }, + }) + views = { + one, + oneTest, + two, + } + }) + + function assertHookCalls( + hooks: { + beforeMount: any + mounted: any + activated: any + deactivated: any + unmounted: any + }, + callCounts: number[], + ) { + expect([ + hooks.beforeMount.mock.calls.length, + hooks.mounted.mock.calls.length, + hooks.activated.mock.calls.length, + hooks.deactivated.mock.calls.length, + hooks.unmounted.mock.calls.length, + ]).toEqual(callCounts) + } + + test('should preserve state', async () => { + const viewRef = ref('one') + const instanceRef = ref(null) + + const { mount } = define({ + setup() { + const setTemplateRef = createTemplateRefSetter() + const n4 = createComponent(VaporKeepAlive, null, { + default: () => { + const n0 = createDynamicComponent(() => views[viewRef.value]) as any + setTemplateRef(n0, instanceRef) + return n0 + }, + }) + return n4 + }, + }).create() + + mount(root) + expect(root.innerHTML).toBe(`
one
`) + + instanceRef.value.setMsg('changed') + await nextTick() + expect(root.innerHTML).toBe(`
changed
`) + + viewRef.value = 'two' + await nextTick() + expect(root.innerHTML).toBe(`
two
`) + + viewRef.value = 'one' + await nextTick() + expect(root.innerHTML).toBe(`
changed
`) + }) + + test('should call correct lifecycle hooks', async () => { + const toggle = ref(true) + const viewRef = ref('one') + + const { mount } = define({ + setup() { + return createIf( + () => toggle.value, + () => + createComponent(VaporKeepAlive, null, { + default: () => createDynamicComponent(() => views[viewRef.value]), + }), + ) + }, + }).create() + mount(root) + expect(root.innerHTML).toBe( + `
one
`, + ) + assertHookCalls(oneHooks, [1, 1, 1, 0, 0]) + assertHookCalls(twoHooks, [0, 0, 0, 0, 0]) + + // toggle kept-alive component + viewRef.value = 'two' + await nextTick() + expect(root.innerHTML).toBe( + `
two
`, + ) + assertHookCalls(oneHooks, [1, 1, 1, 1, 0]) + assertHookCalls(twoHooks, [1, 1, 1, 0, 0]) + + viewRef.value = 'one' + await nextTick() + expect(root.innerHTML).toBe( + `
one
`, + ) + assertHookCalls(oneHooks, [1, 1, 2, 1, 0]) + assertHookCalls(twoHooks, [1, 1, 1, 1, 0]) + + viewRef.value = 'two' + await nextTick() + expect(root.innerHTML).toBe( + `
two
`, + ) + assertHookCalls(oneHooks, [1, 1, 2, 2, 0]) + assertHookCalls(twoHooks, [1, 1, 2, 1, 0]) + + // teardown keep-alive, should unmount all components including cached + toggle.value = false + await nextTick() + expect(root.innerHTML).toBe(``) + assertHookCalls(oneHooks, [1, 1, 2, 2, 1]) + assertHookCalls(twoHooks, [1, 1, 2, 2, 1]) + }) + + test('should call correct lifecycle hooks when toggle the KeepAlive first', async () => { + const toggle = ref(true) + const viewRef = ref('one') + + const { mount } = define({ + setup() { + return createIf( + () => toggle.value, + () => + createComponent(VaporKeepAlive, null, { + default: () => createDynamicComponent(() => views[viewRef.value]), + }), + ) + }, + }).create() + mount(root) + expect(root.innerHTML).toBe( + `
one
`, + ) + assertHookCalls(oneHooks, [1, 1, 1, 0, 0]) + assertHookCalls(twoHooks, [0, 0, 0, 0, 0]) + + // should unmount 'one' component when toggle the KeepAlive first + toggle.value = false + await nextTick() + expect(root.innerHTML).toBe(``) + assertHookCalls(oneHooks, [1, 1, 1, 1, 1]) + assertHookCalls(twoHooks, [0, 0, 0, 0, 0]) + + toggle.value = true + await nextTick() + expect(root.innerHTML).toBe( + `
one
`, + ) + assertHookCalls(oneHooks, [2, 2, 2, 1, 1]) + assertHookCalls(twoHooks, [0, 0, 0, 0, 0]) + + // 1. the first time toggle kept-alive component + viewRef.value = 'two' + await nextTick() + expect(root.innerHTML).toBe( + `
two
`, + ) + assertHookCalls(oneHooks, [2, 2, 2, 2, 1]) + assertHookCalls(twoHooks, [1, 1, 1, 0, 0]) + + // 2. should unmount all components including cached + toggle.value = false + await nextTick() + expect(root.innerHTML).toBe(``) + assertHookCalls(oneHooks, [2, 2, 2, 2, 2]) + assertHookCalls(twoHooks, [1, 1, 1, 1, 1]) + }) + + test('should call lifecycle hooks on nested components', async () => { + const one = defineVaporComponent({ + name: 'one', + setup() { + onBeforeMount(() => oneHooks.beforeMount()) + onMounted(() => oneHooks.mounted()) + onActivated(() => oneHooks.activated()) + onDeactivated(() => oneHooks.deactivated()) + onUnmounted(() => oneHooks.unmounted()) + return createComponent(two) + }, + }) + const toggle = ref(true) + const { html } = define({ + setup() { + return createComponent(VaporKeepAlive, null, { + default() { + return createIf( + () => toggle.value, + () => + createComponent(one as any, null, { + default: () => createDynamicComponent(() => views['one']), + }), + ) + }, + }) + }, + }).render() + expect(html()).toBe(`
two
`) + assertHookCalls(oneHooks, [1, 1, 1, 0, 0]) + assertHookCalls(twoHooks, [1, 1, 1, 0, 0]) + + toggle.value = false + await nextTick() + expect(html()).toBe(``) + assertHookCalls(oneHooks, [1, 1, 1, 1, 0]) + assertHookCalls(twoHooks, [1, 1, 1, 1, 0]) + + toggle.value = true + await nextTick() + expect(html()).toBe(`
two
`) + assertHookCalls(oneHooks, [1, 1, 2, 1, 0]) + assertHookCalls(twoHooks, [1, 1, 2, 1, 0]) + + toggle.value = false + await nextTick() + expect(html()).toBe(``) + assertHookCalls(oneHooks, [1, 1, 2, 2, 0]) + assertHookCalls(twoHooks, [1, 1, 2, 2, 0]) + }) + + test('should call lifecycle hooks on nested components when root component no hooks', async () => { + const spy = vi.fn() + const two = defineVaporComponent({ + name: 'two', + setup() { + onActivated(() => spy()) + return template(`
two
`)() + }, + }) + const one = defineVaporComponent({ + name: 'one', + setup() { + return createComponent(two) + }, + }) + + const toggle = ref(true) + const { html } = define({ + setup() { + return createComponent(VaporKeepAlive, null, { + default() { + return createIf( + () => toggle.value, + () => createComponent(one), + ) + }, + }) + }, + }).render() + + expect(html()).toBe(`
two
`) + expect(spy).toHaveBeenCalledTimes(1) + }) + + test('should call correct hooks for nested keep-alive', async () => { + const toggle2 = ref(true) + const one = defineVaporComponent({ + name: 'one', + setup() { + onBeforeMount(() => oneHooks.beforeMount()) + onMounted(() => oneHooks.mounted()) + onActivated(() => oneHooks.activated()) + onDeactivated(() => oneHooks.deactivated()) + onUnmounted(() => oneHooks.unmounted()) + return createComponent(VaporKeepAlive, null, { + default() { + return createIf( + () => toggle2.value, + () => createComponent(two), + ) + }, + }) + }, + }) + + const toggle1 = ref(true) + const { html } = define({ + setup() { + return createComponent(VaporKeepAlive, null, { + default() { + return createIf( + () => toggle1.value, + () => createComponent(one), + ) + }, + }) + }, + }).render() + + expect(html()).toBe(`
two
`) + assertHookCalls(oneHooks, [1, 1, 1, 0, 0]) + assertHookCalls(twoHooks, [1, 1, 1, 0, 0]) + + toggle1.value = false + await nextTick() + expect(html()).toBe(``) + assertHookCalls(oneHooks, [1, 1, 1, 1, 0]) + assertHookCalls(twoHooks, [1, 1, 1, 1, 0]) + + toggle1.value = true + await nextTick() + expect(html()).toBe(`
two
`) + assertHookCalls(oneHooks, [1, 1, 2, 1, 0]) + assertHookCalls(twoHooks, [1, 1, 2, 1, 0]) + + // toggle nested instance + toggle2.value = false + await nextTick() + expect(html()).toBe(``) + assertHookCalls(oneHooks, [1, 1, 2, 1, 0]) + assertHookCalls(twoHooks, [1, 1, 2, 2, 0]) + + toggle2.value = true + await nextTick() + expect(html()).toBe(`
two
`) + assertHookCalls(oneHooks, [1, 1, 2, 1, 0]) + // problem is component one isDeactivated. leading to + // the activated hook of two is not called + assertHookCalls(twoHooks, [1, 1, 3, 2, 0]) + + toggle1.value = false + await nextTick() + expect(html()).toBe(``) + assertHookCalls(oneHooks, [1, 1, 2, 2, 0]) + assertHookCalls(twoHooks, [1, 1, 3, 3, 0]) + + // toggle nested instance when parent is deactivated + toggle2.value = false + await nextTick() + expect(html()).toBe(``) + assertHookCalls(oneHooks, [1, 1, 2, 2, 0]) + assertHookCalls(twoHooks, [1, 1, 3, 3, 0]) // should not be affected + + toggle2.value = true + await nextTick() + expect(html()).toBe(``) + assertHookCalls(oneHooks, [1, 1, 2, 2, 0]) + assertHookCalls(twoHooks, [1, 1, 3, 3, 0]) // should not be affected + + toggle1.value = true + await nextTick() + expect(html()).toBe(`
two
`) + assertHookCalls(oneHooks, [1, 1, 3, 2, 0]) + assertHookCalls(twoHooks, [1, 1, 4, 3, 0]) + + toggle1.value = false + toggle2.value = false + await nextTick() + expect(html()).toBe(``) + assertHookCalls(oneHooks, [1, 1, 3, 3, 0]) + assertHookCalls(twoHooks, [1, 1, 4, 4, 0]) + + toggle1.value = true + await nextTick() + expect(html()).toBe(``) + assertHookCalls(oneHooks, [1, 1, 4, 3, 0]) + assertHookCalls(twoHooks, [1, 1, 4, 4, 0]) // should remain inactive + }) + + async function assertNameMatch(props: LooseRawProps) { + const outerRef = ref(true) + const viewRef = ref('one') + const { html } = define({ + setup() { + return createIf( + () => outerRef.value, + () => + createComponent(VaporKeepAlive, props, { + default: () => createDynamicComponent(() => views[viewRef.value]), + }), + ) + }, + }).render() + + expect(html()).toBe(`
one
`) + assertHookCalls(oneHooks, [1, 1, 1, 0, 0]) + assertHookCalls(twoHooks, [0, 0, 0, 0, 0]) + + viewRef.value = 'two' + await nextTick() + expect(html()).toBe(`
two
`) + assertHookCalls(oneHooks, [1, 1, 1, 1, 0]) + assertHookCalls(twoHooks, [1, 1, 0, 0, 0]) + + viewRef.value = 'one' + await nextTick() + expect(html()).toBe(`
one
`) + assertHookCalls(oneHooks, [1, 1, 2, 1, 0]) + assertHookCalls(twoHooks, [1, 1, 0, 0, 1]) + + viewRef.value = 'two' + await nextTick() + expect(html()).toBe(`
two
`) + assertHookCalls(oneHooks, [1, 1, 2, 2, 0]) + assertHookCalls(twoHooks, [2, 2, 0, 0, 1]) + + // teardown + outerRef.value = false + await nextTick() + expect(html()).toBe(``) + assertHookCalls(oneHooks, [1, 1, 2, 2, 1]) + assertHookCalls(twoHooks, [2, 2, 0, 0, 2]) + } + + async function assertNameMatchWithFlag(props: LooseRawProps) { + const outerRef = ref(true) + const viewRef = ref('one') + const { html } = define({ + setup() { + return createIf( + () => outerRef.value, + () => + createComponent(VaporKeepAlive, props, { + default: () => createDynamicComponent(() => views[viewRef.value]), + }), + ) + }, + }).render() + + expect(html()).toBe(`
one
`) + assertHookCalls(oneHooks, [1, 1, 1, 0, 0]) + assertHookCalls(oneTestHooks, [0, 0, 0, 0, 0]) + assertHookCalls(twoHooks, [0, 0, 0, 0, 0]) + + viewRef.value = 'oneTest' + await nextTick() + expect(html()).toBe(`
oneTest
`) + assertHookCalls(oneHooks, [1, 1, 1, 1, 0]) + assertHookCalls(oneTestHooks, [1, 1, 1, 0, 0]) + assertHookCalls(twoHooks, [0, 0, 0, 0, 0]) + + viewRef.value = 'two' + await nextTick() + expect(html()).toBe(`
two
`) + assertHookCalls(oneHooks, [1, 1, 1, 1, 0]) + assertHookCalls(oneTestHooks, [1, 1, 1, 1, 0]) + assertHookCalls(twoHooks, [1, 1, 0, 0, 0]) + + viewRef.value = 'one' + await nextTick() + expect(html()).toBe(`
one
`) + assertHookCalls(oneHooks, [1, 1, 2, 1, 0]) + assertHookCalls(oneTestHooks, [1, 1, 1, 1, 0]) + assertHookCalls(twoHooks, [1, 1, 0, 0, 1]) + + viewRef.value = 'oneTest' + await nextTick() + expect(html()).toBe(`
oneTest
`) + assertHookCalls(oneHooks, [1, 1, 2, 2, 0]) + assertHookCalls(oneTestHooks, [1, 1, 2, 1, 0]) + assertHookCalls(twoHooks, [1, 1, 0, 0, 1]) + + viewRef.value = 'two' + await nextTick() + expect(html()).toBe(`
two
`) + assertHookCalls(oneHooks, [1, 1, 2, 2, 0]) + assertHookCalls(oneTestHooks, [1, 1, 2, 2, 0]) + assertHookCalls(twoHooks, [2, 2, 0, 0, 1]) + + // teardown + outerRef.value = false + await nextTick() + expect(html()).toBe(``) + assertHookCalls(oneHooks, [1, 1, 2, 2, 1]) + assertHookCalls(oneTestHooks, [1, 1, 2, 2, 1]) + assertHookCalls(twoHooks, [2, 2, 0, 0, 2]) + } + + async function assertNameMatchWithFlagExclude(props: LooseRawProps) { + const outerRef = ref(true) + const viewRef = ref('one') + const { html } = define({ + setup() { + return createIf( + () => outerRef.value, + () => + createComponent(VaporKeepAlive, props, { + default: () => createDynamicComponent(() => views[viewRef.value]), + }), + ) + }, + }).render() + + expect(html()).toBe(`
one
`) + assertHookCalls(oneHooks, [1, 1, 0, 0, 0]) + assertHookCalls(oneTestHooks, [0, 0, 0, 0, 0]) + assertHookCalls(twoHooks, [0, 0, 0, 0, 0]) + + viewRef.value = 'oneTest' + await nextTick() + expect(html()).toBe(`
oneTest
`) + assertHookCalls(oneHooks, [1, 1, 0, 0, 1]) + assertHookCalls(oneTestHooks, [1, 1, 0, 0, 0]) + assertHookCalls(twoHooks, [0, 0, 0, 0, 0]) + + viewRef.value = 'two' + await nextTick() + expect(html()).toBe(`
two
`) + assertHookCalls(oneHooks, [1, 1, 0, 0, 1]) + assertHookCalls(oneTestHooks, [1, 1, 0, 0, 1]) + assertHookCalls(twoHooks, [1, 1, 1, 0, 0]) + + viewRef.value = 'one' + await nextTick() + expect(html()).toBe(`
one
`) + assertHookCalls(oneHooks, [2, 2, 0, 0, 1]) + assertHookCalls(oneTestHooks, [1, 1, 0, 0, 1]) + assertHookCalls(twoHooks, [1, 1, 1, 1, 0]) + + viewRef.value = 'oneTest' + await nextTick() + expect(html()).toBe(`
oneTest
`) + assertHookCalls(oneHooks, [2, 2, 0, 0, 2]) + assertHookCalls(oneTestHooks, [2, 2, 0, 0, 1]) + assertHookCalls(twoHooks, [1, 1, 1, 1, 0]) + + viewRef.value = 'two' + await nextTick() + expect(html()).toBe(`
two
`) + assertHookCalls(oneHooks, [2, 2, 0, 0, 2]) + assertHookCalls(oneTestHooks, [2, 2, 0, 0, 2]) + assertHookCalls(twoHooks, [1, 1, 2, 1, 0]) + + // teardown + outerRef.value = false + await nextTick() + expect(html()).toBe(``) + assertHookCalls(oneHooks, [2, 2, 0, 0, 2]) + assertHookCalls(oneTestHooks, [2, 2, 0, 0, 2]) + assertHookCalls(twoHooks, [1, 1, 2, 2, 1]) + } + + describe('props', () => { + test('include (string)', async () => { + await assertNameMatch({ include: () => 'one' }) + }) + + test('include (regex)', async () => { + await assertNameMatch({ include: () => /^one$/ }) + }) + + test('include (regex with g flag)', async () => { + await assertNameMatchWithFlag({ include: () => /one/g }) + }) + + 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 (regex with a flag)', async () => { + await assertNameMatchWithFlagExclude({ exclude: () => /one/g }) + }) + + test('exclude (array)', async () => { + await assertNameMatch({ exclude: () => ['two'] }) + }) + + test('include + exclude', async () => { + await assertNameMatch({ include: () => 'one,two', exclude: () => 'two' }) + }) + + test('max', async () => { + const spyAC = vi.fn() + const spyBC = vi.fn() + const spyCC = vi.fn() + const spyAA = vi.fn() + const spyBA = vi.fn() + const spyCA = vi.fn() + const spyADA = vi.fn() + const spyBDA = vi.fn() + const spyCDA = vi.fn() + const spyAUM = vi.fn() + const spyBUM = vi.fn() + const spyCUM = vi.fn() + + function assertCount(calls: number[]) { + expect([ + spyAC.mock.calls.length, + spyAA.mock.calls.length, + spyADA.mock.calls.length, + spyAUM.mock.calls.length, + spyBC.mock.calls.length, + spyBA.mock.calls.length, + spyBDA.mock.calls.length, + spyBUM.mock.calls.length, + spyCC.mock.calls.length, + spyCA.mock.calls.length, + spyCDA.mock.calls.length, + spyCUM.mock.calls.length, + ]).toEqual(calls) + } + const viewRef = ref('a') + const views: Record = { + a: defineVaporComponent({ + name: 'a', + setup() { + onBeforeMount(() => spyAC()) + onActivated(() => spyAA()) + onDeactivated(() => spyADA()) + onUnmounted(() => spyAUM()) + return template(`one`)() + }, + }), + b: defineVaporComponent({ + name: 'b', + setup() { + onBeforeMount(() => spyBC()) + onActivated(() => spyBA()) + onDeactivated(() => spyBDA()) + onUnmounted(() => spyBUM()) + return template(`two`)() + }, + }), + c: defineVaporComponent({ + name: 'c', + setup() { + onBeforeMount(() => spyCC()) + onActivated(() => spyCA()) + onDeactivated(() => spyCDA()) + onUnmounted(() => spyCUM()) + return template(`three`)() + }, + }), + } + + define({ + setup() { + return createComponent( + VaporKeepAlive, + { max: () => 2 }, + { + default: () => createDynamicComponent(() => views[viewRef.value]), + }, + ) + }, + }).render() + assertCount([1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]) + + viewRef.value = 'b' + await nextTick() + assertCount([1, 1, 1, 0, 1, 1, 0, 0, 0, 0, 0, 0]) + + viewRef.value = 'c' + await nextTick() + // should prune A because max cache reached + assertCount([1, 1, 1, 1, 1, 1, 1, 0, 1, 1, 0, 0]) + + viewRef.value = 'b' + await nextTick() + // B should be reused, and made latest + assertCount([1, 1, 1, 1, 1, 2, 1, 0, 1, 1, 1, 0]) + + viewRef.value = 'a' + await nextTick() + // C should be pruned because B was used last so C is the oldest cached + assertCount([2, 2, 1, 1, 1, 2, 2, 0, 1, 1, 1, 1]) + }) + }) + + describe('cache invalidation', () => { + function setup() { + const viewRef = ref('one') + const includeRef = ref('one,two') + define({ + setup() { + return createComponent( + VaporKeepAlive, + { include: () => includeRef.value }, + { + default: () => createDynamicComponent(() => views[viewRef.value]), + }, + ) + }, + }).render() + return { viewRef, includeRef } + } + + function setupExclude() { + const viewRef = ref('one') + const excludeRef = ref('') + define({ + setup() { + return createComponent( + VaporKeepAlive, + { exclude: () => excludeRef.value }, + { + default: () => createDynamicComponent(() => views[viewRef.value]), + }, + ) + }, + }).render() + return { viewRef, excludeRef } + } + + test('on include change', async () => { + const { viewRef, includeRef } = setup() + + viewRef.value = 'two' + await nextTick() + assertHookCalls(oneHooks, [1, 1, 1, 1, 0]) + assertHookCalls(twoHooks, [1, 1, 1, 0, 0]) + + includeRef.value = 'two' + await nextTick() + assertHookCalls(oneHooks, [1, 1, 1, 1, 1]) + assertHookCalls(twoHooks, [1, 1, 1, 0, 0]) + + viewRef.value = 'one' + await nextTick() + assertHookCalls(oneHooks, [2, 2, 1, 1, 1]) + assertHookCalls(twoHooks, [1, 1, 1, 1, 0]) + }) + + test('on exclude change', async () => { + const { viewRef, excludeRef } = setupExclude() + + viewRef.value = 'two' + await nextTick() + assertHookCalls(oneHooks, [1, 1, 1, 1, 0]) + assertHookCalls(twoHooks, [1, 1, 1, 0, 0]) + + excludeRef.value = 'one' + await nextTick() + assertHookCalls(oneHooks, [1, 1, 1, 1, 1]) + assertHookCalls(twoHooks, [1, 1, 1, 0, 0]) + + viewRef.value = 'one' + await nextTick() + assertHookCalls(oneHooks, [2, 2, 1, 1, 1]) + assertHookCalls(twoHooks, [1, 1, 1, 1, 0]) + }) + + test('on include change + view switch', async () => { + const { viewRef, includeRef } = setup() + + viewRef.value = 'two' + await nextTick() + assertHookCalls(oneHooks, [1, 1, 1, 1, 0]) + assertHookCalls(twoHooks, [1, 1, 1, 0, 0]) + + includeRef.value = 'one' + viewRef.value = 'one' + await nextTick() + assertHookCalls(oneHooks, [1, 1, 2, 1, 0]) + // two should be pruned + assertHookCalls(twoHooks, [1, 1, 1, 1, 1]) + }) + + test('on exclude change + view switch', async () => { + const { viewRef, excludeRef } = setupExclude() + + viewRef.value = 'two' + await nextTick() + assertHookCalls(oneHooks, [1, 1, 1, 1, 0]) + assertHookCalls(twoHooks, [1, 1, 1, 0, 0]) + + excludeRef.value = 'two' + viewRef.value = 'one' + await nextTick() + assertHookCalls(oneHooks, [1, 1, 2, 1, 0]) + // two should be pruned + assertHookCalls(twoHooks, [1, 1, 1, 1, 1]) + }) + + test('should not prune current active instance', async () => { + const { viewRef, includeRef } = setup() + + includeRef.value = 'two' + await nextTick() + assertHookCalls(oneHooks, [1, 1, 1, 0, 0]) + assertHookCalls(twoHooks, [0, 0, 0, 0, 0]) + + viewRef.value = 'two' + await nextTick() + assertHookCalls(oneHooks, [1, 1, 1, 0, 1]) + assertHookCalls(twoHooks, [1, 1, 1, 0, 0]) + }) + + async function assertAnonymous(include: boolean) { + const oneBeforeMountHooks = vi.fn() + const one = defineVaporComponent({ + name: 'one', + setup() { + onBeforeMount(() => oneBeforeMountHooks()) + return template(`one`)() + }, + }) + + const twoBeforeMountHooks = vi.fn() + const two = defineVaporComponent({ + // anonymous + setup() { + onBeforeMount(() => twoBeforeMountHooks()) + return template(`two`)() + }, + }) + + const views: any = { one, two } + const viewRef = ref('one') + + define({ + setup() { + return createComponent( + VaporKeepAlive, + { include: () => (include ? 'one' : undefined) }, + { + default: () => createDynamicComponent(() => views[viewRef.value]), + }, + ) + }, + }).render() + + function assert(oneCreateCount: number, twoCreateCount: number) { + expect(oneBeforeMountHooks.mock.calls.length).toBe(oneCreateCount) + expect(twoBeforeMountHooks.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) + } + + 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) + }) + + test('should not destroy active instance when pruning cache', async () => { + const unmounted = vi.fn() + const Foo = defineVaporComponent({ + setup() { + onUnmounted(() => unmounted()) + return template(`foo`)() + }, + }) + + const includeRef = ref(['foo']) + define({ + setup() { + return createComponent( + VaporKeepAlive, + { include: () => includeRef.value }, + { + default: () => createDynamicComponent(() => Foo), + }, + ) + }, + }).render() + + // condition: a render where a previous component is reused + includeRef.value = ['foo', 'bar'] + await nextTick() + includeRef.value = [] + await nextTick() + expect(unmounted).not.toHaveBeenCalled() + }) + + test('should update re-activated component if props have changed', async () => { + const Foo = defineVaporComponent({ + props: ['n'], + setup(props) { + const n0 = template(`
`)() as any + const x0 = child(n0) as any + renderEffect(() => setText(x0, props.n)) + return n0 + }, + }) + + const toggle = ref(true) + const n = ref(0) + const { html } = define({ + setup() { + return createComponent(VaporKeepAlive, null, { + default: () => { + return createIf( + () => toggle.value, + () => createComponent(Foo, { n: () => n.value }), + ) + }, + }) + }, + }).render() + + expect(html()).toBe(`
0
`) + + toggle.value = false + await nextTick() + expect(html()).toBe(``) + + n.value++ + await nextTick() + toggle.value = true + await nextTick() + expect(html()).toBe(`
1
`) + }) + }) + + test.todo('should work with async component', async () => {}) + + test('handle error in async onActivated', async () => { + const err = new Error('foo') + const handler = vi.fn() + const Child = defineVaporComponent({ + setup() { + onActivated(async () => { + throw err + }) + + return template(` createComponent(Child), + }) + }, + }).create() + + app.config.errorHandler = handler + app.mount(document.createElement('div')) + + await nextTick() + expect(handler).toHaveBeenCalledTimes(1) + }) + + test('should avoid unmount later included components', async () => { + const unmountedA = vi.fn() + const mountedA = vi.fn() + const activatedA = vi.fn() + const deactivatedA = vi.fn() + const unmountedB = vi.fn() + const mountedB = vi.fn() + + const A = defineVaporComponent({ + name: 'A', + setup() { + onMounted(mountedA) + onUnmounted(unmountedA) + onActivated(activatedA) + onDeactivated(deactivatedA) + return template(`
A
`)() + }, + }) + + const B = defineVaporComponent({ + name: 'B', + setup() { + onMounted(mountedB) + onUnmounted(unmountedB) + return template(`
B
`)() + }, + }) + + const include = reactive([]) + const current = shallowRef(A) + const { html } = define({ + setup() { + return createComponent( + VaporKeepAlive, + { include: () => include }, + { + default: () => createDynamicComponent(() => current.value), + }, + ) + }, + }).render() + + expect(html()).toBe(`
A
`) + expect(mountedA).toHaveBeenCalledTimes(1) + expect(unmountedA).toHaveBeenCalledTimes(0) + expect(activatedA).toHaveBeenCalledTimes(0) + expect(deactivatedA).toHaveBeenCalledTimes(0) + expect(mountedB).toHaveBeenCalledTimes(0) + expect(unmountedB).toHaveBeenCalledTimes(0) + + include.push('A') // cache A + await nextTick() + current.value = B // toggle to B + await nextTick() + expect(html()).toBe(`
B
`) + expect(mountedA).toHaveBeenCalledTimes(1) + expect(unmountedA).toHaveBeenCalledTimes(0) + expect(activatedA).toHaveBeenCalledTimes(0) + expect(deactivatedA).toHaveBeenCalledTimes(1) + expect(mountedB).toHaveBeenCalledTimes(1) + expect(unmountedB).toHaveBeenCalledTimes(0) + }) + + test('remove component from include then switching child', async () => { + const About = defineVaporComponent({ + name: 'About', + setup() { + return template(`

About

`)() + }, + }) + const mountedHome = vi.fn() + const unmountedHome = vi.fn() + const activatedHome = vi.fn() + const deactivatedHome = vi.fn() + + const Home = defineVaporComponent({ + name: 'Home', + setup() { + onMounted(mountedHome) + onUnmounted(unmountedHome) + onDeactivated(deactivatedHome) + onActivated(activatedHome) + return template(`

Home

`)() + }, + }) + + const activeViewName = ref('Home') + const cacheList = reactive(['Home']) + + define({ + setup() { + return createComponent( + VaporKeepAlive, + { include: () => cacheList }, + { + default: () => { + return createIf( + () => activeViewName.value === 'Home', + () => createComponent(Home), + () => createComponent(About), + ) + }, + }, + ) + }, + }).render() + + expect(mountedHome).toHaveBeenCalledTimes(1) + expect(activatedHome).toHaveBeenCalledTimes(1) + cacheList.splice(0, 1) + await nextTick() + activeViewName.value = 'About' + await nextTick() + expect(deactivatedHome).toHaveBeenCalledTimes(0) + expect(unmountedHome).toHaveBeenCalledTimes(1) + }) +}) diff --git a/packages/runtime-vapor/src/apiTemplateRef.ts b/packages/runtime-vapor/src/apiTemplateRef.ts index 7a30d219811..885c56dbe29 100644 --- a/packages/runtime-vapor/src/apiTemplateRef.ts +++ b/packages/runtime-vapor/src/apiTemplateRef.ts @@ -51,7 +51,6 @@ export function setRef( const setupState: any = __DEV__ ? instance.setupState || {} : null const refValue = getRefValue(el) - const refs = instance.refs === EMPTY_OBJ ? (instance.refs = {}) : instance.refs diff --git a/packages/runtime-vapor/src/block.ts b/packages/runtime-vapor/src/block.ts index e021ce84b05..1cfda886c39 100644 --- a/packages/runtime-vapor/src/block.ts +++ b/packages/runtime-vapor/src/block.ts @@ -1,6 +1,7 @@ import { isArray } from '@vue/shared' import { type VaporComponentInstance, + currentInstance, isVaporComponent, mountComponent, unmountComponent, @@ -8,6 +9,8 @@ import { import { createComment, createTextNode } from './dom/node' import { EffectScope, setActiveSub } from '@vue/reactivity' import { isHydrating } from './dom/hydration' +import { isKeepAlive } from 'vue' +import type { KeepAliveInstance } from './components/KeepAlive' export type Block = | Node @@ -50,15 +53,23 @@ export class DynamicFragment extends VaporFragment { const prevSub = setActiveSub() const parent = this.anchor.parentNode + const instance = currentInstance! // teardown previous branch if (this.scope) { - this.scope.stop() + if (isKeepAlive(instance)) { + ;(instance as KeepAliveInstance).process(this.nodes) + } else { + this.scope.stop() + } parent && remove(this.nodes, parent) } if (render) { this.scope = new EffectScope() this.nodes = this.scope.run(render) || [] + if (isKeepAlive(instance)) { + ;(instance as KeepAliveInstance).process(this.nodes) + } if (parent) insert(this.nodes, parent, this.anchor) } else { this.scope = undefined @@ -114,7 +125,7 @@ export function insert( parent.insertBefore(block, anchor) } } else if (isVaporComponent(block)) { - if (block.isMounted) { + if (block.isMounted && !block.isDeactivated) { insert(block.block!, parent, anchor) } else { mountComponent(block, parent, anchor) diff --git a/packages/runtime-vapor/src/component.ts b/packages/runtime-vapor/src/component.ts index da57882c49d..ec503d2e664 100644 --- a/packages/runtime-vapor/src/component.ts +++ b/packages/runtime-vapor/src/component.ts @@ -15,6 +15,7 @@ import { currentInstance, endMeasure, expose, + isKeepAlive, nextUid, popWarningContext, pushWarningContext, @@ -34,7 +35,13 @@ import { setActiveSub, unref, } from '@vue/reactivity' -import { EMPTY_OBJ, invokeArrayFns, isFunction, isString } from '@vue/shared' +import { + EMPTY_OBJ, + ShapeFlags, + invokeArrayFns, + isFunction, + isString, +} from '@vue/shared' import { type DynamicPropsSource, type RawProps, @@ -58,6 +65,7 @@ import { } from './componentSlots' import { hmrReload, hmrRerender } from './hmr' import { isHydrating, locateHydrationNode } from './dom/hydration' +import type { KeepAliveInstance } from './components/KeepAlive' import { insertionAnchor, insertionParent, @@ -149,6 +157,19 @@ export function createComponent( resetInsertionState() } + // keep-alive + if ( + currentInstance && + currentInstance.vapor && + isKeepAlive(currentInstance) + ) { + const cached = (currentInstance as KeepAliveInstance).getCachedComponent( + component, + ) + // @ts-expect-error cached may be a fragment + if (cached) return cached + } + // vdom interop enabled and component is not an explicit vapor component if (appContext.vapor && !component.__vapor) { const frag = appContext.vapor.vdomMount( @@ -156,6 +177,7 @@ export function createComponent( rawProps, rawSlots, ) + if (!isHydrating && _insertionParent) { insert(frag, _insertionParent, _insertionAnchor) } @@ -395,6 +417,7 @@ export class VaporComponentInstance implements GenericComponentInstance { propsOptions?: NormalizedPropsOptions emitsOptions?: ObjectEmitsOptions | null isSingleRoot?: boolean + shapeFlag?: number constructor( comp: VaporComponent, @@ -526,15 +549,30 @@ export function createComponentWithFallback( export function mountComponent( instance: VaporComponentInstance, - parent: ParentNode, + parentNode: ParentNode, anchor?: Node | null | 0, ): void { + if (instance.shapeFlag! & ShapeFlags.COMPONENT_KEPT_ALIVE) { + ;(instance.parent as KeepAliveInstance).activate( + instance, + parentNode, + anchor, + ) + return + } + if (__DEV__) { startMeasure(instance, `mount`) } if (instance.bm) invokeArrayFns(instance.bm) - insert(instance.block, parent, anchor) - if (instance.m) queuePostFlushCb(() => invokeArrayFns(instance.m!)) + insert(instance.block, parentNode, anchor) + if (instance.m) queuePostFlushCb(instance.m!) + if ( + instance.shapeFlag! & ShapeFlags.COMPONENT_SHOULD_KEEP_ALIVE && + instance.a + ) { + queuePostFlushCb(instance.a!) + } instance.isMounted = true if (__DEV__) { endMeasure(instance, `mount`) @@ -545,6 +583,16 @@ export function unmountComponent( instance: VaporComponentInstance, parentNode?: ParentNode, ): void { + if ( + parentNode && + instance.parent && + instance.parent.vapor && + instance.shapeFlag! & ShapeFlags.COMPONENT_SHOULD_KEEP_ALIVE + ) { + ;(instance.parent as KeepAliveInstance).deactivate(instance) + return + } + if (instance.isMounted && !instance.isUnmounted) { if (__DEV__ && instance.type.__hmrId) { unregisterHMR(instance) @@ -556,7 +604,7 @@ export function unmountComponent( instance.scope.stop() if (instance.um) { - queuePostFlushCb(() => invokeArrayFns(instance.um!)) + queuePostFlushCb(instance.um!) } instance.isUnmounted = true } diff --git a/packages/runtime-vapor/src/components/KeepAlive.ts b/packages/runtime-vapor/src/components/KeepAlive.ts new file mode 100644 index 00000000000..b785f384560 --- /dev/null +++ b/packages/runtime-vapor/src/components/KeepAlive.ts @@ -0,0 +1,267 @@ +import { + type KeepAliveProps, + currentInstance, + devtoolsComponentAdded, + getComponentName, + matches, + onBeforeUnmount, + onMounted, + onUpdated, + queuePostFlushCb, + resetShapeFlag, + warn, + watch, +} from '@vue/runtime-dom' +import { + type Block, + type VaporFragment, + insert, + isFragment, + remove, +} from '../block' +import { + type ObjectVaporComponent, + type VaporComponent, + type VaporComponentInstance, + isVaporComponent, +} from '../component' +import { defineVaporComponent } from '../apiDefineComponent' +import { ShapeFlags, invokeArrayFns, isArray } from '@vue/shared' +import { createElement } from '../dom/node' + +export interface KeepAliveInstance extends VaporComponentInstance { + activate: ( + instance: VaporComponentInstance, + parentNode: ParentNode, + anchor?: Node | null | 0, + ) => void + deactivate: (instance: VaporComponentInstance) => void + process: (block: Block) => void + getCachedComponent: ( + comp: VaporComponent, + ) => VaporComponentInstance | VaporFragment | undefined + getStorageContainer: () => ParentNode +} + +type CacheKey = VaporComponent +type Cache = Map +type Keys = Set + +export const VaporKeepAliveImpl: ObjectVaporComponent = defineVaporComponent({ + name: 'VaporKeepAlive', + __isKeepAlive: true, + props: { + include: [String, RegExp, Array], + exclude: [String, RegExp, Array], + max: [String, Number], + }, + setup(props: KeepAliveProps, { slots }) { + if (!slots.default) { + return undefined + } + + const keepAliveInstance = currentInstance! as KeepAliveInstance + const cache: Cache = new Map() + const keys: Keys = new Set() + const storageContainer = createElement('div') + let current: VaporComponentInstance | VaporFragment | undefined + + if (__DEV__ || __FEATURE_PROD_DEVTOOLS__) { + ;(keepAliveInstance as any).__v_cache = cache + } + + function shouldCache(instance: VaporComponentInstance) { + const { include, exclude } = props + const name = getComponentName(instance.type) + return !( + (include && (!name || !matches(include, name))) || + (exclude && name && matches(exclude, name)) + ) + } + + function cacheBlock() { + const { max } = props + // TODO suspense + const block = keepAliveInstance.block! + const innerBlock = getInnerBlock(block)! + if (!innerBlock || !shouldCache(innerBlock)) return + + const key = innerBlock.type + if (cache.has(key)) { + // 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(keys.values().next().value!) + } + } + cache.set( + key, + (current = + isFragment(block) && isFragment(block.nodes) + ? // cache the fragment nodes for vdom interop + block.nodes + : innerBlock), + ) + } + + onMounted(cacheBlock) + onUpdated(cacheBlock) + + onBeforeUnmount(() => { + cache.forEach(item => { + const cached = getInnerComponent(item)! + resetShapeFlag(cached) + cache.delete(cached.type) + // current instance will be unmounted as part of keep-alive's unmount + if (current) { + const innerComp = getInnerComponent(current)! + if (innerComp.type === cached.type) { + const instance = cached.vapor + ? cached + : // vdom interop + (cached as any).component + const da = instance.da + da && queuePostFlushCb(da) + return + } + } + remove(item, storageContainer) + }) + }) + + keepAliveInstance.getStorageContainer = () => storageContainer + keepAliveInstance.getCachedComponent = comp => cache.get(comp) + + const processShapeFlag = (keepAliveInstance.process = block => { + const instance = getInnerComponent(block) + if (!instance) return + + if (cache.has(instance.type)) { + instance.shapeFlag! |= ShapeFlags.COMPONENT_KEPT_ALIVE + } + + if (shouldCache(instance)) { + instance.shapeFlag! |= ShapeFlags.COMPONENT_SHOULD_KEEP_ALIVE + } + }) + + keepAliveInstance.activate = (instance, parentNode, anchor) => { + current = instance + activate(instance, parentNode, anchor) + } + + keepAliveInstance.deactivate = instance => { + deactivate(instance, storageContainer) + } + + let children = slots.default() + if (isArray(children) && children.length > 1) { + if (__DEV__) { + warn(`KeepAlive should contain exactly one component child.`) + } + return children + } + + // `children` could be either a `VaporComponentInstance` or a `DynamicFragment` + // (when using `v-if` or ``). For `DynamicFragment` children, + // the `shapeFlag` is processed in `DynamicFragment.update`. Here only need + // to process the `VaporComponentInstance` + if (isVaporComponent(children)) processShapeFlag(children) + + function pruneCache(filter: (name: string) => boolean) { + cache.forEach((instance, key) => { + instance = getInnerComponent(instance)! + const name = getComponentName(instance.type) + if (name && !filter(name)) { + pruneCacheEntry(key) + } + }) + } + + function pruneCacheEntry(key: CacheKey) { + const cached = cache.get(key)! + resetShapeFlag(cached) + // don't unmount if the instance is the current one + if (cached !== current) { + remove(cached) + } + cache.delete(key) + keys.delete(key) + } + + // prune cache on include/exclude prop change + watch( + () => [props.include, props.exclude], + ([include, exclude]) => { + include && pruneCache(name => matches(include, name)) + exclude && pruneCache(name => !matches(exclude, name)) + }, + // prune post-render after `current` has been updated + { flush: 'post', deep: true }, + ) + + return children + }, +}) + +function getInnerBlock(block: Block): VaporComponentInstance | undefined { + if (isVaporComponent(block)) { + return block + } + if (isVdomInteropFragment(block)) { + return block.nodes as any + } + if (isFragment(block)) { + return getInnerBlock(block.nodes) + } +} + +function getInnerComponent(block: Block): VaporComponentInstance | undefined { + if (isVaporComponent(block)) { + return block + } else if (isVdomInteropFragment(block)) { + // vdom interop + return block.nodes as any + } +} + +function isVdomInteropFragment(block: Block): block is VaporFragment { + return !!(isFragment(block) && block.insert) +} + +export function activate( + instance: VaporComponentInstance, + parentNode: ParentNode, + anchor?: Node | null | 0, +): void { + insert(instance.block, parentNode, anchor) + + queuePostFlushCb(() => { + instance.isDeactivated = false + if (instance.a) invokeArrayFns(instance.a) + }) + + if (__DEV__ || __FEATURE_PROD_DEVTOOLS__) { + devtoolsComponentAdded(instance) + } +} + +export function deactivate( + instance: VaporComponentInstance, + container: ParentNode, +): void { + insert(instance.block, container) + + queuePostFlushCb(() => { + if (instance.da) invokeArrayFns(instance.da) + instance.isDeactivated = true + }) + + if (__DEV__ || __FEATURE_PROD_DEVTOOLS__) { + devtoolsComponentAdded(instance) + } +} diff --git a/packages/runtime-vapor/src/dom/node.ts b/packages/runtime-vapor/src/dom/node.ts index 83bc32c57f0..26cb66c462c 100644 --- a/packages/runtime-vapor/src/dom/node.ts +++ b/packages/runtime-vapor/src/dom/node.ts @@ -1,3 +1,8 @@ +/*! #__NO_SIDE_EFFECTS__ */ +export function createElement(tagName: string): HTMLElement { + return document.createElement(tagName) +} + /*! #__NO_SIDE_EFFECTS__ */ export function createTextNode(value = ''): Text { return document.createTextNode(value) diff --git a/packages/runtime-vapor/src/index.ts b/packages/runtime-vapor/src/index.ts index 7a8aea5a0d7..b588ea7478a 100644 --- a/packages/runtime-vapor/src/index.ts +++ b/packages/runtime-vapor/src/index.ts @@ -3,6 +3,7 @@ export { createVaporApp, createVaporSSRApp } from './apiCreateApp' export { defineVaporComponent } from './apiDefineComponent' export { vaporInteropPlugin } from './vdomInterop' export type { VaporDirective } from './directives/custom' +export { VaporKeepAliveImpl as VaporKeepAlive } from './components/KeepAlive' // compiler-use only export { insert, prepend, remove, isFragment, VaporFragment } from './block' diff --git a/packages/runtime-vapor/src/vdomInterop.ts b/packages/runtime-vapor/src/vdomInterop.ts index 1573a306922..446eb6a8ad6 100644 --- a/packages/runtime-vapor/src/vdomInterop.ts +++ b/packages/runtime-vapor/src/vdomInterop.ts @@ -14,11 +14,14 @@ import { currentInstance, ensureRenderer, isEmitListener, + isKeepAlive, onScopeDispose, renderSlot, shallowReactive, shallowRef, simpleSetCurrentInstance, + activate as vdomActivate, + deactivate as vdomDeactivate, } from '@vue/runtime-dom' import { type LooseRawProps, @@ -30,12 +33,24 @@ import { unmountComponent, } from './component' import { type Block, VaporFragment, insert, remove } from './block' -import { EMPTY_OBJ, extend, isFunction } from '@vue/shared' +import { + EMPTY_OBJ, + ShapeFlags, + extend, + isFunction, + isReservedProp, +} from '@vue/shared' import { type RawProps, rawPropsProxyHandlers } from './componentProps' import type { RawSlots, VaporSlot } from './componentSlots' import { renderEffect } from './renderEffect' import { createTextNode } from './dom/node' import { optimizePropertyLookup } from './dom/prop' +import { + type KeepAliveInstance, + activate, + deactivate, +} from './components/KeepAlive' +import type { KeepAliveContext } from 'packages/runtime-core/src/components/KeepAlive' export const interopKey: unique symbol = Symbol(`interop`) @@ -50,7 +65,15 @@ const vaporInteropImpl: Omit< const prev = currentInstance simpleSetCurrentInstance(parentComponent) - const propsRef = shallowRef(vnode.props) + // filter out reserved props + const props: VNode['props'] = {} + for (const key in vnode.props) { + if (!isReservedProp(key)) { + props[key] = vnode.props[key] + } + } + + const propsRef = shallowRef(props) const slotsRef = shallowRef(vnode.children) const dynamicPropSource: (() => any)[] & { [interopKey]?: boolean } = [ @@ -70,6 +93,8 @@ const vaporInteropImpl: Omit< )) instance.rawPropsRef = propsRef instance.rawSlotsRef = slotsRef + // copy the shape flag from the vdom component if inside a keep-alive + if (isKeepAlive(parentComponent)) instance.shapeFlag = vnode.shapeFlag mountComponent(instance, container, selfAnchor) simpleSetCurrentInstance(prev) return instance @@ -123,6 +148,23 @@ const vaporInteropImpl: Omit< insert(vnode.vb || (vnode.component as any), container, anchor) insert(vnode.anchor as any, container, anchor) }, + + activate(vnode, container, anchor, parentComponent) { + const cached = (parentComponent.ctx as KeepAliveContext).getCachedComponent( + vnode, + ) + + vnode.el = cached.el + vnode.component = cached.component + vnode.anchor = cached.anchor + activate(vnode.component as any, container, anchor) + insert(vnode.anchor as any, container, anchor) + }, + + deactivate(vnode, container) { + deactivate(vnode.component as any, container) + insert(vnode.anchor as any, container) + }, } const vaporSlotPropsProxyHandler: ProxyHandler< @@ -190,10 +232,33 @@ function createVDOMComponent( let isMounted = false const parentInstance = currentInstance as VaporComponentInstance const unmount = (parentNode?: ParentNode) => { + if (vnode.shapeFlag & ShapeFlags.COMPONENT_SHOULD_KEEP_ALIVE) { + vdomDeactivate( + vnode, + (parentInstance as KeepAliveInstance).getStorageContainer(), + internals, + parentInstance as any, + null, + ) + return + } internals.umt(vnode.component!, null, !!parentNode) } frag.insert = (parentNode, anchor) => { + if (vnode.shapeFlag & ShapeFlags.COMPONENT_KEPT_ALIVE) { + vdomActivate( + vnode, + parentNode, + anchor, + internals, + parentInstance as any, + null, + undefined, + false, + ) + return + } if (!isMounted) { internals.mt( vnode, @@ -219,6 +284,7 @@ function createVDOMComponent( } frag.remove = unmount + frag.nodes = vnode as any return frag }