From: Matej Černý Date: Thu, 16 Jul 2026 00:18:56 +0000 (+0200) Subject: fix(runtime-core): unwind dangling blocks when slot content throws (#15071) X-Git-Tag: v3.5.40~17 X-Git-Url: http://git.ipfire.org/cgi-bin/gitweb.cgi?a=commitdiff_plain;h=ddc132d7c054ecd2029ddcc95bcba770cd44f022;p=thirdparty%2Fvuejs%2Fcore.git fix(runtime-core): unwind dangling blocks when slot content throws (#15071) fix #15070 --- diff --git a/packages/runtime-core/src/componentRenderContext.ts b/packages/runtime-core/src/componentRenderContext.ts index b1fc70c03b..d10eba6480 100644 --- a/packages/runtime-core/src/componentRenderContext.ts +++ b/packages/runtime-core/src/componentRenderContext.ts @@ -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) diff --git a/packages/runtime-core/src/helpers/renderSlot.ts b/packages/runtime-core/src/helpers/renderSlot.ts index 2f1296bb13..8f65f8af40 100644 --- a/packages/runtime-core/src/helpers/renderSlot.ts +++ b/packages/runtime-core/src/helpers/renderSlot.ts @@ -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 index 0000000000..2d23452a3e --- /dev/null +++ b/packages/server-renderer/__tests__/ssrRender.spec.ts @@ -0,0 +1,84 @@ +import { createSSRApp } from 'vue' +import { renderToString } from '../src/renderToString' + +const gc = () => + new Promise(resolve => { + setTimeout(() => { + global.gc!() + resolve() + }) + }) + +const Card = { + props: { tag: { type: String, default: 'div' } }, + template: ``, +} + +const Page = { + components: { Card }, + props: ['payload'], + template: `
{{ payload.title }}
`, +} + +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: `
{{ payload.list.missing.x }}
`, + } + 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) + }) +}) diff --git a/vitest.config.ts b/vitest.config.ts index 2fbc92f920..8e1f5bec8a 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -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'], }, },