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 @@
@@ -19,4 +30,16 @@ const passSlot = ref(true)
A test slot
+
+
+
+
+
+
+
+
+
+
+
+
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
}