Просмотр исходного кода

fix(suspense): avoid DOM leak with out-in transition in v-if fragment (#14762)

close #14761
edison 1 месяц назад
Родитель
Сommit
9667e0d498

+ 5 - 1
packages/runtime-core/src/components/Suspense.ts

@@ -553,13 +553,16 @@ function createSuspenseBoundary(
           activeBranch &&
           pendingBranch!.transition &&
           pendingBranch!.transition.mode === 'out-in'
+        let hasUpdatedAnchor = false
         if (delayEnter) {
           activeBranch!.transition!.afterLeave = () => {
             if (pendingId === suspense.pendingId) {
               move(
                 pendingBranch!,
                 container,
-                anchor === initialAnchor ? next(activeBranch!) : anchor,
+                anchor === initialAnchor && !hasUpdatedAnchor
+                  ? next(activeBranch!)
+                  : anchor,
                 MoveType.ENTER,
               )
               queuePostFlushCb(effects)
@@ -587,6 +590,7 @@ function createSuspenseBoundary(
           // it is necessary to get the latest anchor.
           if (parentNode(activeBranch.el!) === container) {
             anchor = next(activeBranch)
+            hasUpdatedAnchor = true
           }
           unmount(activeBranch, parentComponent, suspense, true)
           // clear el reference from fallback vnode to allow GC

+ 76 - 1
packages/vue/__tests__/e2e/memory-leak.spec.ts

@@ -1,7 +1,7 @@
 import { E2E_TIMEOUT, setupPuppeteer } from './e2eUtils'
 import path from 'node:path'
 
-const { page, html, click } = setupPuppeteer()
+const { page, html, click, timeout } = setupPuppeteer()
 
 beforeEach(async () => {
   await page().setContent(`<div id="app"></div>`)
@@ -224,4 +224,79 @@ describe('not leaking', async () => {
     },
     E2E_TIMEOUT,
   )
+
+  // #14761
+  test(
+    'Transition out-in with Suspense inside template v-if should not leak DOM',
+    async () => {
+      await page().evaluate(async () => {
+        const { createApp, h, ref } = (window as any).Vue
+        const AsyncChild = {
+          props: ['label'],
+          async setup(props: { label: string }) {
+            const value = await Promise.resolve(1)
+            return () =>
+              h(
+                'div',
+                { class: 'async-child' },
+                `Async child (label=${props.label}, value=${value})`,
+              )
+          },
+        }
+
+        createApp({
+          components: { AsyncChild },
+          template: `
+            <button id="toggleBtn" @click="toggleOn = !toggleOn">button</button>
+            <div id="container">
+              <template v-if="toggleOn">
+                <h4>Path A</h4>
+                <Transition mode="out-in">
+                  <Suspense>
+                    <AsyncChild label="A" />
+                  </Suspense>
+                </Transition>
+              </template>
+              <template v-else>
+                <h4>Path B</h4>
+                <Transition mode="out-in">
+                  <Suspense>
+                    <AsyncChild label="B" />
+                  </Suspense>
+                </Transition>
+              </template>
+            </div>
+          `,
+          setup() {
+            const toggleOn = ref(true)
+            return { toggleOn }
+          },
+        }).mount('#app')
+      })
+
+      const assertAsyncChildren = async (label: string) => {
+        expect(
+          await page().$$eval('#container .async-child', children =>
+            children.map(child => child.textContent),
+          ),
+        ).toEqual([`Async child (label=${label}, value=1)`])
+      }
+
+      await timeout(1)
+      await assertAsyncChildren('A')
+
+      await click('#toggleBtn')
+      await timeout(1)
+      await assertAsyncChildren('B')
+
+      await click('#toggleBtn')
+      await timeout(1)
+      await assertAsyncChildren('A')
+
+      await click('#toggleBtn')
+      await timeout(1)
+      await assertAsyncChildren('B')
+    },
+    E2E_TIMEOUT,
+  )
 })