fix(compiler-core): avoid cached text vnodes retaining detached DOM nodes (#13662)

close #13661
This commit is contained in:
edison 2025-07-23 08:36:15 +08:00 committed by GitHub
parent da1f8d7987
commit 00695a5b41
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 102 additions and 2 deletions

View File

@ -60,7 +60,7 @@ return function render(_ctx, _cache) {
return (_openBlock(), _createElementBlock("div", null, _cache[0] || (_cache[0] = [
_createElementVNode("span", null, null, -1 /* CACHED */),
_createTextVNode("foo"),
_createTextVNode("foo", -1 /* CACHED */),
_createElementVNode("div", null, null, -1 /* CACHED */)
])))
}

View File

@ -24,7 +24,13 @@ import {
getVNodeHelper,
} from '../ast'
import type { TransformContext } from '../transform'
import { PatchFlags, isArray, isString, isSymbol } from '@vue/shared'
import {
PatchFlagNames,
PatchFlags,
isArray,
isString,
isSymbol,
} from '@vue/shared'
import { findDir, isSlotOutlet } from '../utils'
import {
GUARD_REACTIVE_PROPS,
@ -109,6 +115,15 @@ function walk(
? ConstantTypes.NOT_CONSTANT
: getConstantType(child, context)
if (constantType >= ConstantTypes.CAN_CACHE) {
if (
child.codegenNode.type === NodeTypes.JS_CALL_EXPRESSION &&
child.codegenNode.arguments.length > 0
) {
child.codegenNode.arguments.push(
PatchFlags.CACHED +
(__DEV__ ? ` /* ${PatchFlagNames[PatchFlags.CACHED]} */` : ``),
)
}
toCache.push(child)
continue
}

View File

@ -0,0 +1,85 @@
import { E2E_TIMEOUT, setupPuppeteer } from './e2eUtils'
import path from 'node:path'
const { page, html, click } = setupPuppeteer()
beforeEach(async () => {
await page().setContent(`<div id="app"></div>`)
await page().addScriptTag({
path: path.resolve(__dirname, '../../dist/vue.global.js'),
})
})
describe('not leaking', async () => {
// #13661
test(
'cached text vnodes should not retaining detached DOM nodes',
async () => {
const client = await page().createCDPSession()
await page().evaluate(async () => {
const { createApp, ref } = (window as any).Vue
createApp({
components: {
Comp1: {
template: `
<h1><slot></slot></h1>
<div>{{ test.length }}</div>
`,
setup() {
const test = ref([...Array(3000)].map((_, i) => ({ i })))
// @ts-expect-error
window.__REF__ = new WeakRef(test)
return { test }
},
},
Comp2: {
template: `<h2>comp2</h2>`,
},
},
template: `
<button id="toggleBtn" @click="click">button</button>
<Comp1 v-if="toggle">
<div>
<Comp2/>
text node
</div>
</Comp1>
`,
setup() {
const toggle = ref(true)
const click = () => (toggle.value = !toggle.value)
return { toggle, click }
},
}).mount('#app')
})
expect(await html('#app')).toBe(
`<button id="toggleBtn">button</button>` +
`<h1>` +
`<div>` +
`<h2>comp2</h2>` +
` text node ` +
`</div>` +
`</h1>` +
`<div>3000</div>`,
)
await click('#toggleBtn')
expect(await html('#app')).toBe(
`<button id="toggleBtn">button</button><!--v-if-->`,
)
const isCollected = async () =>
// @ts-expect-error
await page().evaluate(() => window.__REF__.deref() === undefined)
while ((await isCollected()) === false) {
await client.send('HeapProfiler.collectGarbage')
}
expect(await isCollected()).toBe(true)
},
E2E_TIMEOUT,
)
})