Преглед на файлове

fix(keep-alive): stabilize KeepAlive cache keys with DynamicFragment context

daiwei преди 2 месеца
родител
ревизия
f8da5e3c84

+ 47 - 0
packages/runtime-vapor/__tests__/components/KeepAlive.spec.ts

@@ -187,6 +187,53 @@ describe('VaporKeepAlive', () => {
     expect(root.innerHTML).toBe(`<div>changed</div><!--dynamic-component-->`)
   })
 
+  test('should cache same component across branches', async () => {
+    const toggle = ref(true)
+    const instanceA = ref<any>(null)
+    const instanceB = ref<any>(null)
+
+    const { html } = define({
+      setup() {
+        const setRefA = createTemplateRefSetter()
+        const setRefB = createTemplateRefSetter()
+        return createComponent(VaporKeepAlive, null, {
+          default: () =>
+            createIf(
+              () => toggle.value,
+              () => {
+                const n0 = createComponent(one)
+                setRefA(n0, instanceA)
+                return n0
+              },
+              () => {
+                const n1 = createComponent(one)
+                setRefB(n1, instanceB)
+                return n1
+              },
+            ),
+        })
+      },
+    }).render()
+
+    expect(html()).toBe(`<div>one</div><!--if-->`)
+
+    instanceA.value.setMsg('A')
+    await nextTick()
+    expect(html()).toBe(`<div>A</div><!--if-->`)
+
+    toggle.value = false
+    await nextTick()
+    expect(html()).toBe(`<div>one</div><!--if-->`)
+
+    instanceB.value.setMsg('B')
+    await nextTick()
+    expect(html()).toBe(`<div>B</div><!--if-->`)
+
+    toggle.value = true
+    await nextTick()
+    expect(html()).toBe(`<div>A</div><!--if-->`)
+  })
+
   test('should call correct lifecycle hooks', async () => {
     const toggle = ref(true)
     const viewRef = ref('one')

+ 10 - 2
packages/runtime-vapor/src/apiCreateDynamicComponent.ts

@@ -17,7 +17,10 @@ import {
 } from './insertionState'
 import { advanceHydrationNode, isHydrating } from './dom/hydration'
 import { DynamicFragment, type VaporFragment } from './fragment'
-import type { KeepAliveInstance } from './components/KeepAlive'
+import {
+  type KeepAliveInstance,
+  resolveKeepAliveKey,
+} from './components/KeepAlive'
 
 export function createDynamicComponent(
   getter: () => any,
@@ -46,14 +49,19 @@ export function createDynamicComponent(
 
       // Handles VNodes passed from VDOM components (e.g., `h(VaporComp)` from slots)
       if (appContext.vapor && isVNode(value)) {
+        let cacheKey: ReturnType<typeof resolveKeepAliveKey> | undefined
         if (isKeepAlive(currentInstance)) {
+          cacheKey = resolveKeepAliveKey(value.type, value.key)
           const frag = (
             currentInstance as KeepAliveInstance
-          ).ctx.getCachedComponent(value.type as any) as VaporFragment
+          ).ctx.getCachedComponent(value.type as any, cacheKey) as VaporFragment
           if (frag) return frag
         }
 
         const frag = appContext.vapor.vdomMountVNode(value, currentInstance)
+        if (cacheKey) {
+          frag.cacheKey = cacheKey
+        }
         if (isHydrating) {
           frag.hydrate()
           if (_isLastInsertion) {

+ 11 - 1
packages/runtime-vapor/src/component.ts

@@ -107,6 +107,7 @@ import {
 import type { KeepAliveInstance } from './components/KeepAlive'
 import {
   currentKeepAliveCtx,
+  resolveKeepAliveKey,
   setCurrentKeepAliveCtx,
 } from './components/KeepAlive'
 import {
@@ -276,14 +277,16 @@ export function createComponent(
   }
 
   // keep-alive
+  let cacheKey
   if (
     currentInstance &&
     currentInstance.vapor &&
     isKeepAlive(currentInstance)
   ) {
+    cacheKey = resolveKeepAliveKey(component)
     const cached = (
       currentInstance as KeepAliveInstance
-    ).ctx.getCachedComponent(component)
+    ).ctx.getCachedComponent(component, cacheKey)
     // @ts-expect-error
     if (cached) return cached
   }
@@ -297,6 +300,9 @@ export function createComponent(
       rawSlots,
       isSingleRoot,
     )
+    if (cacheKey) {
+      frag.cacheKey = cacheKey
+    }
     if (!isHydrating) {
       if (_insertionParent) insert(frag, _insertionParent, _insertionAnchor)
     } else {
@@ -330,6 +336,9 @@ export function createComponent(
     appContext,
     once,
   )
+  if (cacheKey) {
+    instance.cacheKey = cacheKey
+  }
 
   // handle currentKeepAliveCtx for component boundary isolation
   // AsyncWrapper should NOT clear currentKeepAliveCtx so its internal
@@ -588,6 +597,7 @@ export class VaporComponentInstance<
 
   // for keep-alive
   shapeFlag?: number
+  cacheKey?: any
 
   // for v-once: caches props/attrs values to ensure they remain frozen
   // even when the component re-renders due to local state changes

+ 107 - 13
packages/runtime-vapor/src/components/KeepAlive.ts

@@ -9,6 +9,7 @@ import {
   devtoolsComponentAdded,
   getComponentName,
   isAsyncWrapper,
+  isVNode,
   matches,
   onBeforeUnmount,
   onMounted,
@@ -26,9 +27,14 @@ import {
   isVaporComponent,
 } from '../component'
 import { defineVaporComponent } from '../apiDefineComponent'
-import { ShapeFlags, invokeArrayFns, isArray } from '@vue/shared'
+import { ShapeFlags, invokeArrayFns, isArray, isObject } from '@vue/shared'
 import { createElement } from '../dom/node'
-import { type VaporFragment, isDynamicFragment, isFragment } from '../fragment'
+import {
+  type VaporFragment,
+  currentDynamicFragment,
+  isDynamicFragment,
+  isFragment,
+} from '../fragment'
 import type { EffectScope } from '@vue/reactivity'
 
 export interface KeepAliveContext {
@@ -60,15 +66,92 @@ export interface KeepAliveInstance extends VaporComponentInstance {
     deactivate: (instance: VaporComponentInstance) => void
     getCachedComponent: (
       comp: VaporComponent,
+      key?: CacheKey,
     ) => VaporComponentInstance | VaporFragment | undefined
     getStorageContainer: () => ParentNode
   }
 }
 
-type CacheKey = VaporComponent | VNode['type']
+type CompositeKey = {
+  type: VaporComponent | VNode['type']
+  fragId: number
+  key: any
+}
+
+type CacheKey = VaporComponent | VNode['type'] | CompositeKey
 type Cache = Map<CacheKey, VaporComponentInstance | VaporFragment>
 type Keys = Set<CacheKey>
 
+const compositeKeyCache = new WeakMap<
+  object,
+  Map<number, Map<any, CompositeKey>>
+>()
+const compositeKeyCachePrimitive = new Map<
+  any,
+  Map<number, Map<any, CompositeKey>>
+>()
+
+function getOrCreate<K extends object, V>(
+  map: WeakMap<K, V>,
+  key: K,
+  init: () => V,
+): V
+function getOrCreate<K, V>(map: Map<K, V>, key: K, init: () => V): V
+function getOrCreate(
+  map: Map<any, any> | WeakMap<object, any>,
+  key: any,
+  init: () => any,
+): any {
+  let value = map.get(key)
+  if (!value) {
+    value = init()
+    map.set(key, value)
+  }
+  return value
+}
+
+function getCompositeKey(
+  type: VaporComponent | VNode['type'],
+  fragId: number,
+  key: any,
+): CompositeKey {
+  const isObjectType = isObject(type) || typeof type === 'function'
+  if (isObjectType) {
+    const perType = getOrCreate(
+      compositeKeyCache,
+      type as object,
+      () => new Map(),
+    )
+    const perFrag = getOrCreate(perType, fragId, () => new Map())
+    return getOrCreate(perFrag, key, () => ({ type, fragId, key }))
+  }
+  const perType = getOrCreate(compositeKeyCachePrimitive, type, () => new Map())
+  const perFrag = getOrCreate(perType, fragId, () => new Map())
+  return getOrCreate(perFrag, key, () => ({ type, fragId, key }))
+}
+
+export function resolveKeepAliveKey(
+  type: VaporComponent | VNode['type'],
+  key?: any,
+): CacheKey {
+  const frag = currentDynamicFragment
+  if (frag) {
+    let root = frag
+    while (root.parentDynamicFragment) root = root.parentDynamicFragment
+    let fragKey = key
+    if (fragKey === undefined) {
+      const current = root.current
+      if (isVNode(current)) {
+        fragKey = current.key || current.type
+      } else {
+        fragKey = current
+      }
+    }
+    return getCompositeKey(type, root.id, fragKey)
+  }
+  return type
+}
+
 const KeepAliveImpl: ObjectVaporComponent = defineVaporComponent({
   name: 'VaporKeepAlive',
   __isKeepAlive: true,
@@ -111,7 +194,7 @@ const KeepAliveImpl: ObjectVaporComponent = defineVaporComponent({
 
     keepAliveInstance.ctx = {
       getStorageContainer: () => storageContainer,
-      getCachedComponent: comp => cache.get(comp),
+      getCachedComponent: (comp, key) => cache.get(key || comp),
       activate: (instance, parentNode, anchor) => {
         current = instance
         activate(instance, parentNode, anchor)
@@ -162,23 +245,21 @@ const KeepAliveImpl: ObjectVaporComponent = defineVaporComponent({
       }
       const [innerBlock, interop] = getInnerBlock(block)
       if (!innerBlock || !shouldCache(innerBlock, props, interop)) return
-      innerCacheBlock(
-        interop ? innerBlock.vnode!.type : innerBlock.type,
-        innerBlock,
-      )
+      innerCacheBlock(getCacheKey(innerBlock, interop), innerBlock)
     }
 
     const processShapeFlag = (block: Block): boolean => {
       const [innerBlock, interop] = getInnerBlock(block)
       if (!innerBlock || !shouldCache(innerBlock!, props, interop)) return false
 
+      const cacheKey = getCacheKey(innerBlock, interop)
       if (interop) {
-        if (cache.has(innerBlock.vnode!.type)) {
+        if (cache.has(cacheKey)) {
           innerBlock.vnode!.shapeFlag! |= ShapeFlags.COMPONENT_KEPT_ALIVE
         }
         innerBlock.vnode!.shapeFlag! |= ShapeFlags.COMPONENT_SHOULD_KEEP_ALIVE
       } else {
-        if (cache.has(innerBlock!.type)) {
+        if (cache.has(cacheKey)) {
           innerBlock!.shapeFlag! |= ShapeFlags.COMPONENT_KEPT_ALIVE
         }
         innerBlock!.shapeFlag! |= ShapeFlags.COMPONENT_SHOULD_KEEP_ALIVE
@@ -234,9 +315,7 @@ const KeepAliveImpl: ObjectVaporComponent = defineVaporComponent({
 
         // current instance will be unmounted as part of keep-alive's unmount
         if (current) {
-          const currentKey = isVaporComponent(current)
-            ? current.type
-            : current.vnode!.type
+          const currentKey = getCacheKey(current, !isVaporComponent(current))
           if (currentKey === key) {
             // call deactivated hook
             const da = instance.da
@@ -343,6 +422,21 @@ function isInteropFragment(block: Block): block is VaporFragment {
   return !!(isFragment(block) && block.vnode)
 }
 
+function getCacheKey(
+  block: VaporComponentInstance | VaporFragment,
+  interop: boolean,
+): CacheKey {
+  if (interop) {
+    const frag = block as VaporFragment
+    if (frag.cacheKey) return frag.cacheKey
+    const { vnode } = frag
+    const key = vnode!.key
+    return resolveKeepAliveKey(vnode!.type, key)
+  }
+  const instance = block as VaporComponentInstance
+  return instance.cacheKey || instance.type
+}
+
 function getInstanceFromCache(
   cached: VaporComponentInstance | VaporFragment,
 ): GenericComponentInstance {

+ 31 - 0
packages/runtime-vapor/src/fragment.ts

@@ -43,6 +43,20 @@ import {
   setCurrentKeepAliveCtx,
 } from './components/KeepAlive'
 
+let dynamicFragmentId = 0
+
+export let currentDynamicFragment: DynamicFragment | null = null
+
+export function setCurrentDynamicFragment(
+  frag: DynamicFragment | null,
+): DynamicFragment | null {
+  try {
+    return currentDynamicFragment
+  } finally {
+    currentDynamicFragment = frag
+  }
+}
+
 export class VaporFragment<
   T extends Block = Block,
 > implements TransitionOptions {
@@ -50,6 +64,7 @@ export class VaporFragment<
   $transition?: VaporTransitionHooks | undefined
   nodes: T
   vnode?: VNode | null = null
+  cacheKey?: any
   anchor?: Node
   parentComponent?: GenericComponentInstance | null
   fallback?: BlockFn
@@ -82,6 +97,7 @@ export class ForFragment extends VaporFragment<Block[]> {
 }
 
 export class DynamicFragment extends VaporFragment {
+  id: number
   anchor!: Node
   scope: EffectScope | undefined
   current?: BlockFn
@@ -89,6 +105,7 @@ export class DynamicFragment extends VaporFragment {
   fallback?: BlockFn
   anchorLabel?: string
   keyed?: boolean
+  parentDynamicFragment: DynamicFragment | null
 
   // fallthrough attrs
   attrs?: Record<string, any>
@@ -102,9 +119,16 @@ export class DynamicFragment extends VaporFragment {
 
   constructor(anchorLabel?: string, keyed: boolean = false) {
     super([])
+    this.id = dynamicFragmentId++
     this.keyed = keyed
     this.slotOwner = currentSlotOwner
     this.keepAliveCtx = currentKeepAliveCtx
+    this.parentDynamicFragment =
+      this.keepAliveCtx &&
+      currentDynamicFragment &&
+      currentDynamicFragment.keepAliveCtx === this.keepAliveCtx
+        ? currentDynamicFragment
+        : null
     if (isHydrating) {
       this.anchorLabel = anchorLabel
       locateHydrationNode()
@@ -234,11 +258,18 @@ export class DynamicFragment extends VaporFragment {
       const prevOwner = setCurrentSlotOwner(this.slotOwner)
       // set currentKeepAliveCtx so nested DynamicFragments and components can capture it
       const prevCtx = setCurrentKeepAliveCtx(keepAliveCtx)
+      const shouldSetFrag = !!(
+        keepAliveCtx &&
+        (!currentDynamicFragment ||
+          currentDynamicFragment.keepAliveCtx !== keepAliveCtx)
+      )
+      const prevFrag = shouldSetFrag ? setCurrentDynamicFragment(this) : null
       // switch current instance to parent instance during update
       // ensure that the parent instance is correct for nested components
       const prev = parent && instance ? setCurrentInstance(instance) : undefined
       this.nodes = this.scope.run(render) || []
       if (prev !== undefined) setCurrentInstance(...prev)
+      if (shouldSetFrag) setCurrentDynamicFragment(prevFrag)
       setCurrentKeepAliveCtx(prevCtx)
       setCurrentSlotOwner(prevOwner)