Skip to content

Commit 47ead3b

Browse files
committed
refactor(ssr): improve ssr async setup / suspense error handling
1 parent 9c4de7b commit 47ead3b

File tree

3 files changed

+58
-20
lines changed

3 files changed

+58
-20
lines changed

packages/runtime-core/src/component.ts

+2-2
Original file line numberDiff line numberDiff line change
@@ -337,7 +337,7 @@ function setupStatefulComponent(
337337
// 2. create props proxy
338338
// the propsProxy is a reactive AND readonly proxy to the actual props.
339339
// it will be updated in resolveProps() on updates before render
340-
const propsProxy = (instance.propsProxy = isInSSRComponentSetup
340+
const propsProxy = (instance.propsProxy = isSSR
341341
? instance.props
342342
: shallowReadonly(instance.props))
343343
// 3. call setup()
@@ -360,7 +360,7 @@ function setupStatefulComponent(
360360
currentSuspense = null
361361

362362
if (isPromise(setupResult)) {
363-
if (isInSSRComponentSetup) {
363+
if (isSSR) {
364364
// return the promise so server-renderer can wait on it
365365
return setupResult.then(resolvedResult => {
366366
handleSetupResult(instance, resolvedResult, parentSuspense, isSSR)

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

+17-1
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,16 @@ import { createApp, h, Suspense } from 'vue'
22
import { renderToString } from '../src/renderToString'
33

44
describe('SSR Suspense', () => {
5+
let logError: jest.SpyInstance
6+
7+
beforeEach(() => {
8+
logError = jest.spyOn(console, 'error').mockImplementation(() => {})
9+
})
10+
11+
afterEach(() => {
12+
logError.mockRestore()
13+
})
14+
515
const ResolvingAsync = {
616
async setup() {
717
return () => h('div', 'async')
@@ -10,7 +20,7 @@ describe('SSR Suspense', () => {
1020

1121
const RejectingAsync = {
1222
setup() {
13-
return new Promise((_, reject) => reject())
23+
return new Promise((_, reject) => reject('foo'))
1424
}
1525
}
1626

@@ -25,6 +35,7 @@ describe('SSR Suspense', () => {
2535
}
2636

2737
expect(await renderToString(createApp(Comp))).toBe(`<div>async</div>`)
38+
expect(logError).not.toHaveBeenCalled()
2839
})
2940

3041
test('fallback', async () => {
@@ -38,6 +49,7 @@ describe('SSR Suspense', () => {
3849
}
3950

4051
expect(await renderToString(createApp(Comp))).toBe(`<div>fallback</div>`)
52+
expect(logError).toHaveBeenCalled()
4153
})
4254

4355
test('2 components', async () => {
@@ -53,6 +65,7 @@ describe('SSR Suspense', () => {
5365
expect(await renderToString(createApp(Comp))).toBe(
5466
`<div><div>async</div><div>async</div></div>`
5567
)
68+
expect(logError).not.toHaveBeenCalled()
5669
})
5770

5871
test('resolving component + rejecting component', async () => {
@@ -66,6 +79,7 @@ describe('SSR Suspense', () => {
6679
}
6780

6881
expect(await renderToString(createApp(Comp))).toBe(`<div>fallback</div>`)
82+
expect(logError).toHaveBeenCalled()
6983
})
7084

7185
test('failing suspense in passing suspense', async () => {
@@ -87,6 +101,7 @@ describe('SSR Suspense', () => {
87101
expect(await renderToString(createApp(Comp))).toBe(
88102
`<div><div>async</div><div>fallback 2</div></div>`
89103
)
104+
expect(logError).toHaveBeenCalled()
90105
})
91106

92107
test('passing suspense in failing suspense', async () => {
@@ -106,5 +121,6 @@ describe('SSR Suspense', () => {
106121
}
107122

108123
expect(await renderToString(createApp(Comp))).toBe(`<div>fallback 1</div>`)
124+
expect(logError).toHaveBeenCalled()
109125
})
110126
})

packages/server-renderer/src/renderToString.ts

+39-17
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,6 @@ import {
1010
Fragment,
1111
ssrUtils,
1212
Slots,
13-
warn,
1413
createApp,
1514
ssrContextKey
1615
} from 'vue'
@@ -139,6 +138,8 @@ export function renderComponent(
139138
)
140139
}
141140

141+
export const AsyncSetupErrorMarker = Symbol('Vue async setup error')
142+
142143
function renderComponentVNode(
143144
vnode: VNode,
144145
parentComponent: ComponentInternalInstance | null = null
@@ -150,7 +151,21 @@ function renderComponentVNode(
150151
true /* isSSR */
151152
)
152153
if (isPromise(res)) {
153-
return res.then(() => renderComponentSubTree(instance))
154+
return res
155+
.catch(err => {
156+
// normalize async setup rejection
157+
if (!(err instanceof Error)) {
158+
err = new Error(String(err))
159+
}
160+
err[AsyncSetupErrorMarker] = true
161+
console.error(
162+
`[@vue/server-renderer]: Uncaught error in async setup:\n`,
163+
err
164+
)
165+
// rethrow for suspense
166+
throw err
167+
})
168+
.then(() => renderComponentSubTree(instance))
154169
} else {
155170
return renderComponentSubTree(instance)
156171
}
@@ -208,15 +223,17 @@ function ssrCompile(
208223
isNativeTag: instance.appContext.config.isNativeTag || NO,
209224
onError(err: CompilerError) {
210225
if (__DEV__) {
211-
const message = `Template compilation error: ${err.message}`
226+
const message = `[@vue/server-renderer] Template compilation error: ${
227+
err.message
228+
}`
212229
const codeFrame =
213230
err.loc &&
214231
generateCodeFrame(
215232
template as string,
216233
err.loc.start.offset,
217234
err.loc.end.offset
218235
)
219-
warn(codeFrame ? `${message}\n${codeFrame}` : message)
236+
console.error(codeFrame ? `${message}\n${codeFrame}` : message)
220237
} else {
221238
throw err
222239
}
@@ -243,15 +260,15 @@ function renderVNode(
243260
break
244261
default:
245262
if (shapeFlag & ShapeFlags.ELEMENT) {
246-
renderElement(push, vnode, parentComponent)
263+
renderElementVNode(push, vnode, parentComponent)
247264
} else if (shapeFlag & ShapeFlags.COMPONENT) {
248265
push(renderComponentVNode(vnode, parentComponent))
249266
} else if (shapeFlag & ShapeFlags.PORTAL) {
250-
renderPortal(vnode, parentComponent)
267+
renderPortalVNode(vnode, parentComponent)
251268
} else if (shapeFlag & ShapeFlags.SUSPENSE) {
252-
push(renderSuspense(vnode, parentComponent))
269+
push(renderSuspenseVNode(vnode, parentComponent))
253270
} else {
254-
console.warn(
271+
console.error(
255272
'[@vue/server-renderer] Invalid VNode type:',
256273
type,
257274
`(${typeof type})`
@@ -270,7 +287,7 @@ export function renderVNodeChildren(
270287
}
271288
}
272289

273-
function renderElement(
290+
function renderElementVNode(
274291
push: PushFn,
275292
vnode: VNode,
276293
parentComponent: ComponentInternalInstance
@@ -325,17 +342,17 @@ function renderElement(
325342
}
326343
}
327344

328-
function renderPortal(
345+
function renderPortalVNode(
329346
vnode: VNode,
330347
parentComponent: ComponentInternalInstance
331348
) {
332349
const target = vnode.props && vnode.props.target
333350
if (!target) {
334-
console.warn(`[@vue/server-renderer] Portal is missing target prop.`)
351+
console.error(`[@vue/server-renderer] Portal is missing target prop.`)
335352
return []
336353
}
337354
if (!isString(target)) {
338-
console.warn(
355+
console.error(
339356
`[@vue/server-renderer] Portal target must be a query selector string.`
340357
)
341358
return []
@@ -367,18 +384,23 @@ async function resolvePortals(context: SSRContext) {
367384
}
368385
}
369386

370-
async function renderSuspense(
387+
async function renderSuspenseVNode(
371388
vnode: VNode,
372389
parentComponent: ComponentInternalInstance
373390
): Promise<ResolvedSSRBuffer> {
374391
const { content, fallback } = normalizeSuspenseChildren(vnode)
375392
try {
376393
const { push, getBuffer } = createBuffer()
377394
renderVNode(push, content, parentComponent)
395+
// await here so error can be caught
378396
return await getBuffer()
379-
} catch {
380-
const { push, getBuffer } = createBuffer()
381-
renderVNode(push, fallback, parentComponent)
382-
return getBuffer()
397+
} catch (e) {
398+
if (e[AsyncSetupErrorMarker]) {
399+
const { push, getBuffer } = createBuffer()
400+
renderVNode(push, fallback, parentComponent)
401+
return getBuffer()
402+
} else {
403+
throw e
404+
}
383405
}
384406
}

0 commit comments

Comments
 (0)