]> git.ipfire.org Git - thirdparty/vuejs/core.git/commitdiff
feat(vapor): vapor keepalive (#13186)
authoredison <daiwei521@126.com>
Mon, 20 Oct 2025 06:21:27 +0000 (14:21 +0800)
committerGitHub <noreply@github.com>
Mon, 20 Oct 2025 06:21:27 +0000 (14:21 +0800)
24 files changed:
packages-private/vapor-e2e-test/__tests__/keepalive.spec.ts [new file with mode: 0644]
packages-private/vapor-e2e-test/__tests__/vdomInterop.spec.ts
packages-private/vapor-e2e-test/index.html
packages-private/vapor-e2e-test/interop/App.vue
packages-private/vapor-e2e-test/interop/components/SimpleVaporComp.vue [new file with mode: 0644]
packages-private/vapor-e2e-test/keepalive/App.vue [new file with mode: 0644]
packages-private/vapor-e2e-test/keepalive/components/VdomComp.vue [new file with mode: 0644]
packages-private/vapor-e2e-test/keepalive/index.html [new file with mode: 0644]
packages-private/vapor-e2e-test/keepalive/main.ts [new file with mode: 0644]
packages-private/vapor-e2e-test/vite.config.ts
packages/compiler-vapor/src/generators/component.ts
packages/compiler-vapor/src/utils.ts
packages/runtime-core/src/apiCreateApp.ts
packages/runtime-core/src/component.ts
packages/runtime-core/src/components/KeepAlive.ts
packages/runtime-core/src/index.ts
packages/runtime-core/src/renderer.ts
packages/runtime-vapor/__tests__/components/KeepAlive.spec.ts [new file with mode: 0644]
packages/runtime-vapor/src/apiTemplateRef.ts
packages/runtime-vapor/src/block.ts
packages/runtime-vapor/src/component.ts
packages/runtime-vapor/src/components/KeepAlive.ts [new file with mode: 0644]
packages/runtime-vapor/src/index.ts
packages/runtime-vapor/src/vdomInterop.ts

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 (file)
index 0000000..ab0607a
--- /dev/null
@@ -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 = '8197'
+  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('<input type="text">')
+      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('<input type="text">')
+      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('<input type="text">')
+      expect(await value(inputSelector)).toBe('vdom')
+      calls = await page().evaluate(() => {
+        return (window as any).getCalls()
+      })
+      expect(calls).toStrictEqual(['mounted', 'activated'])
+    },
+    E2E_TIMEOUT,
+  )
+})
index e05f06e1abd7b999664428deafca228b479a5d03..eda60f14eefca840cbe7a219fa215e3aa0bb4334 100644 (file)
@@ -11,30 +11,36 @@ const {
   text,
   enterValue,
   html,
+  value,
   transitionStart,
   waitForElement,
   nextFrame,
   timeout,
 } = 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()
+})
+
+beforeEach(async () => {
+  const baseUrl = `http://localhost:${port}/interop/`
+  await page().goto(baseUrl)
+  await page().waitForSelector('#app')
+})
+
 const duration = process.env.CI ? 200 : 50
 const buffer = process.env.CI ? 50 : 20
 const transitionFinish = (time = duration) => timeout(time + buffer)
 
 describe('vdom / vapor interop', () => {
-  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()
-  })
-
   beforeEach(async () => {
     const baseUrl = `http://localhost:${port}/interop/`
     await page().goto(baseUrl)
@@ -98,6 +104,66 @@ 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('<input type="text">')
+        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('<input type="text">')
+        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('<input type="text">')
+        expect(await value(inputSelector)).toBe('vapor')
+        calls = await page().evaluate(() => {
+          return (window as any).getCalls()
+        })
+        expect(calls).toStrictEqual(['mounted', 'activated'])
+      },
+      E2E_TIMEOUT,
+    )
+  })
+
   describe('vdom transition', () => {
     test(
       'render vapor component',
index 09ea6aa607a4283ce54ff83a4fd91428153002ff..7ae09c3b669efcd424fda9cf2ef8e3cf714d7481 100644 (file)
@@ -1,5 +1,6 @@
 <a href="/interop/">VDOM / Vapor interop</a>
 <a href="/todomvc/">Vapor TodoMVC</a>
+<a href="/keepalive/">Vapor KeepAlive</a>
 <a href="/transition/">Vapor Transition</a>
 <a href="/transition-group/">Vapor TransitionGroup</a>
 
index 8cf42e475498f62f71155efdc4dc147117f23fa4..ae7a2b8ee6174f9e550b6432df954fa2ad1458a8 100644 (file)
@@ -1,6 +1,7 @@
 <script setup lang="ts">
 import { ref, shallowRef } from 'vue'
 import VaporComp from './VaporComp.vue'
+import SimpleVaporComp from './components/SimpleVaporComp.vue'
 import VaporCompA from '../transition/components/VaporCompA.vue'
 import VdomComp from '../transition/components/VdomComp.vue'
 import VaporSlot from '../transition/components/VaporSlot.vue'
@@ -8,6 +9,15 @@ import VaporSlot from '../transition/components/VaporSlot.vue'
 const msg = ref('hello')
 const passSlot = ref(true)
 
+;(window as any).calls = []
+;(window as any).getCalls = () => {
+  const ret = (window as any).calls.slice()
+  ;(window as any).calls = []
+  return ret
+}
+
+const show = ref(true)
+const toggle = ref(true)
 const toggleVapor = ref(true)
 const interopComponent = shallowRef(VdomComp)
 function toggleInteropComponent() {
@@ -33,6 +43,17 @@ const enterClick = () => items.value.push('d', 'e')
     <template #test v-if="passSlot">A test slot</template>
   </VaporComp>
 
+  <!-- keepalive -->
+  <div class="render-vapor-component">
+    <button class="btn-show" @click="show = !show">show</button>
+    <button class="btn-toggle" @click="toggle = !toggle">toggle</button>
+    <div>
+      <KeepAlive v-if="show">
+        <SimpleVaporComp v-if="toggle" />
+      </KeepAlive>
+    </div>
+  </div>
+  <!-- keepalive end -->
   <!-- transition interop -->
   <div>
     <div class="trans-vapor">
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 (file)
index 0000000..7f8dc48
--- /dev/null
@@ -0,0 +1,20 @@
+<script vapor>
+import { onActivated, onDeactivated, onMounted, onUnmounted, ref } from 'vue'
+const msg = ref('vapor')
+
+onMounted(() => {
+  window.calls.push('mounted')
+})
+onActivated(() => {
+  window.calls.push('activated')
+})
+onDeactivated(() => {
+  window.calls.push('deactivated')
+})
+onUnmounted(() => {
+  window.calls.push('unmounted')
+})
+</script>
+<template>
+  <input type="text" v-model="msg" />
+</template>
diff --git a/packages-private/vapor-e2e-test/keepalive/App.vue b/packages-private/vapor-e2e-test/keepalive/App.vue
new file mode 100644 (file)
index 0000000..c388228
--- /dev/null
@@ -0,0 +1,26 @@
+<script vapor>
+import { ref } from 'vue'
+import VdomComp from './components/VdomComp.vue'
+
+window.calls = []
+window.getCalls = () => {
+  const ret = window.calls.slice()
+  window.calls = []
+  return ret
+}
+
+const show = ref(true)
+const toggle = ref(true)
+</script>
+
+<template>
+  <div class="render-vdom-component">
+    <button class="btn-show" @click="show = !show">show</button>
+    <button class="btn-toggle" @click="toggle = !toggle">toggle</button>
+    <div>
+      <KeepAlive v-if="show">
+        <VdomComp v-if="toggle"></VdomComp>
+      </KeepAlive>
+    </div>
+  </div>
+</template>
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 (file)
index 0000000..4c539e9
--- /dev/null
@@ -0,0 +1,20 @@
+<script setup>
+import { onActivated, onDeactivated, onMounted, onUnmounted, ref } from 'vue'
+const msg = ref('vdom')
+
+onMounted(() => {
+  window.calls.push('mounted')
+})
+onActivated(() => {
+  window.calls.push('activated')
+})
+onDeactivated(() => {
+  window.calls.push('deactivated')
+})
+onUnmounted(() => {
+  window.calls.push('unmounted')
+})
+</script>
+<template>
+  <input type="text" v-model="msg" />
+</template>
diff --git a/packages-private/vapor-e2e-test/keepalive/index.html b/packages-private/vapor-e2e-test/keepalive/index.html
new file mode 100644 (file)
index 0000000..79052a0
--- /dev/null
@@ -0,0 +1,2 @@
+<script type="module" src="./main.ts"></script>
+<div id="app"></div>
diff --git a/packages-private/vapor-e2e-test/keepalive/main.ts b/packages-private/vapor-e2e-test/keepalive/main.ts
new file mode 100644 (file)
index 0000000..bc6b491
--- /dev/null
@@ -0,0 +1,4 @@
+import { createVaporApp, vaporInteropPlugin } from 'vue'
+import App from './App.vue'
+
+createVaporApp(App).use(vaporInteropPlugin).mount('#app')
index f50fccea3cee448746785d46ced5fafcaa12925d..931794ca84110b630f50867541e951d580605b12 100644 (file)
@@ -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'),
         transition: resolve(import.meta.dirname, 'transition/index.html'),
         transitionGroup: resolve(
           import.meta.dirname,
index aa2f6844c1391918c04dbfab18a030d4dbd5f863..41d04c83d5dbd541d4590f6eb8c4779a12d2e437 100644 (file)
@@ -39,7 +39,6 @@ import { genEventHandler } from './event'
 import { genDirectiveModifiers, genDirectivesForElement } from './directive'
 import { genBlock } from './block'
 import { genModelHandler } from './vModel'
-
 import { isBuiltInComponent } from '../utils'
 
 export function genCreateComponent(
index d2c7eca3bb1501bd2a1048fccd5e54655de18251..3ddd7ece58b36bf3c0c9f35416a0b81bde272e20 100644 (file)
@@ -115,8 +115,15 @@ export function isTransitionGroupTag(tag: string): boolean {
   return tag === 'transitiongroup' || tag === 'vaportransitiongroup'
 }
 
+export function isKeepAliveTag(tag: string): boolean {
+  tag = tag.toLowerCase()
+  return tag === 'keepalive' || tag === 'vaporkeepalive'
+}
+
 export function isBuiltInComponent(tag: string): string | undefined {
-  if (isTransitionTag(tag)) {
+  if (isKeepAliveTag(tag)) {
+    return 'VaporKeepAlive'
+  } else if (isTransitionTag(tag)) {
     return 'VaporTransition'
   } else if (isTransitionGroupTag(tag)) {
     return 'VaporTransitionGroup'
index 4c18a11f493f2a726bb1a25f4420bafa36243edd..a6b8fcbe8b56d06f4be81a44058f85d4c1e1023f 100644 (file)
@@ -187,6 +187,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
   setTransitionHooks(
     component: ComponentInternalInstance,
     transition: TransitionHooks,
index 67d5a1b8720b9420bc0a5a865deaff3ac4ebfdb9..78314be69d12b3d91a6693637e43797b4e36ab1a 100644 (file)
@@ -197,6 +197,10 @@ export interface ComponentInternalOptions {
    * indicates vapor component
    */
   __vapor?: boolean
+  /**
+   * indicates keep-alive component
+   */
+  __isKeepAlive?: boolean
   /**
    * @internal
    */
index f4244f360e34a6e669d1d50120e07eb1f6a7355e..5cad49b96786a4ca53845e562748a7447408d598 100644 (file)
@@ -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
+  }
+}
index aec569123252fc6ee75f1907ac581984f66bfc79..dd63143077b64f29f50a763d5ccb728ad82931b5 100644 (file)
@@ -510,7 +510,7 @@ export { type VaporInteropInterface } from './apiCreateApp'
 /**
  * @internal
  */
-export { type RendererInternals, MoveType } from './renderer'
+export { type RendererInternals, MoveType, invalidateMount } from './renderer'
 /**
  * @internal
  */
@@ -563,6 +563,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
  */
index 7a4e16f8781ca70c8cecb74a05f7137a76858e67..bd13cb2a40fb86d04de0f0ff749124d05651ef2e 100644 (file)
@@ -1181,12 +1181,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,
@@ -2264,7 +2273,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
     }
 
@@ -2694,7 +2710,7 @@ export function traverseStaticChildren(
 }
 
 function locateNonHydratedAsyncRoot(
-  instance: ComponentInternalInstance,
+  instance: GenericComponentInstance,
 ): ComponentInternalInstance | undefined {
   const subComponent = instance.subTree && instance.subTree.component
   if (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 (file)
index 0000000..754d83a
--- /dev/null
@@ -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<string, VaporComponent>
+  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(`<div> </div>`)() 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(`<div> </div>`)() 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(`<div> </div>`)() 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<any>(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(`<div>one</div><!--dynamic-component-->`)
+
+    instanceRef.value.setMsg('changed')
+    await nextTick()
+    expect(root.innerHTML).toBe(`<div>changed</div><!--dynamic-component-->`)
+
+    viewRef.value = 'two'
+    await nextTick()
+    expect(root.innerHTML).toBe(`<div>two</div><!--dynamic-component-->`)
+
+    viewRef.value = 'one'
+    await nextTick()
+    expect(root.innerHTML).toBe(`<div>changed</div><!--dynamic-component-->`)
+  })
+
+  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(
+      `<div>one</div><!--dynamic-component--><!--if-->`,
+    )
+    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(
+      `<div>two</div><!--dynamic-component--><!--if-->`,
+    )
+    assertHookCalls(oneHooks, [1, 1, 1, 1, 0])
+    assertHookCalls(twoHooks, [1, 1, 1, 0, 0])
+
+    viewRef.value = 'one'
+    await nextTick()
+    expect(root.innerHTML).toBe(
+      `<div>one</div><!--dynamic-component--><!--if-->`,
+    )
+    assertHookCalls(oneHooks, [1, 1, 2, 1, 0])
+    assertHookCalls(twoHooks, [1, 1, 1, 1, 0])
+
+    viewRef.value = 'two'
+    await nextTick()
+    expect(root.innerHTML).toBe(
+      `<div>two</div><!--dynamic-component--><!--if-->`,
+    )
+    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(`<!--if-->`)
+    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(
+      `<div>one</div><!--dynamic-component--><!--if-->`,
+    )
+    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(`<!--if-->`)
+    assertHookCalls(oneHooks, [1, 1, 1, 1, 1])
+    assertHookCalls(twoHooks, [0, 0, 0, 0, 0])
+
+    toggle.value = true
+    await nextTick()
+    expect(root.innerHTML).toBe(
+      `<div>one</div><!--dynamic-component--><!--if-->`,
+    )
+    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(
+      `<div>two</div><!--dynamic-component--><!--if-->`,
+    )
+    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(`<!--if-->`)
+    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(`<div>two</div><!--if-->`)
+    assertHookCalls(oneHooks, [1, 1, 1, 0, 0])
+    assertHookCalls(twoHooks, [1, 1, 1, 0, 0])
+
+    toggle.value = false
+    await nextTick()
+    expect(html()).toBe(`<!--if-->`)
+    assertHookCalls(oneHooks, [1, 1, 1, 1, 0])
+    assertHookCalls(twoHooks, [1, 1, 1, 1, 0])
+
+    toggle.value = true
+    await nextTick()
+    expect(html()).toBe(`<div>two</div><!--if-->`)
+    assertHookCalls(oneHooks, [1, 1, 2, 1, 0])
+    assertHookCalls(twoHooks, [1, 1, 2, 1, 0])
+
+    toggle.value = false
+    await nextTick()
+    expect(html()).toBe(`<!--if-->`)
+    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(`<div>two</div>`)()
+      },
+    })
+    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(`<div>two</div><!--if-->`)
+    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(`<div>two</div><!--if--><!--if-->`)
+    assertHookCalls(oneHooks, [1, 1, 1, 0, 0])
+    assertHookCalls(twoHooks, [1, 1, 1, 0, 0])
+
+    toggle1.value = false
+    await nextTick()
+    expect(html()).toBe(`<!--if-->`)
+    assertHookCalls(oneHooks, [1, 1, 1, 1, 0])
+    assertHookCalls(twoHooks, [1, 1, 1, 1, 0])
+
+    toggle1.value = true
+    await nextTick()
+    expect(html()).toBe(`<div>two</div><!--if--><!--if-->`)
+    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(`<!--if--><!--if-->`)
+    assertHookCalls(oneHooks, [1, 1, 2, 1, 0])
+    assertHookCalls(twoHooks, [1, 1, 2, 2, 0])
+
+    toggle2.value = true
+    await nextTick()
+    expect(html()).toBe(`<div>two</div><!--if--><!--if-->`)
+    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(`<!--if-->`)
+    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(`<!--if-->`)
+    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(`<!--if-->`)
+    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(`<div>two</div><!--if--><!--if-->`)
+    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(`<!--if-->`)
+    assertHookCalls(oneHooks, [1, 1, 3, 3, 0])
+    assertHookCalls(twoHooks, [1, 1, 4, 4, 0])
+
+    toggle1.value = true
+    await nextTick()
+    expect(html()).toBe(`<!--if--><!--if-->`)
+    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(`<div>one</div><!--dynamic-component--><!--if-->`)
+    assertHookCalls(oneHooks, [1, 1, 1, 0, 0])
+    assertHookCalls(twoHooks, [0, 0, 0, 0, 0])
+
+    viewRef.value = 'two'
+    await nextTick()
+    expect(html()).toBe(`<div>two</div><!--dynamic-component--><!--if-->`)
+    assertHookCalls(oneHooks, [1, 1, 1, 1, 0])
+    assertHookCalls(twoHooks, [1, 1, 0, 0, 0])
+
+    viewRef.value = 'one'
+    await nextTick()
+    expect(html()).toBe(`<div>one</div><!--dynamic-component--><!--if-->`)
+    assertHookCalls(oneHooks, [1, 1, 2, 1, 0])
+    assertHookCalls(twoHooks, [1, 1, 0, 0, 1])
+
+    viewRef.value = 'two'
+    await nextTick()
+    expect(html()).toBe(`<div>two</div><!--dynamic-component--><!--if-->`)
+    assertHookCalls(oneHooks, [1, 1, 2, 2, 0])
+    assertHookCalls(twoHooks, [2, 2, 0, 0, 1])
+
+    // teardown
+    outerRef.value = false
+    await nextTick()
+    expect(html()).toBe(`<!--if-->`)
+    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(`<div>one</div><!--dynamic-component--><!--if-->`)
+    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(`<div>oneTest</div><!--dynamic-component--><!--if-->`)
+    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(`<div>two</div><!--dynamic-component--><!--if-->`)
+    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(`<div>one</div><!--dynamic-component--><!--if-->`)
+    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(`<div>oneTest</div><!--dynamic-component--><!--if-->`)
+    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(`<div>two</div><!--dynamic-component--><!--if-->`)
+    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(`<!--if-->`)
+    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(`<div>one</div><!--dynamic-component--><!--if-->`)
+    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(`<div>oneTest</div><!--dynamic-component--><!--if-->`)
+    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(`<div>two</div><!--dynamic-component--><!--if-->`)
+    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(`<div>one</div><!--dynamic-component--><!--if-->`)
+    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(`<div>oneTest</div><!--dynamic-component--><!--if-->`)
+    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(`<div>two</div><!--dynamic-component--><!--if-->`)
+    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(`<!--if-->`)
+    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<string, VaporComponent> = {
+        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(`<div> </div>`)() 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(`<div>0</div><!--if-->`)
+
+      toggle.value = false
+      await nextTick()
+      expect(html()).toBe(`<!--if-->`)
+
+      n.value++
+      await nextTick()
+      toggle.value = true
+      await nextTick()
+      expect(html()).toBe(`<div>1</div><!--if-->`)
+    })
+  })
+
+  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(`<span></span`)()
+      },
+    })
+
+    const { app } = define({
+      setup() {
+        return createComponent(VaporKeepAlive, null, {
+          default: () => 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(`<div>A</div>`)()
+      },
+    })
+
+    const B = defineVaporComponent({
+      name: 'B',
+      setup() {
+        onMounted(mountedB)
+        onUnmounted(unmountedB)
+        return template(`<div>B</div>`)()
+      },
+    })
+
+    const include = reactive<string[]>([])
+    const current = shallowRef(A)
+    const { html } = define({
+      setup() {
+        return createComponent(
+          VaporKeepAlive,
+          { include: () => include },
+          {
+            default: () => createDynamicComponent(() => current.value),
+          },
+        )
+      },
+    }).render()
+
+    expect(html()).toBe(`<div>A</div><!--dynamic-component-->`)
+    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(`<div>B</div><!--dynamic-component-->`)
+    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(`<h1>About</h1>`)()
+      },
+    })
+    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(`<h1>Home</h1>`)()
+      },
+    })
+
+    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)
+  })
+})
index a14f373e7def428d3dc890c5ff61ddcfeb930077..2c7b275c194277a628e67c1a13df231b43171a08 100644 (file)
@@ -52,7 +52,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
 
index a45e98ca6a323eb2e5f1ae0e29f1e5d7af66059f..2574287b72c15e94aec860e743486377f100d682 100644 (file)
@@ -1,6 +1,7 @@
 import { isArray } from '@vue/shared'
 import {
   type VaporComponentInstance,
+  currentInstance,
   isVaporComponent,
   mountComponent,
   unmountComponent,
@@ -12,6 +13,8 @@ import {
   type TransitionHooks,
   type TransitionProps,
   type TransitionState,
+  type VNode,
+  isKeepAlive,
   performTransitionEnter,
   performTransitionLeave,
 } from '@vue/runtime-dom'
@@ -19,6 +22,7 @@ import {
   applyTransitionHooks,
   applyTransitionLeaveHooks,
 } from './components/Transition'
+import type { KeepAliveInstance } from './components/KeepAlive'
 
 export interface TransitionOptions {
   $key?: any
@@ -47,6 +51,7 @@ export class VaporFragment implements TransitionOptions {
   $key?: any
   $transition?: VaporTransitionHooks | undefined
   nodes: Block
+  vnode?: VNode | null = null
   anchor?: Node
   insert?: (
     parent: ParentNode,
@@ -85,6 +90,9 @@ export class DynamicFragment extends VaporFragment {
       if (render) {
         this.scope = new EffectScope()
         this.nodes = this.scope.run(render) || []
+        if (isKeepAlive(instance)) {
+          ;(instance as KeepAliveInstance).process(this.nodes)
+        }
         if (transition) {
           this.$transition = applyTransitionHooks(this.nodes, transition)
         }
@@ -94,10 +102,14 @@ export class DynamicFragment extends VaporFragment {
         this.nodes = []
       }
     }
-
+    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()
+      }
       const mode = transition && transition.mode
       if (mode) {
         applyTransitionLeaveHooks(this.nodes, transition, renderBranch)
@@ -177,7 +189,7 @@ export function insert(
       }
     }
   } else if (isVaporComponent(block)) {
-    if (block.isMounted) {
+    if (block.isMounted && !block.isDeactivated) {
       insert(block.block!, parent, anchor)
     } else {
       mountComponent(block, parent, anchor)
index 4f8a2f5b2d7f447f383b84f3d1f4490184608cd1..755554e996c07352dd2689c515b3799ca55d03bb 100644 (file)
@@ -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,
@@ -59,6 +66,7 @@ import {
 import { hmrReload, hmrRerender } from './hmr'
 import { createElement } from './dom/node'
 import { isHydrating, locateHydrationNode } from './dom/hydration'
+import type { KeepAliveInstance } from './components/KeepAlive'
 import {
   insertionAnchor,
   insertionParent,
@@ -168,6 +176,19 @@ export function createComponent(
     }
   }
 
+  // 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(
@@ -401,6 +422,7 @@ export class VaporComponentInstance implements GenericComponentInstance {
   propsOptions?: NormalizedPropsOptions
   emitsOptions?: ObjectEmitsOptions | null
   isSingleRoot?: boolean
+  shapeFlag?: number
 
   constructor(
     comp: VaporComponent,
@@ -536,12 +558,23 @@ export function mountComponent(
   parent: ParentNode,
   anchor?: Node | null | 0,
 ): void {
+  if (instance.shapeFlag! & ShapeFlags.COMPONENT_KEPT_ALIVE) {
+    ;(instance.parent as KeepAliveInstance).activate(instance, parent, 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!))
+  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`)
@@ -552,6 +585,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)
@@ -563,7 +606,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 (file)
index 0000000..37c137c
--- /dev/null
@@ -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<CacheKey, VaporComponentInstance | VaporFragment>
+type Keys = Set<CacheKey>
+
+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 `<component is/>`). 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.vnode 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.vnode 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)
+  }
+}
index 54051cc1950173093d48d99e6654f54ea27f7ade..a6f78ca1d8504992c57ac8955962fb32dff27e43 100644 (file)
@@ -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'
index 8458198e7cc4cb3d9c1e4499e70625365185586b..edaec9c6ffb2c511feed13335746c8d5475fd8f4 100644 (file)
@@ -15,12 +15,15 @@ import {
   currentInstance,
   ensureRenderer,
   isEmitListener,
+  isKeepAlive,
   onScopeDispose,
   renderSlot,
   setTransitionHooks as setVNodeTransitionHooks,
   shallowReactive,
   shallowRef,
   simpleSetCurrentInstance,
+  activate as vdomActivate,
+  deactivate as vdomDeactivate,
 } from '@vue/runtime-dom'
 import {
   type LooseRawProps,
@@ -38,13 +41,25 @@ import {
   insert,
   remove,
 } from './block'
-import { EMPTY_OBJ, extend, isFunction, isReservedProp } 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 { setTransitionHooks as setVaporTransitionHooks } from './components/Transition'
+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`)
 
@@ -87,6 +102,10 @@ 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
+
     if (vnode.transition) {
       setVaporTransitionHooks(
         instance,
@@ -150,6 +169,23 @@ const vaporInteropImpl: Omit<
   setTransitionHooks(component, hooks) {
     setVaporTransitionHooks(component as any, hooks as VaporTransitionHooks)
   },
+
+  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<
@@ -186,10 +222,10 @@ function createVDOMComponent(
   rawSlots?: LooseRawSlots | null,
 ): VaporFragment {
   const frag = new VaporFragment([])
-  const vnode = createVNode(
+  const vnode = (frag.vnode = createVNode(
     component,
     rawProps && new Proxy(rawProps, rawPropsProxyHandlers),
-  )
+  ))
   const wrapper = new VaporComponentInstance(
     { props: component.props },
     rawProps as RawProps,
@@ -218,10 +254,34 @@ function createVDOMComponent(
   const parentInstance = currentInstance as VaporComponentInstance
   const unmount = (parentNode?: ParentNode, transition?: TransitionHooks) => {
     if (transition) setVNodeTransitionHooks(vnode, transition)
+    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, transition) => {
+    if (vnode.shapeFlag & ShapeFlags.COMPONENT_KEPT_ALIVE) {
+      vdomActivate(
+        vnode,
+        parentNode,
+        anchor,
+        internals,
+        parentInstance as any,
+        null,
+        undefined,
+        false,
+      )
+      return
+    }
+
     const prev = currentInstance
     simpleSetCurrentInstance(parentInstance)
     if (!isMounted) {