Răsfoiți Sursa

fix(runtime-vapor): fix keptAliveScopes key mismatch causing scope leak on prune

daiwei 1 lună în urmă
părinte
comite
3f2603da58

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

@@ -1647,6 +1647,60 @@ describe('VaporKeepAlive', () => {
     expect(cache.size).toBe(0)
   })
 
+  test('should stop branch scope when cache entry is pruned', async () => {
+    const One = defineVaporComponent({
+      name: 'One',
+      setup() {
+        return template('<div>one</div>')()
+      },
+    })
+
+    const Two = defineVaporComponent({
+      name: 'Two',
+      setup() {
+        return template('<div>two</div>')()
+      },
+    })
+
+    const include = ref('One,Two')
+    const toggle = ref(true)
+    const { html, instance } = define({
+      setup() {
+        return createComponent(
+          VaporKeepAlive,
+          { include: () => include.value },
+          {
+            default: () =>
+              createIf(
+                () => toggle.value,
+                () => createComponent(One),
+                () => createComponent(Two),
+              ),
+          },
+        )
+      },
+    }).render()
+
+    const keepAliveInstance = instance!.block as any
+    const keptAliveScopes = keepAliveInstance.__v_keptAliveScopes as Map<
+      any,
+      any
+    >
+
+    expect(html()).toBe('<div>one</div><!--if-->')
+
+    // deactivate One → branch scope retained in keptAliveScopes
+    toggle.value = false
+    await nextTick()
+    expect(html()).toBe('<div>two</div><!--if-->')
+    expect(keptAliveScopes.size).toBe(2)
+
+    // prune One from cache → keptAliveScopes should also be cleaned up
+    include.value = 'Two'
+    await nextTick()
+    expect(keptAliveScopes.size).toBe(0)
+  })
+
   test('handle error in async onActivated', async () => {
     const err = new Error('foo')
     const handler = vi.fn()

+ 36 - 21
packages/runtime-vapor/src/components/KeepAlive.ts

@@ -43,10 +43,10 @@ import type { EffectScope } from '@vue/reactivity'
 import { isInteropEnabled } from '../vdomInteropState'
 
 export interface VaporKeepAliveContext {
-  processShapeFlag(block: Block): boolean
+  processShapeFlag(block: Block): CacheKey | false
   cacheBlock(): void
-  cacheScope(key: any, scope: EffectScope): void
-  getScope(key: any): EffectScope | undefined
+  cacheScope(cacheKey: CacheKey, branchKey: any, scope: EffectScope): void
+  getScope(branchKey: any): EffectScope | undefined
   setCurrentBranchKey(key: any): any
 }
 
@@ -78,11 +78,11 @@ export interface KeepAliveInstance extends VaporComponentInstance {
   }
 }
 
-type CacheKey = VaporComponent | VNode['type'] | any
+type CacheKey = VaporComponent | VNode['type']
 type Cache = Map<CacheKey, VaporComponentInstance | VaporFragment>
 type Keys = Set<CacheKey>
 type CompositeKey = {
-  type: VaporComponent | VNode['type']
+  type: CacheKey
   branchKey: any
 }
 
@@ -91,7 +91,7 @@ type CompositeKey = {
 // module-level Map leaks for primitive type keys (e.g. string tag names
 // from VDOM interop).
 function getCompositeKey(
-  type: VaporComponent | VNode['type'],
+  type: CacheKey,
   branchKey: any,
   compositeKeyCache: WeakMap<object, Map<any, CompositeKey>>,
   compositeKeyCachePrimitive: Map<any, Map<any, CompositeKey>>,
@@ -195,6 +195,7 @@ const VaporKeepAliveImpl = defineVaporComponent({
 
     if (__DEV__ || __FEATURE_PROD_DEVTOOLS__) {
       ;(keepAliveInstance as any).__v_cache = cache
+      ;(keepAliveInstance as any).__v_keptAliveScopes = keptAliveScopes
     }
 
     // Clear cache and shapeFlags before HMR rerender so cached components
@@ -278,7 +279,7 @@ const VaporKeepAliveImpl = defineVaporComponent({
       )
     }
 
-    const processShapeFlag = (block: Block): boolean => {
+    const processShapeFlag = (block: Block): CacheKey | false => {
       const [innerBlock, interop] = getInnerBlock(block)
       if (!innerBlock || !shouldCache(innerBlock!, props, interop)) return false
 
@@ -288,6 +289,7 @@ const VaporKeepAliveImpl = defineVaporComponent({
           innerBlock.vnode!.shapeFlag! |= ShapeFlags.COMPONENT_KEPT_ALIVE
         }
         innerBlock.vnode!.shapeFlag! |= ShapeFlags.COMPONENT_SHOULD_KEEP_ALIVE
+        return cacheKey
       } else {
         const cacheKey = getCacheKey(innerBlock, false, currentBranchKey)
         if (cache.has(cacheKey)) {
@@ -296,8 +298,8 @@ const VaporKeepAliveImpl = defineVaporComponent({
         }
         ;(innerBlock as VaporComponentInstance)!.shapeFlag! |=
           ShapeFlags.COMPONENT_SHOULD_KEEP_ALIVE
+        return cacheKey
       }
-      return true
     }
 
     const pruneCache = (filter: (name: string) => boolean) => {
@@ -315,6 +317,22 @@ const VaporKeepAliveImpl = defineVaporComponent({
       })
     }
 
+    // delete scope from keptAliveScopes by one key,
+    // also removes the paired entry (cache key ↔ branch key)
+    const deleteScope = (key: CacheKey): EffectScope | undefined => {
+      const scope = keptAliveScopes.get(key)
+      if (scope) {
+        keptAliveScopes.delete(key)
+        for (const [k, s] of keptAliveScopes) {
+          if (s === scope) {
+            keptAliveScopes.delete(k)
+            break
+          }
+        }
+      }
+      return scope
+    }
+
     const pruneCacheEntry = (key: CacheKey) => {
       const cached = cache.get(key)!
 
@@ -327,11 +345,8 @@ const VaporKeepAliveImpl = defineVaporComponent({
       }
       cache.delete(key)
       keys.delete(key)
-      const scope = keptAliveScopes.get(key)
-      if (scope) {
-        scope.stop()
-        keptAliveScopes.delete(key)
-      }
+      const scope = deleteScope(key)
+      if (scope) scope.stop()
     }
 
     // prune cache on include/exclude prop change
@@ -379,15 +394,15 @@ const VaporKeepAliveImpl = defineVaporComponent({
     const keepAliveCtx: VaporKeepAliveContext = {
       processShapeFlag,
       cacheBlock,
-      cacheScope(key, scope) {
-        keptAliveScopes.set(key, scope)
+      cacheScope(cacheKey, branchKey, scope) {
+        // store under both keys so the scope can be looked up by:
+        // - cache key: for cleanup in pruneCacheEntry
+        // - branch key: for reuse in getScope (before the cache key is known)
+        keptAliveScopes.set(cacheKey, scope)
+        keptAliveScopes.set(branchKey, scope)
       },
-      getScope(key) {
-        const scope = keptAliveScopes.get(key)
-        if (scope) {
-          keptAliveScopes.delete(key)
-          return scope
-        }
+      getScope(branchKey) {
+        return deleteScope(branchKey)
       },
       setCurrentBranchKey(key) {
         try {

+ 4 - 3
packages/runtime-vapor/src/fragment.ts

@@ -152,10 +152,11 @@ export class DynamicFragment extends VaporFragment {
       let retainScope = false
       const keepAliveCtx = this.keepAliveCtx
 
-      // if keepAliveCtx exists and processShapeFlag returns true,
+      // if keepAliveCtx exists and processShapeFlag returns a cache key,
       // cache the scope and retain it
-      if (keepAliveCtx && keepAliveCtx.processShapeFlag(this.nodes)) {
-        keepAliveCtx.cacheScope(prevKey, this.scope)
+      const cacheKey = keepAliveCtx && keepAliveCtx.processShapeFlag(this.nodes)
+      if (cacheKey) {
+        keepAliveCtx!.cacheScope(cacheKey, prevKey, this.scope)
         retainScope = true
       }