diff --git a/packages/next/build/entries.ts b/packages/next/build/entries.ts index 3c4feca3e299aed..a4b97bc00bd4544 100644 --- a/packages/next/build/entries.ts +++ b/packages/next/build/entries.ts @@ -162,6 +162,7 @@ export function createEntrypoints( page, absoluteAppPath: pages['/_app'], absoluteDocumentPath: pages['/_document'], + absoluteErrorPath: pages['/_error'], absolutePagePath, isServerComponent: isFlight, buildId, diff --git a/packages/next/build/webpack/loaders/next-middleware-ssr-loader/index.ts b/packages/next/build/webpack/loaders/next-middleware-ssr-loader/index.ts index d056811f7ef58b0..0c1fd74c2dce3d0 100644 --- a/packages/next/build/webpack/loaders/next-middleware-ssr-loader/index.ts +++ b/packages/next/build/webpack/loaders/next-middleware-ssr-loader/index.ts @@ -5,6 +5,7 @@ export default async function middlewareRSCLoader(this: any) { absolutePagePath, absoluteAppPath, absoluteDocumentPath, + absoluteErrorPath, basePath, isServerComponent: isServerComponentQuery, assetPrefix, @@ -14,23 +15,36 @@ export default async function middlewareRSCLoader(this: any) { const isServerComponent = isServerComponentQuery === 'true' const stringifiedAbsolutePagePath = stringifyRequest(this, absolutePagePath) const stringifiedAbsoluteAppPath = stringifyRequest(this, absoluteAppPath) + const stringifiedAbsoluteErrorPath = stringifyRequest(this, absoluteErrorPath) + const stringified500PagePath = stringifyRequest(this, './pages/500') const stringifiedAbsoluteDocumentPath = stringifyRequest( this, absoluteDocumentPath ) - let appDefinition = `const App = require(${stringifiedAbsoluteAppPath}).default` - let documentDefinition = `const Document = require(${stringifiedAbsoluteDocumentPath}).default` - const transformed = ` import { adapter } from 'next/dist/server/web/adapter' - import { RouterContext } from 'next/dist/shared/lib/router-context' import { renderToHTML } from 'next/dist/server/web/render' - ${appDefinition} - ${documentDefinition} - + ${ + isServerComponent + ? ` + import { renderToReadableStream } from 'next/dist/compiled/react-server-dom-webpack/writer.browser.server' + import { createFromReadableStream } from 'next/dist/compiled/react-server-dom-webpack'` + : '' + } + + import App from ${stringifiedAbsoluteAppPath} + import Document from ${stringifiedAbsoluteDocumentPath} + + let ErrorPage + try { + ErrorPage = require(${stringified500PagePath}).default + } catch (_) { + ErrorPage = require(${stringifiedAbsoluteErrorPath}).default + } + const { default: Page, config, @@ -66,6 +80,7 @@ export default async function middlewareRSCLoader(this: any) { } delete query.__flight__ + const req = { url: pathname } const renderOpts = { Component, pageConfig: config || {}, @@ -97,32 +112,51 @@ export default async function middlewareRSCLoader(this: any) { const transformStream = new TransformStream() const writer = transformStream.writable.getWriter() const encoder = new TextEncoder() - + let result + let renderError + let statusCode = 200 try { - const result = await renderToHTML( - { url: pathname }, + result = await renderToHTML( + req, {}, pathname, query, renderOpts ) - result.pipe({ - write: str => writer.write(encoder.encode(str)), - end: () => writer.close(), - // Not implemented: cork/uncork/on/removeListener - }) } catch (err) { - return new Response( - (err || 'An error occurred while rendering ' + pathname + '.').toString(), - { - status: 500, - headers: { 'x-middleware-ssr': '1' } - } - ) + renderError = err + statusCode = 500 } + if (renderError) { + try { + const errorRes = { statusCode, err: renderError } + result = await renderToHTML( + req, + errorRes, + pathname, + query, + { ...renderOpts, Component: ErrorPage } + ) + } catch (err) { + return new Response( + (err || 'An error occurred while rendering ' + pathname + '.').toString(), + { + status: 500, + headers: { 'x-middleware-ssr': '1' } + } + ) + } + } + + result.pipe({ + write: str => writer.write(encoder.encode(str)), + end: () => writer.close(), + // Not implemented: cork/uncork/on/removeListener + }) return new Response(transformStream.readable, { - headers: { 'x-middleware-ssr': '1' } + headers: { 'x-middleware-ssr': '1' }, + status: statusCode }) } diff --git a/packages/next/server/dev/hot-reloader.ts b/packages/next/server/dev/hot-reloader.ts index 7a0164864c2c5b9..3887dace73edac3 100644 --- a/packages/next/server/dev/hot-reloader.ts +++ b/packages/next/server/dev/hot-reloader.ts @@ -528,6 +528,7 @@ export default class HotReloader { page, absoluteAppPath: this.pagesMapping['/_app'], absoluteDocumentPath: this.pagesMapping['/_document'], + absoluteErrorPath: this.pagesMapping['/_error'], absolutePagePath, isServerComponent, buildId: this.buildId, diff --git a/test/integration/react-streaming-and-server-components/app/pages/err.js b/test/integration/react-streaming-and-server-components/app/pages/err.js new file mode 100644 index 000000000000000..61c7136f7ba7e48 --- /dev/null +++ b/test/integration/react-streaming-and-server-components/app/pages/err.js @@ -0,0 +1,5 @@ +const page = () => 'page with err' +page.getInitialProps = () => { + throw new Error('oops') +} +export default page diff --git a/test/integration/react-streaming-and-server-components/app/pages/err/render.js b/test/integration/react-streaming-and-server-components/app/pages/err/render.js new file mode 100644 index 000000000000000..c05e49826ae7796 --- /dev/null +++ b/test/integration/react-streaming-and-server-components/app/pages/err/render.js @@ -0,0 +1,7 @@ +let did = false +export default function Error() { + if (!did && typeof window === 'undefined') { + did = true + throw new Error('oops') + } +} diff --git a/test/integration/react-streaming-and-server-components/app/pages/err/suspense.js b/test/integration/react-streaming-and-server-components/app/pages/err/suspense.js new file mode 100644 index 000000000000000..645988d009dc376 --- /dev/null +++ b/test/integration/react-streaming-and-server-components/app/pages/err/suspense.js @@ -0,0 +1,20 @@ +import { Suspense } from 'react' + +let did = false +function Error() { + if (!did && typeof window === 'undefined') { + did = true + throw new Error('broken page') + } +} + +export default function page() { + return ( + <> +

Hey Error

+ + + + + ) +} diff --git a/test/integration/react-streaming-and-server-components/test/index.test.js b/test/integration/react-streaming-and-server-components/test/index.test.js index d0d26c4671a1867..371953a331b90ed 100644 --- a/test/integration/react-streaming-and-server-components/test/index.test.js +++ b/test/integration/react-streaming-and-server-components/test/index.test.js @@ -24,6 +24,7 @@ const nativeModuleTestAppDir = join(__dirname, '../unsupported-native-module') const distDir = join(__dirname, '../app/.next') const documentPage = new File(join(appDir, 'pages/_document.jsx')) const appPage = new File(join(appDir, 'pages/_app.js')) +const error500Page = new File(join(appDir, 'pages/500.js')) const documentWithGip = ` import { Html, Head, Main, NextScript } from 'next/document' @@ -55,6 +56,12 @@ function App({ Component, pageProps }) { export default App ` +const page500 = ` +export default function Page500() { + return 'custom-500-page' +} +` + async function nextBuild(dir) { return await _nextBuild(dir, [], { stdout: true, @@ -100,11 +107,13 @@ describe('concurrentFeatures - prod', () => { const context = { appDir } beforeAll(async () => { + error500Page.write(page500) context.appPort = await findPort() await nextBuild(context.appDir) context.server = await nextStart(context.appDir, context.appPort) }) afterAll(async () => { + error500Page.delete() await killApp(context.server) }) @@ -155,10 +164,12 @@ describe('concurrentFeatures - dev', () => { const context = { appDir } beforeAll(async () => { + error500Page.write(page500) context.appPort = await findPort() context.server = await nextDev(context.appDir, context.appPort) }) afterAll(async () => { + error500Page.delete() await killApp(context.server) }) @@ -217,6 +228,7 @@ async function runBasicTests(context) { ) const path404HTML = await renderViaHTTP(context.appPort, '/404') + const path500HTML = await renderViaHTTP(context.appPort, '/err') const pathNotFoundHTML = await renderViaHTTP( context.appPort, '/this-is-not-found' @@ -230,6 +242,7 @@ async function runBasicTests(context) { expect(dynamicRouteHTML2).toContain('[pid]') expect(path404HTML).toContain('custom-404-page') + expect(path500HTML).toContain('custom-500-page') expect(pathNotFoundHTML).toContain('custom-404-page') })