Sfoglia il codice sorgente

fix(keep-alive): defer keep-alive branch updates while inactive

close #12017
close https://github.com/vuejs/router/issues/626

When a component update and a KeepAlive branch deactivation happen in the same
flush, the deactivated branch can still finish its queued child update. In
nested KeepAlive / keyed dynamic component cases, this can mount the next child
twice: once in the deactivated cached branch and once in the newly active
branch.

Fix this by tracking deferred updates for inactive KeepAlive branches in the
renderer and replaying them after the branch is activated again. This keeps the
change scoped to KeepAlive branch activation state, preserves nested KeepAlive
boundaries, and ensures re-activation applies the latest pending state.
daiwei 2 settimane fa
parent
commit
145abe989c

+ 90 - 0
packages/runtime-core/__tests__/components/KeepAlive.spec.ts

@@ -341,6 +341,96 @@ describe('KeepAlive', () => {
     assertHookCalls(two, [1, 1, 4, 4, 0]) // should remain inactive
   })
 
+  test('should not mount nested dynamic component twice when parent key changes', async () => {
+    const mountedA = vi.fn()
+    const mountedB = vi.fn()
+
+    const A = defineComponent({
+      name: 'A',
+      setup() {
+        onMounted(mountedA)
+        return () => h('span', 'Comp A')
+      },
+    })
+
+    const B = defineComponent({
+      name: 'B',
+      setup() {
+        onMounted(mountedB)
+        return () => h('span', 'Comp B')
+      },
+    })
+
+    const switchRoute = () => {
+      comp.value = B
+    }
+    const comp = shallowRef(A)
+    const HomeView = defineComponent({
+      name: 'HomeView',
+      setup() {
+        return () => h('main', [h(KeepAlive, null, [h(comp.value)])])
+      },
+    })
+
+    const App = defineComponent({
+      setup() {
+        return () =>
+          h(KeepAlive, null, [
+            h(HomeView, {
+              key: (comp.value as ComponentOptions).name,
+            }),
+          ])
+      },
+    })
+
+    render(h(App), root)
+    expect(serializeInner(root)).toBe(`<main><span>Comp A</span></main>`)
+    expect(mountedA).toHaveBeenCalledTimes(1)
+    expect(mountedB).toHaveBeenCalledTimes(0)
+
+    switchRoute()
+    await nextTick()
+
+    expect(serializeInner(root)).toBe(`<main><span>Comp B</span></main>`)
+    expect(mountedA).toHaveBeenCalledTimes(1)
+    expect(mountedB).toHaveBeenCalledTimes(1)
+  })
+
+  test('should apply the latest deferred update when re-activating a branch', async () => {
+    const visible = ref(true)
+    const value = ref('A')
+
+    const Home = defineComponent({
+      name: 'Home',
+      setup() {
+        return () => h('main', value.value)
+      },
+    })
+
+    const App = defineComponent({
+      setup() {
+        return () => h(KeepAlive, null, [visible.value ? h(Home) : null])
+      },
+    })
+
+    render(h(App), root)
+    expect(serializeInner(root)).toBe(`<main>A</main>`)
+
+    visible.value = false
+    await nextTick()
+    expect(serializeInner(root)).toBe(`<!---->`)
+
+    value.value = 'B'
+    await nextTick()
+    value.value = 'C'
+    await nextTick()
+    expect(serializeInner(root)).toBe(`<!---->`)
+
+    visible.value = true
+    await nextTick()
+    expect(serializeInner(root)).toBe(`<main>C</main>`)
+  })
+
   async function assertNameMatch(props: KeepAliveProps) {
     const outerRef = ref(true)
     const viewRef = ref('one')

+ 15 - 0
packages/runtime-core/src/components/KeepAlive.ts

@@ -41,7 +41,9 @@ import {
   type RendererNode,
   invalidateMount,
   queuePostRenderEffect,
+  setKeepAliveBranchActive,
 } from '../renderer'
+import { queuePostFlushCb } from '../scheduler'
 import { setTransitionHooks } from './BaseTransition'
 import type { ComponentRenderContext } from '../componentPublicInstance'
 import { devtoolsComponentAdded } from '../devtools'
@@ -136,6 +138,7 @@ const KeepAliveImpl: ComponentOptions = {
       optimized,
     ) => {
       const instance = vnode.component!
+      const updates = setKeepAliveBranchActive(instance, true)
       move(vnode, container, anchor, MoveType.ENTER, parentSuspense)
       // in case props have changed
       patch(
@@ -149,6 +152,17 @@ const KeepAliveImpl: ComponentOptions = {
         vnode.slotScopeIds,
         optimized,
       )
+      if (updates) {
+        // Replay deferred child updates after the branch is active again.
+        queuePostFlushCb(() => {
+          for (const pending of updates) {
+            if (!pending.isUnmounted) {
+              pending.update()
+            }
+          }
+          updates.clear()
+        })
+      }
       queuePostRenderEffect(() => {
         instance.isDeactivated = false
         if (instance.a) {
@@ -168,6 +182,7 @@ const KeepAliveImpl: ComponentOptions = {
 
     sharedContext.deactivate = (vnode: VNode) => {
       const instance = vnode.component!
+      setKeepAliveBranchActive(instance, false)
       invalidateMount(instance.m)
       invalidateMount(instance.a)
 

+ 45 - 0
packages/runtime-core/src/renderer.ts

@@ -110,6 +110,12 @@ export type RootRenderFunction<HostElement = RendererElement> = (
   namespace?: ElementNamespace,
 ) => void
 
+// Tracks component updates that are deferred while a KeepAlive branch is inactive.
+const deferredKeepAliveBranchUpdates = new WeakMap<
+  ComponentInternalInstance,
+  Set<ComponentInternalInstance>
+>()
+
 export interface RendererOptions<
   HostNode = RendererNode,
   HostElement = RendererElement,
@@ -1465,6 +1471,10 @@ function baseCreateRenderer(
       } else {
         let { next, bu, u, parent, vnode } = instance
 
+        if (deferKeepAliveBranchUpdate(instance)) {
+          return
+        }
+
         if (__FEATURE_SUSPENSE__) {
           const nonHydratedAsyncRoot = locateNonHydratedAsyncRoot(instance)
           // we are trying to update some async comp before hydration
@@ -2596,6 +2606,41 @@ function locateNonHydratedAsyncRoot(
   }
 }
 
+export function deferKeepAliveBranchUpdate(
+  instance: ComponentInternalInstance,
+): boolean {
+  let current: ComponentInternalInstance | null = instance
+  while (current) {
+    const updates = deferredKeepAliveBranchUpdates.get(current)
+    if (updates) {
+      updates.add(instance)
+      return true
+    }
+    // Nested KeepAlive roots manage their own inactive branches.
+    if (isKeepAlive(current.vnode)) {
+      break
+    }
+    current = current.parent
+  }
+  return false
+}
+
+export function setKeepAliveBranchActive(
+  instance: ComponentInternalInstance,
+  active: boolean,
+): Set<ComponentInternalInstance> | undefined {
+  if (active) {
+    const updates = deferredKeepAliveBranchUpdates.get(instance)
+    deferredKeepAliveBranchUpdates.delete(instance)
+    return updates
+  }
+
+  // Child updates will be collected under this inactive KeepAlive root.
+  if (!deferredKeepAliveBranchUpdates.has(instance)) {
+    deferredKeepAliveBranchUpdates.set(instance, new Set())
+  }
+}
+
 export function invalidateMount(hooks: LifecycleHook): void {
   if (hooks) {
     for (let i = 0; i < hooks.length; i++)