Skip to content

Commit 9667e0d

Browse files
authored
fix(suspense): avoid DOM leak with out-in transition in v-if fragment (#14762)
close #14761
1 parent c8e2d4a commit 9667e0d

2 files changed

Lines changed: 81 additions & 2 deletions

File tree

packages/runtime-core/src/components/Suspense.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -553,13 +553,16 @@ function createSuspenseBoundary(
553553
activeBranch &&
554554
pendingBranch!.transition &&
555555
pendingBranch!.transition.mode === 'out-in'
556+
let hasUpdatedAnchor = false
556557
if (delayEnter) {
557558
activeBranch!.transition!.afterLeave = () => {
558559
if (pendingId === suspense.pendingId) {
559560
move(
560561
pendingBranch!,
561562
container,
562-
anchor === initialAnchor ? next(activeBranch!) : anchor,
563+
anchor === initialAnchor && !hasUpdatedAnchor
564+
? next(activeBranch!)
565+
: anchor,
563566
MoveType.ENTER,
564567
)
565568
queuePostFlushCb(effects)
@@ -587,6 +590,7 @@ function createSuspenseBoundary(
587590
// it is necessary to get the latest anchor.
588591
if (parentNode(activeBranch.el!) === container) {
589592
anchor = next(activeBranch)
593+
hasUpdatedAnchor = true
590594
}
591595
unmount(activeBranch, parentComponent, suspense, true)
592596
// clear el reference from fallback vnode to allow GC

packages/vue/__tests__/e2e/memory-leak.spec.ts

Lines changed: 76 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { E2E_TIMEOUT, setupPuppeteer } from './e2eUtils'
22
import path from 'node:path'
33

4-
const { page, html, click } = setupPuppeteer()
4+
const { page, html, click, timeout } = setupPuppeteer()
55

66
beforeEach(async () => {
77
await page().setContent(`<div id="app"></div>`)
@@ -224,4 +224,79 @@ describe('not leaking', async () => {
224224
},
225225
E2E_TIMEOUT,
226226
)
227+
228+
// #14761
229+
test(
230+
'Transition out-in with Suspense inside template v-if should not leak DOM',
231+
async () => {
232+
await page().evaluate(async () => {
233+
const { createApp, h, ref } = (window as any).Vue
234+
const AsyncChild = {
235+
props: ['label'],
236+
async setup(props: { label: string }) {
237+
const value = await Promise.resolve(1)
238+
return () =>
239+
h(
240+
'div',
241+
{ class: 'async-child' },
242+
`Async child (label=${props.label}, value=${value})`,
243+
)
244+
},
245+
}
246+
247+
createApp({
248+
components: { AsyncChild },
249+
template: `
250+
<button id="toggleBtn" @click="toggleOn = !toggleOn">button</button>
251+
<div id="container">
252+
<template v-if="toggleOn">
253+
<h4>Path A</h4>
254+
<Transition mode="out-in">
255+
<Suspense>
256+
<AsyncChild label="A" />
257+
</Suspense>
258+
</Transition>
259+
</template>
260+
<template v-else>
261+
<h4>Path B</h4>
262+
<Transition mode="out-in">
263+
<Suspense>
264+
<AsyncChild label="B" />
265+
</Suspense>
266+
</Transition>
267+
</template>
268+
</div>
269+
`,
270+
setup() {
271+
const toggleOn = ref(true)
272+
return { toggleOn }
273+
},
274+
}).mount('#app')
275+
})
276+
277+
const assertAsyncChildren = async (label: string) => {
278+
expect(
279+
await page().$$eval('#container .async-child', children =>
280+
children.map(child => child.textContent),
281+
),
282+
).toEqual([`Async child (label=${label}, value=1)`])
283+
}
284+
285+
await timeout(1)
286+
await assertAsyncChildren('A')
287+
288+
await click('#toggleBtn')
289+
await timeout(1)
290+
await assertAsyncChildren('B')
291+
292+
await click('#toggleBtn')
293+
await timeout(1)
294+
await assertAsyncChildren('A')
295+
296+
await click('#toggleBtn')
297+
await timeout(1)
298+
await assertAsyncChildren('B')
299+
},
300+
E2E_TIMEOUT,
301+
)
227302
})

0 commit comments

Comments
 (0)