]> git.ipfire.org Git - thirdparty/vuejs/core.git/commitdiff
fix(runtime-core): unwind dangling blocks when slot content throws (#15071)
authorMatej Černý <dev@matejcerny.dev>
Thu, 16 Jul 2026 00:18:56 +0000 (02:18 +0200)
committerGitHub <noreply@github.com>
Thu, 16 Jul 2026 00:18:56 +0000 (08:18 +0800)
fix #15070

packages/runtime-core/src/componentRenderContext.ts
packages/runtime-core/src/helpers/renderSlot.ts
packages/server-renderer/__tests__/ssrRender.spec.ts [new file with mode: 0644]
vitest.config.ts

index b1fc70c03b52b6c2ca934ac64aaaf35ebd0d0143..d10eba6480ea3a9ddf1e2bba7331ada28c7d358e 100644 (file)
@@ -1,6 +1,6 @@
 import type { ComponentInternalInstance } from './component'
 import { devtoolsComponentUpdated } from './devtools'
-import { setBlockTracking } from './vnode'
+import { blockStack, closeBlock, setBlockTracking } from './vnode'
 
 /**
  * mark the current rendering instance for asset resolution (e.g.
@@ -89,10 +89,14 @@ export function withCtx(
       setBlockTracking(-1)
     }
     const prevInstance = setCurrentRenderingInstance(ctx)
+    const prevStackSize = blockStack.length
     let res
     try {
       res = fn(...args)
     } finally {
+      // close blocks left dangling when the slot throws mid-block
+      // inline blocks (for example `v-if`) have no helper to unwind themselves (#15070)
+      for (let i = blockStack.length; i > prevStackSize; i--) closeBlock()
       setCurrentRenderingInstance(prevInstance)
       if (renderFnWithContext._d) {
         setBlockTracking(1)
index 2f1296bb13e9310392a68e627e0f37d6715c43e4..8f65f8af40f0caa9554d96e261e3719b3a5491fd 100644 (file)
@@ -9,6 +9,8 @@ import {
   Fragment,
   type VNode,
   type VNodeArrayChildren,
+  blockStack,
+  closeBlock,
   createBlock,
   createVNode,
   isVNode,
@@ -71,32 +73,42 @@ export function renderSlot(
   if (slot && (slot as ContextualRenderFn)._c) {
     ;(slot as ContextualRenderFn)._d = false
   }
+  const prevStackSize = blockStack.length
   openBlock()
-  const validSlotContent = slot && ensureValidVNode(slot(props))
-  const slotKey =
-    props.key ||
-    // slot content array of a dynamic conditional slot may have a branch
-    // key attached in the `createSlots` helper, respect that
-    (validSlotContent && (validSlotContent as any).key)
-  const rendered = createBlock(
-    Fragment,
-    {
-      key:
-        (slotKey && !isSymbol(slotKey) ? slotKey : `_${name}`) +
-        // #7256 force differentiate fallback content from actual content
-        (!validSlotContent && fallback ? '_fb' : ''),
-    },
-    validSlotContent || (fallback ? fallback() : []),
-    validSlotContent && (slots as RawSlots)._ === SlotFlags.STABLE
-      ? PatchFlags.STABLE_FRAGMENT
-      : PatchFlags.BAIL,
-  )
+  let rendered: VNode
+  try {
+    const validSlotContent = slot && ensureValidVNode(slot(props))
+    const slotKey =
+      props.key ||
+      // slot content array of a dynamic conditional slot may have a branch
+      // key attached in the `createSlots` helper, respect that
+      (validSlotContent && (validSlotContent as any).key)
+    rendered = createBlock(
+      Fragment,
+      {
+        key:
+          (slotKey && !isSymbol(slotKey) ? slotKey : `_${name}`) +
+          // #7256 force differentiate fallback content from actual content
+          (!validSlotContent && fallback ? '_fb' : ''),
+      },
+      validSlotContent || (fallback ? fallback() : []),
+      validSlotContent && (slots as RawSlots)._ === SlotFlags.STABLE
+        ? PatchFlags.STABLE_FRAGMENT
+        : PatchFlags.BAIL,
+    )
+  } catch (err) {
+    // close blocks left dangling when the slot throws mid-block
+    // they would otherwise retain every vnode created afterwards (#15070)
+    for (let i = blockStack.length; i > prevStackSize; i--) closeBlock()
+    throw err
+  } finally {
+    if (slot && (slot as ContextualRenderFn)._c) {
+      ;(slot as ContextualRenderFn)._d = true
+    }
+  }
   if (!noSlotted && rendered.scopeId) {
     rendered.slotScopeIds = [rendered.scopeId + '-s']
   }
-  if (slot && (slot as ContextualRenderFn)._c) {
-    ;(slot as ContextualRenderFn)._d = true
-  }
   return rendered
 }
 
diff --git a/packages/server-renderer/__tests__/ssrRender.spec.ts b/packages/server-renderer/__tests__/ssrRender.spec.ts
new file mode 100644 (file)
index 0000000..2d23452
--- /dev/null
@@ -0,0 +1,84 @@
+import { createSSRApp } from 'vue'
+import { renderToString } from '../src/renderToString'
+
+const gc = () =>
+  new Promise<void>(resolve => {
+    setTimeout(() => {
+      global.gc!()
+      resolve()
+    })
+  })
+
+const Card = {
+  props: { tag: { type: String, default: 'div' } },
+  template: `<component :is="tag" class="card"><slot /></component>`,
+}
+
+const Page = {
+  components: { Card },
+  props: ['payload'],
+  template: `<main><Card>{{ payload.title }}</Card></main>`,
+}
+
+describe.skipIf(!global.gc)('ssr: render error leak', () => {
+  // #15070
+  test('should GC apps created after a render error in a forwarded slot', async () => {
+    const createApp = (payload: any) => createSSRApp(Page, { payload })
+
+    expect(await renderToString(createApp({ title: 'ok' }))).toContain('ok')
+
+    await expect(renderToString(createApp(null))).rejects.toThrow(
+      `Cannot read properties of null`,
+    )
+
+    const weakRefs: { deref(): unknown | undefined }[] = []
+    const renderOnce = async () => {
+      const app = createApp({ title: 'ok' })
+      // @ts-expect-error ES2021 API
+      weakRefs.push(new WeakRef(app))
+      expect(await renderToString(app)).toContain('ok')
+    }
+    for (let i = 0; i < 20; i++) {
+      await renderOnce()
+    }
+
+    for (let i = 0; i < 5; i++) {
+      await gc()
+    }
+
+    expect(weakRefs.filter(ref => ref.deref()).length).toBe(0)
+  })
+
+  test('should GC apps created after a render error in an inline block (`v-if`)', async () => {
+    const PageIf = {
+      props: ['payload'],
+      template: `<component :is="'div'"><div v-if="payload.list">{{ payload.list.missing.x }}</div></component>`,
+    }
+    const createApp = (payload: any) => createSSRApp(PageIf, { payload })
+
+    expect(
+      await renderToString(createApp({ list: { missing: { x: 'ok' } } })),
+    ).toContain('ok')
+
+    await expect(renderToString(createApp({ list: {} }))).rejects.toThrow(
+      `Cannot read properties of undefined`,
+    )
+
+    const weakRefs: { deref(): unknown | undefined }[] = []
+    const renderOnce = async () => {
+      const app = createApp({ list: { missing: { x: 'ok' } } })
+      // @ts-expect-error ES2021 API
+      weakRefs.push(new WeakRef(app))
+      expect(await renderToString(app)).toContain('ok')
+    }
+    for (let i = 0; i < 20; i++) {
+      await renderOnce()
+    }
+
+    for (let i = 0; i < 5; i++) {
+      await gc()
+    }
+
+    expect(weakRefs.filter(ref => ref.deref()).length).toBe(0)
+  })
+})
index 2fbc92f9207b6ad8b62c4b9e72edc2f3d53618df..8e1f5bec8a4494eccb07e0c160251d9952d95569 100644 (file)
@@ -58,6 +58,7 @@ export default defineConfig({
             '**/e2e/**',
             '**/{vue,vue-compat,runtime-dom}/**',
             'packages/server-renderer/__tests__/ssrWatch.spec.ts',
+            'packages/server-renderer/__tests__/ssrRender.spec.ts',
           ],
         },
       },
@@ -66,7 +67,10 @@ export default defineConfig({
         test: {
           name: 'unit-gc',
           pool: 'forks',
-          include: ['packages/server-renderer/__tests__/ssrWatch.spec.ts'],
+          include: [
+            'packages/server-renderer/__tests__/ssrWatch.spec.ts',
+            'packages/server-renderer/__tests__/ssrRender.spec.ts',
+          ],
           execArgv: ['--expose-gc'],
         },
       },