Skip to content

Commit ddc132d

Browse files
authored
fix(runtime-core): unwind dangling blocks when slot content throws (#15071)
fix #15070
1 parent 4d35eca commit ddc132d

4 files changed

Lines changed: 128 additions & 24 deletions

File tree

packages/runtime-core/src/componentRenderContext.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import type { ComponentInternalInstance } from './component'
22
import { devtoolsComponentUpdated } from './devtools'
3-
import { setBlockTracking } from './vnode'
3+
import { blockStack, closeBlock, setBlockTracking } from './vnode'
44

55
/**
66
* mark the current rendering instance for asset resolution (e.g.
@@ -89,10 +89,14 @@ export function withCtx(
8989
setBlockTracking(-1)
9090
}
9191
const prevInstance = setCurrentRenderingInstance(ctx)
92+
const prevStackSize = blockStack.length
9293
let res
9394
try {
9495
res = fn(...args)
9596
} finally {
97+
// close blocks left dangling when the slot throws mid-block
98+
// inline blocks (for example `v-if`) have no helper to unwind themselves (#15070)
99+
for (let i = blockStack.length; i > prevStackSize; i--) closeBlock()
96100
setCurrentRenderingInstance(prevInstance)
97101
if (renderFnWithContext._d) {
98102
setBlockTracking(1)

packages/runtime-core/src/helpers/renderSlot.ts

Lines changed: 34 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,8 @@ import {
99
Fragment,
1010
type VNode,
1111
type VNodeArrayChildren,
12+
blockStack,
13+
closeBlock,
1214
createBlock,
1315
createVNode,
1416
isVNode,
@@ -71,32 +73,42 @@ export function renderSlot(
7173
if (slot && (slot as ContextualRenderFn)._c) {
7274
;(slot as ContextualRenderFn)._d = false
7375
}
76+
const prevStackSize = blockStack.length
7477
openBlock()
75-
const validSlotContent = slot && ensureValidVNode(slot(props))
76-
const slotKey =
77-
props.key ||
78-
// slot content array of a dynamic conditional slot may have a branch
79-
// key attached in the `createSlots` helper, respect that
80-
(validSlotContent && (validSlotContent as any).key)
81-
const rendered = createBlock(
82-
Fragment,
83-
{
84-
key:
85-
(slotKey && !isSymbol(slotKey) ? slotKey : `_${name}`) +
86-
// #7256 force differentiate fallback content from actual content
87-
(!validSlotContent && fallback ? '_fb' : ''),
88-
},
89-
validSlotContent || (fallback ? fallback() : []),
90-
validSlotContent && (slots as RawSlots)._ === SlotFlags.STABLE
91-
? PatchFlags.STABLE_FRAGMENT
92-
: PatchFlags.BAIL,
93-
)
78+
let rendered: VNode
79+
try {
80+
const validSlotContent = slot && ensureValidVNode(slot(props))
81+
const slotKey =
82+
props.key ||
83+
// slot content array of a dynamic conditional slot may have a branch
84+
// key attached in the `createSlots` helper, respect that
85+
(validSlotContent && (validSlotContent as any).key)
86+
rendered = createBlock(
87+
Fragment,
88+
{
89+
key:
90+
(slotKey && !isSymbol(slotKey) ? slotKey : `_${name}`) +
91+
// #7256 force differentiate fallback content from actual content
92+
(!validSlotContent && fallback ? '_fb' : ''),
93+
},
94+
validSlotContent || (fallback ? fallback() : []),
95+
validSlotContent && (slots as RawSlots)._ === SlotFlags.STABLE
96+
? PatchFlags.STABLE_FRAGMENT
97+
: PatchFlags.BAIL,
98+
)
99+
} catch (err) {
100+
// close blocks left dangling when the slot throws mid-block
101+
// they would otherwise retain every vnode created afterwards (#15070)
102+
for (let i = blockStack.length; i > prevStackSize; i--) closeBlock()
103+
throw err
104+
} finally {
105+
if (slot && (slot as ContextualRenderFn)._c) {
106+
;(slot as ContextualRenderFn)._d = true
107+
}
108+
}
94109
if (!noSlotted && rendered.scopeId) {
95110
rendered.slotScopeIds = [rendered.scopeId + '-s']
96111
}
97-
if (slot && (slot as ContextualRenderFn)._c) {
98-
;(slot as ContextualRenderFn)._d = true
99-
}
100112
return rendered
101113
}
102114

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
import { createSSRApp } from 'vue'
2+
import { renderToString } from '../src/renderToString'
3+
4+
const gc = () =>
5+
new Promise<void>(resolve => {
6+
setTimeout(() => {
7+
global.gc!()
8+
resolve()
9+
})
10+
})
11+
12+
const Card = {
13+
props: { tag: { type: String, default: 'div' } },
14+
template: `<component :is="tag" class="card"><slot /></component>`,
15+
}
16+
17+
const Page = {
18+
components: { Card },
19+
props: ['payload'],
20+
template: `<main><Card>{{ payload.title }}</Card></main>`,
21+
}
22+
23+
describe.skipIf(!global.gc)('ssr: render error leak', () => {
24+
// #15070
25+
test('should GC apps created after a render error in a forwarded slot', async () => {
26+
const createApp = (payload: any) => createSSRApp(Page, { payload })
27+
28+
expect(await renderToString(createApp({ title: 'ok' }))).toContain('ok')
29+
30+
await expect(renderToString(createApp(null))).rejects.toThrow(
31+
`Cannot read properties of null`,
32+
)
33+
34+
const weakRefs: { deref(): unknown | undefined }[] = []
35+
const renderOnce = async () => {
36+
const app = createApp({ title: 'ok' })
37+
// @ts-expect-error ES2021 API
38+
weakRefs.push(new WeakRef(app))
39+
expect(await renderToString(app)).toContain('ok')
40+
}
41+
for (let i = 0; i < 20; i++) {
42+
await renderOnce()
43+
}
44+
45+
for (let i = 0; i < 5; i++) {
46+
await gc()
47+
}
48+
49+
expect(weakRefs.filter(ref => ref.deref()).length).toBe(0)
50+
})
51+
52+
test('should GC apps created after a render error in an inline block (`v-if`)', async () => {
53+
const PageIf = {
54+
props: ['payload'],
55+
template: `<component :is="'div'"><div v-if="payload.list">{{ payload.list.missing.x }}</div></component>`,
56+
}
57+
const createApp = (payload: any) => createSSRApp(PageIf, { payload })
58+
59+
expect(
60+
await renderToString(createApp({ list: { missing: { x: 'ok' } } })),
61+
).toContain('ok')
62+
63+
await expect(renderToString(createApp({ list: {} }))).rejects.toThrow(
64+
`Cannot read properties of undefined`,
65+
)
66+
67+
const weakRefs: { deref(): unknown | undefined }[] = []
68+
const renderOnce = async () => {
69+
const app = createApp({ list: { missing: { x: 'ok' } } })
70+
// @ts-expect-error ES2021 API
71+
weakRefs.push(new WeakRef(app))
72+
expect(await renderToString(app)).toContain('ok')
73+
}
74+
for (let i = 0; i < 20; i++) {
75+
await renderOnce()
76+
}
77+
78+
for (let i = 0; i < 5; i++) {
79+
await gc()
80+
}
81+
82+
expect(weakRefs.filter(ref => ref.deref()).length).toBe(0)
83+
})
84+
})

vitest.config.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,7 @@ export default defineConfig({
5858
'**/e2e/**',
5959
'**/{vue,vue-compat,runtime-dom}/**',
6060
'packages/server-renderer/__tests__/ssrWatch.spec.ts',
61+
'packages/server-renderer/__tests__/ssrRender.spec.ts',
6162
],
6263
},
6364
},
@@ -66,7 +67,10 @@ export default defineConfig({
6667
test: {
6768
name: 'unit-gc',
6869
pool: 'forks',
69-
include: ['packages/server-renderer/__tests__/ssrWatch.spec.ts'],
70+
include: [
71+
'packages/server-renderer/__tests__/ssrWatch.spec.ts',
72+
'packages/server-renderer/__tests__/ssrRender.spec.ts',
73+
],
7074
execArgv: ['--expose-gc'],
7175
},
7276
},

0 commit comments

Comments
 (0)