Skip to content

Commit 862f11e

Browse files
authored
fix(server-renderer): cleanup component effect scopes after SSR render (#14548)
1 parent be0a2f1 commit 862f11e

File tree

5 files changed

+159
-22
lines changed

5 files changed

+159
-22
lines changed

packages/server-renderer/__tests__/render.spec.ts

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,9 +10,11 @@ import {
1010
createTextVNode,
1111
createVNode,
1212
defineComponent,
13+
effectScope,
1314
getCurrentInstance,
1415
h,
1516
onErrorCaptured,
17+
onScopeDispose,
1618
onServerPrefetch,
1719
reactive,
1820
ref,
@@ -1002,6 +1004,84 @@ function testRender(type: string, render: typeof renderToString) {
10021004
expect(html).toBe(`<div>hello</div>`)
10031005
})
10041006

1007+
test('cleans up component effect scopes after each render', async () => {
1008+
const cleanups: number[] = []
1009+
const app = createApp({
1010+
setup() {
1011+
onScopeDispose(() => {
1012+
cleanups.push(1)
1013+
})
1014+
return () => h('div', 'ok')
1015+
},
1016+
})
1017+
1018+
expect(cleanups).toEqual([])
1019+
expect(await render(app)).toBe(`<div>ok</div>`)
1020+
expect(cleanups).toEqual([1])
1021+
})
1022+
1023+
test('concurrent renders isolate scope cleanup ownership', async () => {
1024+
const cleaned: string[] = []
1025+
1026+
const deferred = () => {
1027+
let resolve!: () => void
1028+
const promise = new Promise<void>(r => {
1029+
resolve = r
1030+
})
1031+
return { promise, resolve }
1032+
}
1033+
1034+
const gateA = deferred()
1035+
const gateB = deferred()
1036+
1037+
const makeApp = (id: string, gate: ReturnType<typeof deferred>) =>
1038+
createApp({
1039+
async setup() {
1040+
onScopeDispose(() => {
1041+
cleaned.push(id)
1042+
})
1043+
await gate.promise
1044+
return () => h('div', id)
1045+
},
1046+
})
1047+
1048+
const pA = render(makeApp('A', gateA))
1049+
const pB = render(makeApp('B', gateB))
1050+
1051+
gateB.resolve()
1052+
expect(await pB).toBe(`<div>B</div>`)
1053+
expect(cleaned).toEqual(['B'])
1054+
1055+
gateA.resolve()
1056+
expect(await pA).toBe(`<div>A</div>`)
1057+
expect(cleaned.sort()).toEqual(['A', 'B'])
1058+
})
1059+
1060+
test('detached scopes created during SSR are not auto-stopped', async () => {
1061+
let detachedStopped = false
1062+
let detached: any
1063+
1064+
const app = createApp({
1065+
setup() {
1066+
detached = effectScope(true)
1067+
detached.run(() => {
1068+
onScopeDispose(() => {
1069+
detachedStopped = true
1070+
})
1071+
})
1072+
return () => h('div', 'detached')
1073+
},
1074+
})
1075+
1076+
expect(await render(app)).toBe(`<div>detached</div>`)
1077+
expect(detached.active).toBe(true)
1078+
expect(detachedStopped).toBe(false)
1079+
1080+
detached.stop()
1081+
expect(detached.active).toBe(false)
1082+
expect(detachedStopped).toBe(true)
1083+
})
1084+
10051085
test('multiple onServerPrefetch', async () => {
10061086
const msg = Promise.resolve('hello')
10071087
const msg2 = Promise.resolve('hi')

packages/server-renderer/__tests__/ssrWatch.spec.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@ describe('ssr: watch', () => {
3232
const ctx: SSRContext = {}
3333
const html = await renderToString(app, ctx)
3434

35-
expect(ctx.__watcherHandles!.length).toBe(1)
35+
expect(ctx.__watcherHandles!.length).toBe(0)
3636

3737
expect(html).toMatch('hello world')
3838
})
@@ -61,7 +61,7 @@ describe('ssr: watch', () => {
6161
const ctx: SSRContext = {}
6262
const html = await renderToString(app, ctx)
6363

64-
expect(ctx.__watcherHandles!.length).toBe(1)
64+
expect(ctx.__watcherHandles!.length).toBe(0)
6565
expect(html).toMatch('changed again')
6666
await nextTick()
6767
expect(msg).toBe('changed again')
@@ -229,7 +229,7 @@ describe('ssr: watchEffect', () => {
229229
const ctx: SSRContext = {}
230230
const html = await renderToString(app, ctx)
231231

232-
expect(ctx.__watcherHandles!.length).toBe(1)
232+
expect(ctx.__watcherHandles!.length).toBe(0)
233233
expect(html).toMatch('changed again')
234234
await nextTick()
235235
expect(msg).toBe('changed again')

packages/server-renderer/src/render.ts

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import {
1111
type VNodeArrayChildren,
1212
type VNodeProps,
1313
mergeProps,
14+
ssrContextKey,
1415
ssrUtils,
1516
warn,
1617
} from 'vue'
@@ -55,6 +56,37 @@ export type SSRContext = {
5556
* @internal
5657
*/
5758
__watcherHandles?: (() => void)[]
59+
/**
60+
* @internal
61+
*/
62+
__instanceScopes?: { stop: () => void }[]
63+
}
64+
65+
export function cleanupContext(context: SSRContext): void {
66+
let firstError: unknown
67+
if (context.__watcherHandles) {
68+
for (const unwatch of context.__watcherHandles) {
69+
try {
70+
unwatch()
71+
} catch (err) {
72+
if (firstError === undefined) firstError = err
73+
}
74+
}
75+
context.__watcherHandles.length = 0
76+
}
77+
if (context.__instanceScopes) {
78+
for (const scope of context.__instanceScopes) {
79+
try {
80+
scope.stop()
81+
} catch (err) {
82+
if (firstError === undefined) firstError = err
83+
}
84+
}
85+
context.__instanceScopes.length = 0
86+
}
87+
if (firstError !== undefined) {
88+
throw firstError
89+
}
5890
}
5991

6092
// Each component has a buffer array.
@@ -98,6 +130,14 @@ export function renderComponentVNode(
98130
parentComponent,
99131
null,
100132
))
133+
const context = instance.appContext.provides[ssrContextKey as any] as
134+
| SSRContext
135+
| undefined
136+
if (context) {
137+
;(context.__instanceScopes || (context.__instanceScopes = [])).push(
138+
instance.scope,
139+
)
140+
}
101141
if (__DEV__) pushWarningContext(vnode)
102142
const res = setupComponent(instance, true /* isSSR */)
103143
if (__DEV__) popWarningContext()

packages/server-renderer/src/renderToStream.ts

Lines changed: 23 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,12 @@ import {
77
ssrUtils,
88
} from 'vue'
99
import { isPromise, isString } from '@vue/shared'
10-
import { type SSRBuffer, type SSRContext, renderComponentVNode } from './render'
10+
import {
11+
type SSRBuffer,
12+
type SSRContext,
13+
cleanupContext,
14+
renderComponentVNode,
15+
} from './render'
1116
import type { Readable, Writable } from 'node:stream'
1217
import { resolveTeleports } from './renderToString'
1318

@@ -43,7 +48,7 @@ async function unrollBuffer(
4348

4449
function unrollBufferSync(buffer: SSRBuffer, stream: SimpleReadable) {
4550
for (let i = 0; i < buffer.length; i++) {
46-
let item = buffer[i]
51+
const item = buffer[i]
4752
if (isString(item)) {
4853
stream.push(item)
4954
} else {
@@ -73,18 +78,27 @@ export function renderToSimpleStream<T extends SimpleReadable>(
7378
// provide the ssr context to the tree
7479
input.provide(ssrContextKey, context)
7580

76-
Promise.resolve(renderComponentVNode(vnode))
81+
let cleaned = false
82+
const finalize = () => {
83+
if (cleaned) return
84+
cleaned = true
85+
cleanupContext(context)
86+
}
87+
88+
Promise.resolve()
89+
.then(() => renderComponentVNode(vnode))
7790
.then(buffer => unrollBuffer(buffer, stream))
7891
.then(() => resolveTeleports(context))
7992
.then(() => {
80-
if (context.__watcherHandles) {
81-
for (const unwatch of context.__watcherHandles) {
82-
unwatch()
83-
}
84-
}
93+
finalize()
94+
return stream.push(null)
8595
})
86-
.then(() => stream.push(null))
8796
.catch(error => {
97+
try {
98+
finalize()
99+
} catch {
100+
// preserve original render error as the stream failure reason
101+
}
88102
stream.destroy(error)
89103
})
90104

packages/server-renderer/src/renderToString.ts

Lines changed: 13 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,12 @@ import {
77
ssrUtils,
88
} from 'vue'
99
import { isPromise, isString } from '@vue/shared'
10-
import { type SSRBuffer, type SSRContext, renderComponentVNode } from './render'
10+
import {
11+
type SSRBuffer,
12+
type SSRContext,
13+
cleanupContext,
14+
renderComponentVNode,
15+
} from './render'
1116

1217
const { isVNode } = ssrUtils
1318

@@ -81,19 +86,17 @@ export async function renderToString(
8186
vnode.appContext = input._context
8287
// provide the ssr context to the tree
8388
input.provide(ssrContextKey, context)
84-
const buffer = await renderComponentVNode(vnode)
89+
try {
90+
const buffer = await renderComponentVNode(vnode)
8591

86-
const result = await unrollBuffer(buffer as SSRBuffer)
92+
const result = await unrollBuffer(buffer as SSRBuffer)
8793

88-
await resolveTeleports(context)
94+
await resolveTeleports(context)
8995

90-
if (context.__watcherHandles) {
91-
for (const unwatch of context.__watcherHandles) {
92-
unwatch()
93-
}
96+
return result
97+
} finally {
98+
cleanupContext(context)
9499
}
95-
96-
return result
97100
}
98101

99102
export async function resolveTeleports(context: SSRContext): Promise<void> {

0 commit comments

Comments
 (0)