Skip to content

Commit

Permalink
Catch layout error in global-error (#52654)
Browse files Browse the repository at this point in the history
When there's a runtime error showing in root layout (server components), it should be able to catch by `global-error`.

For server components, we caught it and gonna render the error fallback components (either not-found or error page), and the response status is `200`, and since we'll display error dev overlay in developmenet mode so we only render `global-error` for production.

So that you can catch more errors with `global-error` and maybe do potential error tracking on client side.

Follow up of #52573
Closes NEXT-1442

minor refactor: move `appUsingSizeAdjust` into `Metadata` component so that we can just tune the flag as prop
  • Loading branch information
huozhi committed Jul 14, 2023
1 parent 3cde104 commit 79227ee
Show file tree
Hide file tree
Showing 13 changed files with 128 additions and 79 deletions.
4 changes: 4 additions & 0 deletions packages/next/src/lib/metadata/metadata.tsx
Expand Up @@ -25,11 +25,13 @@ export async function MetadataTree({
pathname,
searchParams,
getDynamicParamFromSegment,
appUsingSizeAdjust,
}: {
tree: LoaderTree
pathname: string
searchParams: { [key: string]: any }
getDynamicParamFromSegment: GetDynamicParamFromSegment
appUsingSizeAdjust: boolean
}) {
const metadataContext = {
pathname,
Expand All @@ -56,6 +58,8 @@ export async function MetadataTree({
IconsMetadata({ icons: metadata.icons }),
])

if (appUsingSizeAdjust) elements.push(<meta name="next-size-adjust" />)

return (
<>
{elements.map((el, index) => {
Expand Down
136 changes: 57 additions & 79 deletions packages/next/src/server/app-render/app-render.tsx
Expand Up @@ -17,7 +17,10 @@ import type { RequestAsyncStorage } from '../../client/components/request-async-

import React from 'react'
import { NotFound as DefaultNotFound } from '../../client/components/error'
import { createServerComponentRenderer } from './create-server-components-renderer'
import {
createServerComponentRenderer,
ErrorHtml,
} from './create-server-components-renderer'

import { ParsedUrlQuery } from 'querystring'
import { NextParsedUrlQuery } from '../request-meta'
Expand Down Expand Up @@ -195,7 +198,7 @@ export async function renderToHTMLOrFlight(
serverActionsBodySizeLimit,
} = renderOpts

const appUsingSizeAdjust = nextFontManifest?.appUsingSizeAdjust
const appUsingSizeAdjust = !!nextFontManifest?.appUsingSizeAdjust

const clientReferenceManifest = renderOpts.clientReferenceManifest!

Expand Down Expand Up @@ -1216,8 +1219,8 @@ export async function renderToHTMLOrFlight(
pathname={pathname}
searchParams={providedSearchParams}
getDynamicParamFromSegment={getDynamicParamFromSegment}
appUsingSizeAdjust={appUsingSizeAdjust}
/>
{appUsingSizeAdjust ? <meta name="next-size-adjust" /> : null}
</>
),
injectedCSS: new Set(),
Expand Down Expand Up @@ -1259,12 +1262,12 @@ export async function renderToHTMLOrFlight(
/** GlobalError can be either the default error boundary or the overwritten app/global-error.js **/
ComponentMod.GlobalError as typeof import('../../client/components/error-boundary').GlobalError

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

let serverErrorComponentsInlinedTransformStream: TransformStream<
const serverErrorComponentsInlinedTransformStream: TransformStream<
Uint8Array,
Uint8Array
> = new TransformStream()
Expand Down Expand Up @@ -1367,6 +1370,7 @@ export async function renderToHTMLOrFlight(
pathname={pathname}
searchParams={providedSearchParams}
getDynamicParamFromSegment={getDynamicParamFromSegment}
appUsingSizeAdjust={appUsingSizeAdjust}
/>
)

Expand All @@ -1384,22 +1388,15 @@ export async function renderToHTMLOrFlight(
assetPrefix={assetPrefix}
initialCanonicalUrl={pathname}
initialTree={initialTree}
initialHead={
<>
{createMetadata(loaderTree)}
{appUsingSizeAdjust ? <meta name="next-size-adjust" /> : null}
</>
}
initialHead={<>{createMetadata(loaderTree)}</>}
globalErrorComponent={GlobalError}
notFound={
NotFound ? (
<html id="__next_error__">
<body>
{createMetadata(loaderTree)}
{notFoundStyles}
<NotFound />
</body>
</html>
<ErrorHtml>
{createMetadata(loaderTree)}
{notFoundStyles}
<NotFound />
</ErrorHtml>
) : undefined
}
asNotFound={props.asNotFound}
Expand Down Expand Up @@ -1480,16 +1477,16 @@ export async function renderToHTMLOrFlight(

let polyfillsFlushed = false
let flushedErrorMetaTagsUntilIndex = 0
const getServerInsertedHTML = () => {
const getServerInsertedHTML = (serverCapturedErrors: Error[]) => {
// Loop through all the errors that have been captured but not yet
// flushed.
const errorMetaTags = []
for (
;
flushedErrorMetaTagsUntilIndex < allCapturedErrors.length;
flushedErrorMetaTagsUntilIndex < serverCapturedErrors.length;
flushedErrorMetaTagsUntilIndex++
) {
const error = allCapturedErrors[flushedErrorMetaTagsUntilIndex]
const error = serverCapturedErrors[flushedErrorMetaTagsUntilIndex]
if (isNotFoundError(error)) {
errorMetaTags.push(
<meta name="robots" content="noindex" key={error.digest} />
Expand Down Expand Up @@ -1571,7 +1568,8 @@ export async function renderToHTMLOrFlight(
dataStream: serverComponentsInlinedTransformStream.readable,
generateStaticHTML:
staticGenerationStore.isStaticGeneration || generateStaticHTML,
getServerInsertedHTML,
getServerInsertedHTML: () =>
getServerInsertedHTML(allCapturedErrors),
serverInsertedHTMLToHead: true,
...validateRootLayout,
})
Expand Down Expand Up @@ -1610,23 +1608,6 @@ 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 use404Error = res.statusCode === 404
const useDefaultError = res.statusCode < 400 || res.statusCode === 307

Expand All @@ -1643,48 +1624,45 @@ export async function renderToHTMLOrFlight(
? interopDefault(await rootLayoutModule())
: null

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
}
/>
{use404Error ? (
const serverErrorElement = (
<ErrorHtml
head={
// @ts-expect-error allow to use async server component
<MetadataTree
key={requestId}
tree={emptyLoaderTree}
pathname={pathname}
searchParams={providedSearchParams}
getDynamicParamFromSegment={getDynamicParamFromSegment}
appUsingSizeAdjust={appUsingSizeAdjust}
/>
}
>
{useDefaultError
? null
: React.createElement(
createServerComponentRenderer(
async () => {
return (
<>
<RootLayout params={{}}>
{notFoundStyles}
<NotFound />
</RootLayout>
{use404Error ? (
<RootLayout params={{}}>
{notFoundStyles}
<meta name="robots" content="noindex" />
<NotFound />
</RootLayout>
) : undefined}
</>
) : (
<GlobalError
error={{
message: err?.message,
digest: err?.digest,
}}
/>
)}
</>
)
},
ComponentMod,
serverErrorComponentsRenderOpts,
serverComponentsErrorHandler,
nonce
)
},
ComponentMod,
serverErrorComponentsRenderOpts,
serverComponentsErrorHandler,
nonce
)
)
)}
</ErrorHtml>
)

const renderStream = await renderToInitialStream({
ReactDOMServer: require('react-dom/server.edge'),
Expand Down Expand Up @@ -1713,7 +1691,7 @@ export async function renderToHTMLOrFlight(
: serverErrorComponentsInlinedTransformStream
).readable,
generateStaticHTML: staticGenerationStore.isStaticGeneration,
getServerInsertedHTML,
getServerInsertedHTML: () => getServerInsertedHTML([]),
serverInsertedHTMLToHead: true,
...validateRootLayout,
})
Expand Down
Expand Up @@ -75,3 +75,18 @@ export function createServerComponentRenderer<Props>(
return use(response)
}
}

export function ErrorHtml({
head,
children,
}: {
head?: React.ReactNode
children?: React.ReactNode
}) {
return (
<html id="__next_error__">
<head>{head}</head>
<body>{children}</body>
</html>
)
}
File renamed without changes.
File renamed without changes.
14 changes: 14 additions & 0 deletions test/e2e/app-dir/global-error/layout-error/app/global-error.js
@@ -0,0 +1,14 @@
'use client'

export default function GlobalError({ error }) {
return (
<html>
<head></head>
<body>
<h1>Global Error</h1>
<p id="error">{`Global error: ${error?.message}`}</p>
{error?.digest && <p id="digest">{error?.digest}</p>}
</body>
</html>
)
}
5 changes: 5 additions & 0 deletions test/e2e/app-dir/global-error/layout-error/app/layout.js
@@ -0,0 +1,5 @@
export default function layout() {
throw new Error('Global error: layout error')
}

export const revalidate = 0
3 changes: 3 additions & 0 deletions test/e2e/app-dir/global-error/layout-error/app/page.js
@@ -0,0 +1,3 @@
export default function page() {
return <div>Page</div>
}
30 changes: 30 additions & 0 deletions test/e2e/app-dir/global-error/layout-error/index.test.ts
@@ -0,0 +1,30 @@
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 - layout error',
{
files: __dirname,
skipDeployment: true,
},
({ next, isNextDev }) => {
it('should render global error for error in server components', async () => {
const browser = await next.browser('/')

if (isNextDev) {
await testDev(browser, /Global error: layout 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+/)
}
})
}
)

0 comments on commit 79227ee

Please sign in to comment.