Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Support global-error for ssr fallback #52573

Merged
merged 9 commits into from
Jul 12, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
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
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()
}
})
}
)