diff --git a/packages/next/src/client/index.tsx b/packages/next/src/client/index.tsx index b163c29491247..4259f7983d297 100644 --- a/packages/next/src/client/index.tsx +++ b/packages/next/src/client/index.tsx @@ -66,6 +66,7 @@ declare global { type RenderRouteInfo = PrivateRouteInfo & { App: AppComponent scroll?: { x: number; y: number } | null + isHydratePass?: boolean } type RenderErrorProps = Omit type RegisterFn = (input: [string, () => void]) => void @@ -806,7 +807,16 @@ function doRender(input: RenderRouteInfo): Promise { } async function render(renderingProps: RenderRouteInfo): Promise { - if (renderingProps.err) { + // if an error occurs in a server-side page (e.g. in getInitialProps), + // skip re-rendering the error page client-side as data-fetching operations + // will already have been done on the server and NEXT_DATA contains the correct + // data for straight-forward hydration of the error page + if ( + renderingProps.err && + // renderingProps.Component might be undefined if there is a top/module-level error + (typeof renderingProps.Component === 'undefined' || + !renderingProps.isHydratePass) + ) { await renderError(renderingProps) return } @@ -975,6 +985,7 @@ export async function hydrate(opts?: { beforeRender?: () => Promise }) { Component: CachedComponent, props: initialData.props, err: initialErr, + isHydratePass: true, } if (opts?.beforeRender) { diff --git a/test/production/error-hydration/error-hydration.test.ts b/test/production/error-hydration/error-hydration.test.ts new file mode 100644 index 0000000000000..733b29dcee3f4 --- /dev/null +++ b/test/production/error-hydration/error-hydration.test.ts @@ -0,0 +1,95 @@ +import { NextInstance, createNextDescribe } from 'e2e-utils' + +async function setupErrorHydrationTests( + next: NextInstance, + targetPath: string +) { + const consoleMessages: string[] = [] + + const browser = await next.browser(targetPath, { + beforePageLoad(page) { + page.on('console', (event) => { + consoleMessages.push(event.text()) + }) + }, + }) + + return [browser, consoleMessages] as const +} + +createNextDescribe( + 'error-hydration', + { + files: __dirname, + }, + ({ next }) => { + // Recommended for tests that need a full browser + it('should log no error messages for server-side errors', async () => { + const [, consoleMessages] = await setupErrorHydrationTests( + next, + '/with-error' + ) + + expect( + consoleMessages.find((message) => + message.startsWith('A client-side exception has occurred') + ) + ).toBeUndefined() + + expect( + consoleMessages.find( + (message) => + message === + '{name: Internal Server Error., message: 500 - Internal Server Error., statusCode: 500}' + ) + ).toBeUndefined() + }) + + it('should not invoke the error page getInitialProps client-side for server-side errors', async () => { + const [b] = await setupErrorHydrationTests(next, '/with-error') + + expect( + await b.eval( + () => + (window as any).__ERROR_PAGE_GET_INITIAL_PROPS_INVOKED_CLIENT_SIDE__ + ) + ).toBe(undefined) + }) + + it('should log an message for client-side errors, including the full, custom error', async () => { + const [browser, consoleMessages] = await setupErrorHydrationTests( + next, + '/no-error' + ) + + const link = await browser.elementByCss('a') + await link.click() + + expect( + consoleMessages.some((m) => m.includes('Error: custom error')) + ).toBe(true) + + expect( + consoleMessages.some((m) => + m.includes( + 'A client-side exception has occurred, see here for more info' + ) + ) + ).toBe(true) + }) + + it("invokes _error's getInitialProps for client-side errors", async () => { + const [browser] = await setupErrorHydrationTests(next, '/no-error') + + const link = await browser.elementByCss('a') + await link.click() + + expect( + await browser.eval( + () => + (window as any).__ERROR_PAGE_GET_INITIAL_PROPS_INVOKED_CLIENT_SIDE__ + ) + ).toBe(true) + }) + } +) diff --git a/test/production/error-hydration/pages/_error.tsx b/test/production/error-hydration/pages/_error.tsx new file mode 100644 index 0000000000000..4638807b6c50a --- /dev/null +++ b/test/production/error-hydration/pages/_error.tsx @@ -0,0 +1,11 @@ +export default function ErrorPage() { + return
Error Page Content
+} + +ErrorPage.getInitialProps = async () => { + if (typeof window !== 'undefined') { + ;(window as any).__ERROR_PAGE_GET_INITIAL_PROPS_INVOKED_CLIENT_SIDE__ = true + } + + return {} +} diff --git a/test/production/error-hydration/pages/no-error.tsx b/test/production/error-hydration/pages/no-error.tsx new file mode 100644 index 0000000000000..103573420414f --- /dev/null +++ b/test/production/error-hydration/pages/no-error.tsx @@ -0,0 +1,9 @@ +import Link from 'next/link' + +export default function Page() { + return ( +

+ click me +

+ ) +} diff --git a/test/production/error-hydration/pages/with-error.tsx b/test/production/error-hydration/pages/with-error.tsx new file mode 100644 index 0000000000000..98ca37a3d0a22 --- /dev/null +++ b/test/production/error-hydration/pages/with-error.tsx @@ -0,0 +1,7 @@ +export default function Page() { + return

hello world

+} + +Page.getInitialProps = () => { + throw new Error('custom error') +}