Skip to content

Commit

Permalink
Support global-error for ssr fallback (#52573)
Browse files Browse the repository at this point in the history
Previously `global-error` only caught the error on client side, this PR adds the support for catching the errors thrown during client components SSR or server components RSC rendering.

Closes #46572
Closes #50119
Closes #50723
  • Loading branch information
huozhi authored Jul 12, 2023
1 parent bcd9136 commit b9760b2
Show file tree
Hide file tree
Showing 11 changed files with 161 additions and 56 deletions.
12 changes: 9 additions & 3 deletions packages/next/src/build/webpack/loaders/next-app-loader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -666,9 +666,15 @@ const nextAppLoader: AppLoader = async function nextAppLoader() {
const result = `
export ${treeCodeResult.treeCode}
export ${treeCodeResult.pages}
export { default as GlobalError } from ${JSON.stringify(
treeCodeResult.globalError || 'next/dist/client/components/error-boundary'
)}
${
treeCodeResult.globalError
? `export { default as GlobalError } from ${JSON.stringify(
treeCodeResult.globalError
)}`
: `export { GlobalError } from 'next/dist/client/components/error-boundary'`
}
export const originalPathname = ${JSON.stringify(page)}
export const __next_app__ = {
require: __webpack_require__,
Expand Down
4 changes: 2 additions & 2 deletions packages/next/src/client/components/error-boundary.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -99,10 +99,10 @@ export class ErrorBoundaryHandler extends React.Component<
}
}

export default function GlobalError({ error }: { error: any }) {
export function GlobalError({ error }: { error: any }) {
const digest: string | undefined = error?.digest
return (
<html>
<html id="__next_error__">
<head></head>
<body>
<div style={styles.error}>
Expand Down
90 changes: 69 additions & 21 deletions packages/next/src/server/app-render/app-render.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1224,13 +1224,18 @@ export async function renderToHTMLOrFlight(

const GlobalError =
/** GlobalError can be either the default error boundary or the overwritten app/global-error.js **/
ComponentMod.GlobalError as typeof import('../../client/components/error-boundary').default
ComponentMod.GlobalError as typeof import('../../client/components/error-boundary').GlobalError

let serverComponentsInlinedTransformStream: TransformStream<
Uint8Array,
Uint8Array
> = new TransformStream()

let serverErrorComponentsInlinedTransformStream: TransformStream<
Uint8Array,
Uint8Array
> = new TransformStream()

// Get the nonce from the incoming request if it has one.
const csp = req.headers['content-security-policy']
let nonce: string | undefined
Expand All @@ -1245,8 +1250,11 @@ export async function renderToHTMLOrFlight(
rscChunks: [],
}

if (!clientReferenceManifest) {
console.log(req.url)
const serverErrorComponentsRenderOpts = {
transformStream: serverErrorComponentsInlinedTransformStream,
clientReferenceManifest,
serverContexts,
rscChunks: [],
}

const validateRootLayout = dev
Expand Down Expand Up @@ -1511,7 +1519,7 @@ export async function renderToHTMLOrFlight(
})

const result = await continueFromInitialStream(renderStream, {
dataStream: serverComponentsInlinedTransformStream?.readable,
dataStream: serverComponentsInlinedTransformStream.readable,
generateStaticHTML:
staticGenerationStore.isStaticGeneration || generateStaticHTML,
getServerInsertedHTML,
Expand Down Expand Up @@ -1553,24 +1561,61 @@ export async function renderToHTMLOrFlight(
res.setHeader('Location', getURLFromRedirectError(err))
}

const defaultErrorComponent = (
<html id="__next_error__">
<head>
{/* @ts-expect-error allow to use async server component */}
<MetadataTree
key={requestId}
tree={emptyLoaderTree}
pathname={pathname}
searchParams={providedSearchParams}
getDynamicParamFromSegment={getDynamicParamFromSegment}
/>
{appUsingSizeAdjust ? <meta name="next-size-adjust" /> : null}
</head>
<body></body>
</html>
)

const useDefaultError =
res.statusCode < 400 ||
res.statusCode === 404 ||
res.statusCode === 307
const serverErrorElement = useDefaultError
? defaultErrorComponent
: React.createElement(
createServerComponentRenderer(
async () => {
// only pass plain object to client
return (
<>
{/* @ts-expect-error allow to use async server component */}
<MetadataTree
key={requestId}
tree={emptyLoaderTree}
pathname={pathname}
searchParams={providedSearchParams}
getDynamicParamFromSegment={
getDynamicParamFromSegment
}
/>
<GlobalError
error={{ message: err?.message, digest: err?.digest }}
/>
</>
)
},
ComponentMod,
serverErrorComponentsRenderOpts,
serverComponentsErrorHandler,
nonce
)
)

const renderStream = await renderToInitialStream({
ReactDOMServer: require('react-dom/server.edge'),
element: (
<html id="__next_error__">
<head>
{/* @ts-expect-error allow to use async server component */}
<MetadataTree
key={requestId}
tree={emptyLoaderTree}
pathname={pathname}
searchParams={providedSearchParams}
getDynamicParamFromSegment={getDynamicParamFromSegment}
/>
{appUsingSizeAdjust ? <meta name="next-size-adjust" /> : null}
</head>
<body></body>
</html>
),
element: serverErrorElement,
streamOptions: {
nonce,
// Include hydration scripts in the HTML
Expand All @@ -1590,7 +1635,10 @@ export async function renderToHTMLOrFlight(
})

return await continueFromInitialStream(renderStream, {
dataStream: serverComponentsInlinedTransformStream?.readable,
dataStream: (useDefaultError
? serverComponentsInlinedTransformStream
: serverErrorComponentsInlinedTransformStream
).readable,
generateStaticHTML: staticGenerationStore.isStaticGeneration,
getServerInsertedHTML,
serverInsertedHTMLToHead: true,
Expand Down
8 changes: 7 additions & 1 deletion packages/next/src/server/app-render/use-flight-response.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,13 @@ export function useFlightResponse(
)
}
if (done) {
flightResponseRef.current = null
// Add a setTimeout here because the error component is too small, the first forwardReader.read() read will return the full chunk
// and then it immediately set flightResponseRef.current as null.
// react renders the component twice, the second render will run into the state with useFlightResponse where flightResponseRef.current is null,
// so it tries to render the flight payload again
setTimeout(() => {
flightResponseRef.current = null
})
writer.close()
} else {
const responsePartial = decodeText(value, textDecoder)
Expand Down
4 changes: 3 additions & 1 deletion test/e2e/app-dir/global-error/app/global-error.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,9 @@ export default function GlobalError({ error }) {
<html>
<head></head>
<body>
<div id="error">{`Error message: ${error?.message}`}</div>
<h1>Global Error</h1>
<p id="error">{`Global error: ${error?.message}`}</p>
{error?.digest && <p id="digest">{error?.digest}</p>}
</body>
</html>
)
Expand Down
2 changes: 2 additions & 0 deletions test/e2e/app-dir/global-error/app/layout.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,3 +6,5 @@ export default function Layout({ children }) {
</html>
)
}

export const revalidate = 0
5 changes: 5 additions & 0 deletions test/e2e/app-dir/global-error/app/ssr/client/page.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
'use client'

export default function page() {
throw new Error('client page error')
}
3 changes: 3 additions & 0 deletions test/e2e/app-dir/global-error/app/ssr/server/page.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export default function page() {
throw new Error('server page error')
}
28 changes: 0 additions & 28 deletions test/e2e/app-dir/global-error/global-error.test.ts

This file was deleted.

61 changes: 61 additions & 0 deletions test/e2e/app-dir/global-error/index.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import { getRedboxHeader, hasRedbox } from 'next-test-utils'
import { createNextDescribe } from 'e2e-utils'

async function testDev(browser, errorRegex) {
expect(await hasRedbox(browser, true)).toBe(true)
expect(await getRedboxHeader(browser)).toMatch(errorRegex)
}

createNextDescribe(
'app dir - global error',
{
files: __dirname,
},
({ next, isNextDev }) => {
it('should trigger error component when an error happens during rendering', async () => {
const browser = await next.browser('/client')
await browser
.waitForElementByCss('#error-trigger-button')
.elementByCss('#error-trigger-button')
.click()

if (isNextDev) {
await testDev(browser, /Error: Client error/)
} else {
await browser
expect(await browser.elementByCss('#error').text()).toBe(
'Global error: Client error'
)
}
})

it('should render global error for error in server components', async () => {
const browser = await next.browser('/ssr/server')

if (isNextDev) {
await testDev(browser, /Error: server page error/)
} else {
expect(await browser.elementByCss('h1').text()).toBe('Global Error')
expect(await browser.elementByCss('#error').text()).toBe(
'Global error: An error occurred in the Server Components render. The specific message is omitted in production builds to avoid leaking sensitive details. A digest property is included on this error instance which may provide additional details about the nature of the error.'
)
expect(await browser.elementByCss('#digest').text()).toMatch(/\w+/)
}
})

it('should render global error for error in client components', async () => {
const browser = await next.browser('/ssr/client')

if (isNextDev) {
await testDev(browser, /Error: client page error/)
} else {
expect(await browser.elementByCss('h1').text()).toBe('Global Error')
expect(await browser.elementByCss('#error').text()).toBe(
'Global error: client page error'
)

expect(await browser.hasElementByCssSelector('#digest')).toBeFalsy()
}
})
}
)

0 comments on commit b9760b2

Please sign in to comment.