]> git.ipfire.org Git - thirdparty/vuejs/core.git/commitdiff
fix(compiler-core): avoid cached text vnodes retaining detached DOM nodes (#13662)
authoredison <daiwei521@126.com>
Wed, 23 Jul 2025 00:36:15 +0000 (08:36 +0800)
committerGitHub <noreply@github.com>
Wed, 23 Jul 2025 00:36:15 +0000 (08:36 +0800)
close #13661

packages/compiler-core/__tests__/transforms/__snapshots__/cacheStatic.spec.ts.snap
packages/compiler-core/src/transforms/cacheStatic.ts
packages/vue/__tests__/e2e/memory-leak.spec.ts [new file with mode: 0644]

index b8bef22c4782925339317ed026551bb831a9ee92..91a82db5bba30b55e312652f79010b8267069801 100644 (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 */)
     ])))
   }
index 239ee689a9fd6141b91f557bd1811c8e2c3fabee..0f112e19cadc0ee526ff5893b57113d0fa392033 100644 (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
       }
diff --git a/packages/vue/__tests__/e2e/memory-leak.spec.ts b/packages/vue/__tests__/e2e/memory-leak.spec.ts
new file mode 100644 (file)
index 0000000..2412cea
--- /dev/null
@@ -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,
+  )
+})