From 1f5abf4745869661e96c991f94da5ddb668a6970 Mon Sep 17 00:00:00 2001 From: James Date: Wed, 8 Jul 2026 19:05:00 +0100 Subject: [PATCH 01/17] fix(pages): preserve missing page props on errors --- .../vinext/src/entries/pages-client-entry.ts | 3 ++ .../vinext/src/entries/pages-server-entry.ts | 2 +- packages/vinext/src/server/dev-server.ts | 33 ++++++++++-- .../vinext/src/server/pages-page-handler.ts | 15 ++++-- packages/vinext/src/shims/error.tsx | 52 ++++++++++++------- tests/entry-templates.test.ts | 25 +++++++++ tests/pages-page-handler.test.ts | 31 +++++++++++ tests/shims.test.ts | 17 ++++++ 8 files changed, 152 insertions(+), 26 deletions(-) diff --git a/packages/vinext/src/entries/pages-client-entry.ts b/packages/vinext/src/entries/pages-client-entry.ts index 654b4ef577..b06d7c6778 100644 --- a/packages/vinext/src/entries/pages-client-entry.ts +++ b/packages/vinext/src/entries/pages-client-entry.ts @@ -102,6 +102,9 @@ export async function generateClientEntry( // lgtm[js/bad-code-sanitization] return ` ${JSON.stringify(nextFormatPattern)}: () => import(${JSON.stringify(absPath)})`; }); + if (!pageRoutes.some((route) => route.pattern === "/_error")) { + loaderEntries.push(' "/_error": () => import("next/error")'); + } const appFileBase = appFilePath ?? undefined; diff --git a/packages/vinext/src/entries/pages-server-entry.ts b/packages/vinext/src/entries/pages-server-entry.ts index 4432cc566a..104d7faa01 100644 --- a/packages/vinext/src/entries/pages-server-entry.ts +++ b/packages/vinext/src/entries/pages-server-entry.ts @@ -95,7 +95,7 @@ export async function generateServerEntry( const errorImportCode = errorFilePath !== null ? `import * as ErrorPageModule from ${JSON.stringify(errorFilePath)};` - : `const ErrorPageModule = null;`; + : `import * as ErrorPageModule from "next/error";`; // Serialize i18n config for embedding in the server entry const i18nConfigJson = nextConfig?.i18n diff --git a/packages/vinext/src/server/dev-server.ts b/packages/vinext/src/server/dev-server.ts index 49df92b3ed..9c7ddf0c98 100644 --- a/packages/vinext/src/server/dev-server.ts +++ b/packages/vinext/src/server/dev-server.ts @@ -2094,9 +2094,9 @@ async function renderErrorPage( for (const candidate of candidates) { try { const errorAssetPath = findFileWithExts(pagesDir, candidate, matcher); - if (!errorAssetPath) continue; + if (!errorAssetPath && candidate !== "_error") continue; - const errorModule = await importModule(runner, errorAssetPath); + const errorModule = await importModule(runner, errorAssetPath ?? "next/error"); const ErrorComponent = errorModule.default; if (!ErrorComponent) continue; @@ -2115,6 +2115,7 @@ async function renderErrorPage( } const createElement = React.createElement; + res.statusCode = statusCode; const initialErrorProps = await loadPagesGetInitialProps(ErrorComponent, { req, res, @@ -2125,6 +2126,32 @@ async function renderErrorPage( }); if (res.headersSent || res.writableEnded) return; const errorProps = { ...initialErrorProps, statusCode }; + let renderProps: Record = { pageProps: errorProps }; + if (AppComponent && hasPagesGetInitialProps(AppComponent)) { + const appInitialProps = await loadPagesGetInitialProps(AppComponent, { + AppTree: (appTreeProps: Record) => + createElement(AppComponent, { + ...appTreeProps, + Component: ErrorComponent, + }), + Component: ErrorComponent, + router: { + pathname: candidate === "_error" ? "/_error" : `/${candidate}`, + query: parseQuery(url), + asPath: url, + }, + ctx: { + req, + res, + err, + pathname: candidate === "_error" ? "/_error" : `/${candidate}`, + query: parseQuery(url), + asPath: url, + }, + }); + if (res.headersSent || res.writableEnded) return; + if (appInitialProps) renderProps = appInitialProps; + } // If the caller didn't supply wrapWithRouterContext, load it now. // runner.import() caches internally so the cost is negligible. @@ -2159,8 +2186,8 @@ async function renderErrorPage( ): React.ReactElement => { let errorElement: React.ReactElement = FinalApp ? createElement(FinalApp, { + ...renderProps, Component: FinalComponent, - pageProps: errorProps, }) : createElement(FinalComponent, errorProps); if (wrapFn) errorElement = wrapFn(errorElement); diff --git a/packages/vinext/src/server/pages-page-handler.ts b/packages/vinext/src/server/pages-page-handler.ts index 4e0dead300..5f4706f2d0 100644 --- a/packages/vinext/src/server/pages-page-handler.ts +++ b/packages/vinext/src/server/pages-page-handler.ts @@ -558,13 +558,18 @@ export function createPagesPageHandler( const parsedRouteUrl = new URL(routeUrl, originalRequestUrl); const routePathname = parsedRouteUrl.pathname || "/"; const pagesResolvedUrl = routePathname + originalRequestUrl.search; - const createPageReqRes = () => - createPagesReqRes({ + const createPageReqRes = () => { + const reqRes = createPagesReqRes({ body: undefined, query, request, url: originalRequestPathAndSearch, }); + if (typeof renderStatusCode === "number") { + reqRes.res.statusCode = renderStatusCode; + } + return reqRes; + }; const isOnDemandRevalidate = isOnDemandRevalidateRequest( request.headers.get(PRERENDER_REVALIDATE_HEADER), @@ -655,7 +660,11 @@ export function createPagesPageHandler( let pageProps = pageDataResult.pageProps; let renderProps = pageDataResult.props; - if (routePattern === "/_error" && typeof renderStatusCode === "number") { + if ( + routePattern === "/_error" && + typeof renderStatusCode === "number" && + renderProps.pageProps !== undefined + ) { pageProps = { ...pageProps, statusCode: renderStatusCode }; renderProps = { ...renderProps, pageProps }; } diff --git a/packages/vinext/src/shims/error.tsx b/packages/vinext/src/shims/error.tsx index fc2fe53b50..55682abe3f 100644 --- a/packages/vinext/src/shims/error.tsx +++ b/packages/vinext/src/shims/error.tsx @@ -16,14 +16,22 @@ import { AppRouterContext, type AppRouterInstance } from "./internal/app-router- import { RouterContext } from "./internal/router-context.js"; type ErrorProps = { - statusCode: number; + statusCode?: number; title?: string; withDarkMode?: boolean; }; +type ErrorContext = { + err?: { statusCode?: number }; + res?: { statusCode?: number }; +}; + function ErrorComponent({ statusCode, title }: ErrorProps): React.ReactElement { - const defaultTitle = - statusCode === 404 ? "This page could not be found" : "Internal Server Error"; + const defaultTitle = statusCode + ? statusCode === 404 + ? "This page could not be found" + : "Internal Server Error" + : "Application error: a client-side exception has occurred (see the browser console for more information)"; const displayTitle = title ?? defaultTitle; @@ -44,22 +52,24 @@ function ErrorComponent({ statusCode, title }: ErrorProps): React.ReactElement { React.createElement( "div", null, - React.createElement( - "h1", - { - style: { - display: "inline-block", - margin: "0 20px 0 0", - padding: "0 23px 0 0", - fontSize: 24, - fontWeight: 500, - verticalAlign: "top", - lineHeight: "49px", - borderRight: "1px solid rgba(0, 0, 0, .3)", - }, - }, - statusCode, - ), + statusCode + ? React.createElement( + "h1", + { + style: { + display: "inline-block", + margin: "0 20px 0 0", + padding: "0 23px 0 0", + fontSize: 24, + fontWeight: 500, + verticalAlign: "top", + lineHeight: "49px", + borderRight: "1px solid rgba(0, 0, 0, .3)", + }, + }, + statusCode, + ) + : null, React.createElement( "div", { style: { display: "inline-block" } }, @@ -80,6 +90,10 @@ function ErrorComponent({ statusCode, title }: ErrorProps): React.ReactElement { ); } +ErrorComponent.getInitialProps = ({ err, res }: ErrorContext): ErrorProps => ({ + statusCode: res?.statusCode ?? err?.statusCode, +}); + export default ErrorComponent; // --------------------------------------------------------------------------- diff --git a/tests/entry-templates.test.ts b/tests/entry-templates.test.ts index 3832411954..8e7a3c4992 100644 --- a/tests/entry-templates.test.ts +++ b/tests/entry-templates.test.ts @@ -1187,6 +1187,31 @@ describe("Pages Router entry template", () => { } }); + // Ported from Next.js: test/e2e/no-page-props/no-page-props.test.ts + // https://github.com/vercel/next.js/blob/v16.3.0-canary.80/test/e2e/no-page-props/no-page-props.test.ts + it("uses the framework error page in server and client entries when _error is absent", async () => { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "vinext-pages-default-error-entry-")); + const pagesDir = path.join(tmpDir, "pages"); + + try { + fs.mkdirSync(pagesDir, { recursive: true }); + fs.writeFileSync( + path.join(pagesDir, "index.tsx"), + "export default function Page() { return null; }", + ); + + const nextConfig = await resolveNextConfig({}); + const matcher = createValidFileMatcher(); + const serverCode = await generateServerEntry(pagesDir, nextConfig, matcher, null, null); + const clientCode = await generateClientEntry(pagesDir, nextConfig, matcher); + + expect(serverCode).toContain('import * as ErrorPageModule from "next/error";'); + expect(clientCode).toContain('"/_error": () => import("next/error")'); + } finally { + fs.rmSync(tmpDir, { recursive: true, force: true }); + } + }); + // Refs #1474: Pages Router client entry must import the user's // `instrumentation-client.ts` (at the project root) as a side-effect import // before calling `hydrateRoot()`. Mirrors Next.js's `page-bootstrap.ts` diff --git a/tests/pages-page-handler.test.ts b/tests/pages-page-handler.test.ts index a0022fe3f9..24afa1cc76 100644 --- a/tests/pages-page-handler.test.ts +++ b/tests/pages-page-handler.test.ts @@ -153,6 +153,37 @@ describe("createPagesPageHandler — route miss", () => { expect(res.status).toBe(404); }); + // Ported from Next.js: test/e2e/no-page-props/no-page-props.test.ts + // https://github.com/vercel/next.js/blob/v16.3.0-canary.80/test/e2e/no-page-props/no-page-props.test.ts + it("preserves a custom App initial-props envelope without pageProps on _error", async () => { + const renderedProps: Record[] = []; + const errorComponent = Object.assign(() => null, { + getInitialProps: ({ res }: { res: { statusCode: number } }) => ({ + statusCode: res.statusCode, + }), + }); + const errorRoute = makeRoute("/_error", makePageModule({ default: errorComponent })); + const AppComponent = Object.assign(() => null, { + getInitialProps: async () => ({ initialProps: {} }), + }); + const handler = createPagesPageHandler( + makeOpts({ + pageRoutes: [], + errorPageRoute: errorRoute, + AppComponent, + createPageElement: (_PageComponent, _AppComponent, props) => { + renderedProps.push(props); + return null; + }, + }), + ); + + const res = await handler(makeRequest("/missing"), "/missing", null, null, null); + + expect(res.status).toBe(404); + expect(renderedProps).toContainEqual({ initialProps: {} }); + }); + it("returns _next/data 404 JSON on data request route miss", async () => { const handler = createPagesPageHandler(makeOpts({ pageRoutes: [] })); const res = await handler(makeRequest("/missing"), "/missing", null, null, { isDataReq: true }); diff --git a/tests/shims.test.ts b/tests/shims.test.ts index 400a49197e..a2ad8aa35f 100644 --- a/tests/shims.test.ts +++ b/tests/shims.test.ts @@ -21763,6 +21763,23 @@ describe("next/error shim", () => { expect(html).toContain("403"); expect(html).toContain("Forbidden"); }); + + it("renders the client exception message when statusCode is absent", async () => { + const React = await import("react"); + const { renderToStaticMarkup } = await import("react-dom/server"); + const ErrorComponent = (await import("../packages/vinext/src/shims/error.js")).default; + + const html = renderToStaticMarkup(React.createElement(ErrorComponent, {})); + expect(html).not.toContain(" { + const ErrorComponent = (await import("../packages/vinext/src/shims/error.js")).default; + expect(ErrorComponent.getInitialProps({ res: { statusCode: 404 } })).toEqual({ + statusCode: 404, + }); + }); }); // next/app default export From 36ed1ff988edf965179e7f9c591ca0d8105f1592 Mon Sep 17 00:00:00 2001 From: James Date: Wed, 8 Jul 2026 19:12:21 +0100 Subject: [PATCH 02/17] fix(pages): complete default error props contract --- packages/vinext/src/shims/error.tsx | 39 ++++++++++++++++++++++++++--- tests/shims.test.ts | 36 ++++++++++++++++++++++++-- 2 files changed, 70 insertions(+), 5 deletions(-) diff --git a/packages/vinext/src/shims/error.tsx b/packages/vinext/src/shims/error.tsx index 55682abe3f..c84737620c 100644 --- a/packages/vinext/src/shims/error.tsx +++ b/packages/vinext/src/shims/error.tsx @@ -17,15 +17,49 @@ import { RouterContext } from "./internal/router-context.js"; type ErrorProps = { statusCode?: number; + hostname?: string; title?: string; withDarkMode?: boolean; }; type ErrorContext = { err?: { statusCode?: number }; + req?: { + url?: string; + headers?: { host?: string | string[] }; + }; res?: { statusCode?: number }; }; +function getErrorInitialProps({ + err, + req, + res, +}: ErrorContext): ErrorProps & { statusCode: number } { + const statusCode = res?.statusCode || err?.statusCode || 404; + let hostname: string | undefined; + + if (typeof window !== "undefined") { + hostname = window.location.hostname; + } else if (req) { + if (req.url) { + try { + hostname = new URL(req.url).hostname; + } catch { + // Node Pages requests commonly expose a path-only URL, so use the + // request Host header below when no absolute request URL is available. + } + } + + if (!hostname) { + const host = Array.isArray(req.headers?.host) ? req.headers.host[0] : req.headers?.host; + if (host) hostname = new URL(`http://${host}`).hostname; + } + } + + return { statusCode, hostname }; +} + function ErrorComponent({ statusCode, title }: ErrorProps): React.ReactElement { const defaultTitle = statusCode ? statusCode === 404 @@ -90,9 +124,8 @@ function ErrorComponent({ statusCode, title }: ErrorProps): React.ReactElement { ); } -ErrorComponent.getInitialProps = ({ err, res }: ErrorContext): ErrorProps => ({ - statusCode: res?.statusCode ?? err?.statusCode, -}); +ErrorComponent.getInitialProps = getErrorInitialProps; +ErrorComponent.origGetInitialProps = getErrorInitialProps; export default ErrorComponent; diff --git a/tests/shims.test.ts b/tests/shims.test.ts index a2ad8aa35f..0f04c62d13 100644 --- a/tests/shims.test.ts +++ b/tests/shims.test.ts @@ -21774,10 +21774,42 @@ describe("next/error shim", () => { expect(html).toContain("Application error: a client-side exception has occurred"); }); - it("derives statusCode from the response in getInitialProps", async () => { + it("matches the default Error getInitialProps static contract", async () => { const ErrorComponent = (await import("../packages/vinext/src/shims/error.js")).default; - expect(ErrorComponent.getInitialProps({ res: { statusCode: 404 } })).toEqual({ + + expect(ErrorComponent.origGetInitialProps).toBe(ErrorComponent.getInitialProps); + expect(ErrorComponent.getInitialProps({ res: { statusCode: 500 } })).toEqual({ + statusCode: 500, + hostname: undefined, + }); + expect(ErrorComponent.getInitialProps({ err: { statusCode: 401 } })).toEqual({ + statusCode: 401, + hostname: undefined, + }); + expect(ErrorComponent.getInitialProps({})).toEqual({ + statusCode: 404, + hostname: undefined, + }); + }); + + it("derives the Error hostname from the server request", async () => { + const ErrorComponent = (await import("../packages/vinext/src/shims/error.js")).default; + + expect( + ErrorComponent.getInitialProps({ + req: { url: "https://preview.example.com:8443/failure" }, + }), + ).toEqual({ + statusCode: 404, + hostname: "preview.example.com", + }); + expect( + ErrorComponent.getInitialProps({ + req: { url: "/failure", headers: { host: "fallback.example.com:3000" } }, + }), + ).toEqual({ statusCode: 404, + hostname: "fallback.example.com", }); }); }); From 8635f98585761c2656f86c13c22dfa08435211fc Mon Sep 17 00:00:00 2001 From: James Date: Wed, 8 Jul 2026 19:20:08 +0100 Subject: [PATCH 03/17] fix(pages): match default error component contract --- packages/vinext/src/shims/error.tsx | 13 +++++-------- tests/shims.test.ts | 21 +++++++++++++++++++++ 2 files changed, 26 insertions(+), 8 deletions(-) diff --git a/packages/vinext/src/shims/error.tsx b/packages/vinext/src/shims/error.tsx index c84737620c..7382a79327 100644 --- a/packages/vinext/src/shims/error.tsx +++ b/packages/vinext/src/shims/error.tsx @@ -31,12 +31,8 @@ type ErrorContext = { res?: { statusCode?: number }; }; -function getErrorInitialProps({ - err, - req, - res, -}: ErrorContext): ErrorProps & { statusCode: number } { - const statusCode = res?.statusCode || err?.statusCode || 404; +function getErrorInitialProps({ err, req, res }: ErrorContext): ErrorProps { + const statusCode = res?.statusCode ? res.statusCode : err ? err.statusCode : 404; let hostname: string | undefined; if (typeof window !== "undefined") { @@ -60,12 +56,12 @@ function getErrorInitialProps({ return { statusCode, hostname }; } -function ErrorComponent({ statusCode, title }: ErrorProps): React.ReactElement { +function ErrorComponent({ statusCode, hostname, title }: ErrorProps): React.ReactElement { const defaultTitle = statusCode ? statusCode === 404 ? "This page could not be found" : "Internal Server Error" - : "Application error: a client-side exception has occurred (see the browser console for more information)"; + : `Application error: a client-side exception has occurred${hostname ? ` while loading ${hostname}` : ""} (see the browser console for more information)`; const displayTitle = title ?? defaultTitle; @@ -124,6 +120,7 @@ function ErrorComponent({ statusCode, title }: ErrorProps): React.ReactElement { ); } +ErrorComponent.displayName = "ErrorPage"; ErrorComponent.getInitialProps = getErrorInitialProps; ErrorComponent.origGetInitialProps = getErrorInitialProps; diff --git a/tests/shims.test.ts b/tests/shims.test.ts index 0f04c62d13..a60bc58ba0 100644 --- a/tests/shims.test.ts +++ b/tests/shims.test.ts @@ -21774,6 +21774,17 @@ describe("next/error shim", () => { expect(html).toContain("Application error: a client-side exception has occurred"); }); + it("renders the hostname in the client exception message", async () => { + const React = await import("react"); + const { renderToStaticMarkup } = await import("react-dom/server"); + const ErrorComponent = (await import("../packages/vinext/src/shims/error.js")).default; + + const html = renderToStaticMarkup( + React.createElement(ErrorComponent, { hostname: "preview.example.com" }), + ); + expect(html).toContain("while loading preview.example.com"); + }); + it("matches the default Error getInitialProps static contract", async () => { const ErrorComponent = (await import("../packages/vinext/src/shims/error.js")).default; @@ -21786,12 +21797,22 @@ describe("next/error shim", () => { statusCode: 401, hostname: undefined, }); + expect(ErrorComponent.getInitialProps({ err: {} })).toEqual({ + statusCode: undefined, + hostname: undefined, + }); expect(ErrorComponent.getInitialProps({})).toEqual({ statusCode: 404, hostname: undefined, }); }); + it("exposes the default Error display name", async () => { + const ErrorComponent = (await import("../packages/vinext/src/shims/error.js")).default; + + expect(ErrorComponent.displayName).toBe("ErrorPage"); + }); + it("derives the Error hostname from the server request", async () => { const ErrorComponent = (await import("../packages/vinext/src/shims/error.js")).default; From d863fe36c734e265f63037cded81460ad0ac7490 Mon Sep 17 00:00:00 2001 From: James Date: Wed, 8 Jul 2026 19:27:40 +0100 Subject: [PATCH 04/17] fix(pages): complete next error parity --- packages/vinext/src/shims/error.tsx | 118 +++++++++++++--------- packages/vinext/src/shims/next-shims.d.ts | 18 +++- tests/shims.test.ts | 71 ++++++++++++- 3 files changed, 153 insertions(+), 54 deletions(-) diff --git a/packages/vinext/src/shims/error.tsx b/packages/vinext/src/shims/error.tsx index 7382a79327..f71712f023 100644 --- a/packages/vinext/src/shims/error.tsx +++ b/packages/vinext/src/shims/error.tsx @@ -10,13 +10,21 @@ * `next/error`'s public surface. */ import React from "react"; +import Head from "./head.js"; import { isNextRouterError } from "./navigation.js"; import { useUntrackedPathname } from "./internal/navigation-untracked.js"; import { AppRouterContext, type AppRouterInstance } from "./internal/app-router-context.js"; import { RouterContext } from "./internal/router-context.js"; -type ErrorProps = { - statusCode?: number; +const statusCodes: Record = { + 400: "Bad Request", + 404: "This page could not be found", + 405: "Method Not Allowed", + 500: "Internal Server Error", +}; + +export type ErrorProps = { + statusCode: number; hostname?: string; title?: string; withDarkMode?: boolean; @@ -32,7 +40,7 @@ type ErrorContext = { }; function getErrorInitialProps({ err, req, res }: ErrorContext): ErrorProps { - const statusCode = res?.statusCode ? res.statusCode : err ? err.statusCode : 404; + const statusCode = res?.statusCode ? res.statusCode : err ? err.statusCode! : 404; let hostname: string | undefined; if (typeof window !== "undefined") { @@ -56,64 +64,76 @@ function getErrorInitialProps({ err, req, res }: ErrorContext): ErrorProps { return { statusCode, hostname }; } -function ErrorComponent({ statusCode, hostname, title }: ErrorProps): React.ReactElement { - const defaultTitle = statusCode - ? statusCode === 404 - ? "This page could not be found" - : "Internal Server Error" - : `Application error: a client-side exception has occurred${hostname ? ` while loading ${hostname}` : ""} (see the browser console for more information)`; +const styles: Record = { + error: { + fontFamily: + 'system-ui,"Segoe UI",Roboto,Helvetica,Arial,sans-serif,"Apple Color Emoji","Segoe UI Emoji"', + height: "100vh", + textAlign: "center", + display: "flex", + flexDirection: "column", + alignItems: "center", + justifyContent: "center", + }, + desc: { lineHeight: "48px" }, + h1: { + display: "inline-block", + margin: "0 20px 0 0", + paddingRight: 23, + fontSize: 24, + fontWeight: 500, + verticalAlign: "top", + }, + h2: { fontSize: 14, fontWeight: 400, lineHeight: "28px" }, + wrap: { display: "inline-block" }, +}; - const displayTitle = title ?? defaultTitle; +function ErrorComponent({ + statusCode, + hostname, + title: customTitle, + withDarkMode = true, +}: ErrorProps): React.ReactElement { + const title = customTitle || statusCodes[statusCode] || "An unexpected error has occurred"; return React.createElement( "div", - { - style: { - fontFamily: - '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif', - height: "100vh", - textAlign: "center" as const, - display: "flex", - flexDirection: "column" as const, - alignItems: "center", - justifyContent: "center", - }, - }, + { style: styles.error }, React.createElement( - "div", + Head, null, + React.createElement( + "title", + null, + statusCode + ? `${statusCode}: ${title}` + : "Application error: a client-side exception has occurred", + ), + ), + React.createElement( + "div", + { style: styles.desc }, + React.createElement("style", { + dangerouslySetInnerHTML: { + __html: `body{color:#000;background:#fff;margin:0}.next-error-h1{border-right:1px solid rgba(0,0,0,.3)}${ + withDarkMode + ? "@media (prefers-color-scheme:dark){body{color:#fff;background:#000}.next-error-h1{border-right:1px solid rgba(255,255,255,.3)}}" + : "" + }`, + }, + }), statusCode - ? React.createElement( - "h1", - { - style: { - display: "inline-block", - margin: "0 20px 0 0", - padding: "0 23px 0 0", - fontSize: 24, - fontWeight: 500, - verticalAlign: "top", - lineHeight: "49px", - borderRight: "1px solid rgba(0, 0, 0, .3)", - }, - }, - statusCode, - ) + ? React.createElement("h1", { className: "next-error-h1", style: styles.h1 }, statusCode) : null, React.createElement( "div", - { style: { display: "inline-block" } }, + { style: styles.wrap }, React.createElement( "h2", - { - style: { - fontSize: 14, - fontWeight: 400, - lineHeight: "49px", - margin: 0, - }, - }, - displayTitle + ".", + { style: styles.h2 }, + customTitle || statusCode + ? `${title}.` + : `Application error: a client-side exception has occurred${hostname ? ` while loading ${hostname}` : ""} (see the browser console for more information).`, ), ), ), diff --git a/packages/vinext/src/shims/next-shims.d.ts b/packages/vinext/src/shims/next-shims.d.ts index 2b4f994d3f..06b662ce7d 100644 --- a/packages/vinext/src/shims/next-shims.d.ts +++ b/packages/vinext/src/shims/next-shims.d.ts @@ -410,16 +410,28 @@ declare module "next/legacy/image" { } declare module "next/error" { + import * as React from "react"; import { ComponentType, ReactNode } from "react"; - type ErrorProps = { + export type ErrorProps = { statusCode: number; + hostname?: string; title?: string; withDarkMode?: boolean; }; - const ErrorComponent: ComponentType; - export default ErrorComponent; + type ErrorContext = { + err?: { statusCode?: number }; + req?: { url?: string; headers?: { host?: string | string[] } }; + res?: { statusCode?: number }; + }; + + export default class ErrorComponent

extends React.Component

{ + static displayName: string; + static getInitialProps: (context: ErrorContext) => ErrorProps | Promise; + static origGetInitialProps: (context: ErrorContext) => ErrorProps | Promise; + render(): React.ReactNode; + } export type ErrorInfo = { error: unknown; diff --git a/tests/shims.test.ts b/tests/shims.test.ts index a60bc58ba0..984cc67337 100644 --- a/tests/shims.test.ts +++ b/tests/shims.test.ts @@ -21752,6 +21752,50 @@ describe("next/error shim", () => { expect(html).toContain("Internal Server Error"); }); + it("matches Next.js status-specific and unknown error titles", async () => { + const React = await import("react"); + const { renderToStaticMarkup } = await import("react-dom/server"); + const ErrorComponent = (await import("../packages/vinext/src/shims/error.js")).default; + + expect( + renderToStaticMarkup(React.createElement(ErrorComponent, { statusCode: 400 })), + ).toContain("Bad Request"); + expect( + renderToStaticMarkup(React.createElement(ErrorComponent, { statusCode: 405 })), + ).toContain("Method Not Allowed"); + expect( + renderToStaticMarkup(React.createElement(ErrorComponent, { statusCode: 418 })), + ).toContain("An unexpected error has occurred"); + }); + + it("sets the matching error document title", async () => { + const React = await import("react"); + const { renderToStaticMarkup } = await import("react-dom/server"); + const ErrorComponent = (await import("../packages/vinext/src/shims/error.js")).default; + const { getSSRHeadHTML, resetSSRHead } = await import("../packages/vinext/src/shims/head.js"); + + resetSSRHead(); + renderToStaticMarkup(React.createElement(ErrorComponent, { statusCode: 400 })); + expect(getSSRHeadHTML()).toContain('400: Bad Request'); + }); + + it("enables dark mode styles by default and removes only the media rule when disabled", async () => { + const React = await import("react"); + const { renderToStaticMarkup } = await import("react-dom/server"); + const ErrorComponent = (await import("../packages/vinext/src/shims/error.js")).default; + + const darkHtml = renderToStaticMarkup(React.createElement(ErrorComponent, { statusCode: 500 })); + const lightHtml = renderToStaticMarkup( + React.createElement(ErrorComponent, { statusCode: 500, withDarkMode: false }), + ); + + expect(darkHtml).toContain("body{color:#000;background:#fff;margin:0}"); + expect(darkHtml).toContain("@media (prefers-color-scheme:dark)"); + expect(lightHtml).toContain("body{color:#000;background:#fff;margin:0}"); + expect(lightHtml).not.toContain("@media (prefers-color-scheme:dark)"); + expect(lightHtml).toContain('class="next-error-h1"'); + }); + it("renders custom title", async () => { const React = await import("react"); const { renderToStaticMarkup } = await import("react-dom/server"); @@ -21769,7 +21813,9 @@ describe("next/error shim", () => { const { renderToStaticMarkup } = await import("react-dom/server"); const ErrorComponent = (await import("../packages/vinext/src/shims/error.js")).default; - const html = renderToStaticMarkup(React.createElement(ErrorComponent, {})); + const html = renderToStaticMarkup( + React.createElement(ErrorComponent, { statusCode: undefined as never }), + ); expect(html).not.toContain(" { const ErrorComponent = (await import("../packages/vinext/src/shims/error.js")).default; const html = renderToStaticMarkup( - React.createElement(ErrorComponent, { hostname: "preview.example.com" }), + React.createElement(ErrorComponent, { + statusCode: undefined as never, + hostname: "preview.example.com", + }), ); expect(html).toContain("while loading preview.example.com"); }); @@ -21813,6 +21862,24 @@ describe("next/error shim", () => { expect(ErrorComponent.displayName).toBe("ErrorPage"); }); + it("exports the public ErrorProps and static component contract", async () => { + const declaration = await readFile( + new URL("../packages/vinext/src/shims/next-shims.d.ts", import.meta.url), + "utf8", + ); + const errorDeclaration = declaration.slice( + declaration.indexOf('declare module "next/error"'), + declaration.indexOf('declare module "next/font/google"'), + ); + + expect(errorDeclaration).toContain("export type ErrorProps = {"); + expect(errorDeclaration).toContain("hostname?: string;"); + expect(errorDeclaration).toContain("export default class ErrorComponent

"); + expect(errorDeclaration).toContain("static displayName: string;"); + expect(errorDeclaration).toContain("static getInitialProps:"); + expect(errorDeclaration).toContain("static origGetInitialProps:"); + }); + it("derives the Error hostname from the server request", async () => { const ErrorComponent = (await import("../packages/vinext/src/shims/error.js")).default; From 438a17a815c03002f259b6799fbc7bc08147cd46 Mon Sep 17 00:00:00 2001 From: James Date: Wed, 8 Jul 2026 19:33:11 +0100 Subject: [PATCH 05/17] fix(pages): make next error subclassable --- packages/vinext/src/shims/error.tsx | 88 ++++++++++++++--------------- tests/shims.test.ts | 16 ++++++ 2 files changed, 59 insertions(+), 45 deletions(-) diff --git a/packages/vinext/src/shims/error.tsx b/packages/vinext/src/shims/error.tsx index f71712f023..d1155cacdf 100644 --- a/packages/vinext/src/shims/error.tsx +++ b/packages/vinext/src/shims/error.tsx @@ -88,62 +88,60 @@ const styles: Record = { wrap: { display: "inline-block" }, }; -function ErrorComponent({ - statusCode, - hostname, - title: customTitle, - withDarkMode = true, -}: ErrorProps): React.ReactElement { - const title = customTitle || statusCodes[statusCode] || "An unexpected error has occurred"; +class ErrorComponent

extends React.Component

{ + static displayName = "ErrorPage"; + static getInitialProps = getErrorInitialProps; + static origGetInitialProps = getErrorInitialProps; - return React.createElement( - "div", - { style: styles.error }, - React.createElement( - Head, - null, + render(): React.ReactElement { + const { statusCode, hostname, title: customTitle, withDarkMode = true } = this.props; + const title = customTitle || statusCodes[statusCode] || "An unexpected error has occurred"; + + return React.createElement( + "div", + { style: styles.error }, React.createElement( - "title", + Head, null, - statusCode - ? `${statusCode}: ${title}` - : "Application error: a client-side exception has occurred", + React.createElement( + "title", + null, + statusCode + ? `${statusCode}: ${title}` + : "Application error: a client-side exception has occurred", + ), ), - ), - React.createElement( - "div", - { style: styles.desc }, - React.createElement("style", { - dangerouslySetInnerHTML: { - __html: `body{color:#000;background:#fff;margin:0}.next-error-h1{border-right:1px solid rgba(0,0,0,.3)}${ - withDarkMode - ? "@media (prefers-color-scheme:dark){body{color:#fff;background:#000}.next-error-h1{border-right:1px solid rgba(255,255,255,.3)}}" - : "" - }`, - }, - }), - statusCode - ? React.createElement("h1", { className: "next-error-h1", style: styles.h1 }, statusCode) - : null, React.createElement( "div", - { style: styles.wrap }, + { style: styles.desc }, + React.createElement("style", { + dangerouslySetInnerHTML: { + __html: `body{color:#000;background:#fff;margin:0}.next-error-h1{border-right:1px solid rgba(0,0,0,.3)}${ + withDarkMode + ? "@media (prefers-color-scheme:dark){body{color:#fff;background:#000}.next-error-h1{border-right:1px solid rgba(255,255,255,.3)}}" + : "" + }`, + }, + }), + statusCode + ? React.createElement("h1", { className: "next-error-h1", style: styles.h1 }, statusCode) + : null, React.createElement( - "h2", - { style: styles.h2 }, - customTitle || statusCode - ? `${title}.` - : `Application error: a client-side exception has occurred${hostname ? ` while loading ${hostname}` : ""} (see the browser console for more information).`, + "div", + { style: styles.wrap }, + React.createElement( + "h2", + { style: styles.h2 }, + customTitle || statusCode + ? `${title}.` + : `Application error: a client-side exception has occurred${hostname ? ` while loading ${hostname}` : ""} (see the browser console for more information).`, + ), ), ), - ), - ); + ); + } } -ErrorComponent.displayName = "ErrorPage"; -ErrorComponent.getInitialProps = getErrorInitialProps; -ErrorComponent.origGetInitialProps = getErrorInitialProps; - export default ErrorComponent; // --------------------------------------------------------------------------- diff --git a/tests/shims.test.ts b/tests/shims.test.ts index 984cc67337..68ded0b508 100644 --- a/tests/shims.test.ts +++ b/tests/shims.test.ts @@ -21862,6 +21862,22 @@ describe("next/error shim", () => { expect(ErrorComponent.displayName).toBe("ErrorPage"); }); + it("supports subclassing the default Error component", async () => { + const React = await import("react"); + const { renderToStaticMarkup } = await import("react-dom/server"); + const ErrorComponent = (await import("../packages/vinext/src/shims/error.js")).default; + + class CustomError extends ErrorComponent { + render() { + return React.createElement("section", { "data-custom-error": true }, super.render()); + } + } + + const html = renderToStaticMarkup(React.createElement(CustomError, { statusCode: 404 })); + expect(html).toContain('

'); + expect(html).toContain("This page could not be found"); + }); + it("exports the public ErrorProps and static component contract", async () => { const declaration = await readFile( new URL("../packages/vinext/src/shims/next-shims.d.ts", import.meta.url), From de064d8c80c16c48552bd6a15b9d7349d709fc5c Mon Sep 17 00:00:00 2001 From: James Date: Wed, 8 Jul 2026 19:39:38 +0100 Subject: [PATCH 06/17] fix(pages): align next error context types --- packages/vinext/src/shims/error.tsx | 12 +-- packages/vinext/src/shims/next-shims.d.ts | 25 ++++-- tests/shims.test.ts | 96 +++++++++++++++++++---- 3 files changed, 98 insertions(+), 35 deletions(-) diff --git a/packages/vinext/src/shims/error.tsx b/packages/vinext/src/shims/error.tsx index d1155cacdf..7f2b7c2951 100644 --- a/packages/vinext/src/shims/error.tsx +++ b/packages/vinext/src/shims/error.tsx @@ -10,6 +10,7 @@ * `next/error`'s public surface. */ import React from "react"; +import type { NextPageContext } from "next"; import Head from "./head.js"; import { isNextRouterError } from "./navigation.js"; import { useUntrackedPathname } from "./internal/navigation-untracked.js"; @@ -30,16 +31,7 @@ export type ErrorProps = { withDarkMode?: boolean; }; -type ErrorContext = { - err?: { statusCode?: number }; - req?: { - url?: string; - headers?: { host?: string | string[] }; - }; - res?: { statusCode?: number }; -}; - -function getErrorInitialProps({ err, req, res }: ErrorContext): ErrorProps { +function getErrorInitialProps({ err, req, res }: NextPageContext): ErrorProps { const statusCode = res?.statusCode ? res.statusCode : err ? err.statusCode! : 404; let hostname: string | undefined; diff --git a/packages/vinext/src/shims/next-shims.d.ts b/packages/vinext/src/shims/next-shims.d.ts index 06b662ce7d..60f6b50dbb 100644 --- a/packages/vinext/src/shims/next-shims.d.ts +++ b/packages/vinext/src/shims/next-shims.d.ts @@ -8,6 +8,20 @@ declare module "next" { import type { IncomingMessage, ServerResponse } from "node:http"; + import type { ParsedUrlQuery } from "node:querystring"; + import type { ComponentType } from "react"; + export type NextPageContext = { + err?: (Error & { statusCode?: number }) | null; + req?: IncomingMessage; + res?: ServerResponse; + pathname: string; + query: ParsedUrlQuery; + asPath?: string; + locale?: string; + locales?: readonly string[]; + defaultLocale?: string; + AppTree: ComponentType<{ pageProps: unknown; [name: string]: unknown }>; + }; export type NextApiRequest = { query: Record; body: unknown; @@ -412,6 +426,7 @@ declare module "next/legacy/image" { declare module "next/error" { import * as React from "react"; import { ComponentType, ReactNode } from "react"; + import type { NextPageContext } from "next"; export type ErrorProps = { statusCode: number; @@ -420,16 +435,10 @@ declare module "next/error" { withDarkMode?: boolean; }; - type ErrorContext = { - err?: { statusCode?: number }; - req?: { url?: string; headers?: { host?: string | string[] } }; - res?: { statusCode?: number }; - }; - export default class ErrorComponent

extends React.Component

{ static displayName: string; - static getInitialProps: (context: ErrorContext) => ErrorProps | Promise; - static origGetInitialProps: (context: ErrorContext) => ErrorProps | Promise; + static getInitialProps: (context: NextPageContext) => ErrorProps | Promise; + static origGetInitialProps: (context: NextPageContext) => ErrorProps | Promise; render(): React.ReactNode; } diff --git a/tests/shims.test.ts b/tests/shims.test.ts index 68ded0b508..6201150cd3 100644 --- a/tests/shims.test.ts +++ b/tests/shims.test.ts @@ -1,5 +1,6 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vite-plus/test"; -import { readFile } from "node:fs/promises"; +import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import os from "node:os"; import path from "node:path"; import { PAGES_FIXTURE_DIR, aliasEntriesToRecord } from "./helpers.js"; import { isExternalUrl, isHashOnlyChange } from "../packages/vinext/src/shims/router.js"; @@ -9,6 +10,7 @@ import vinext from "../packages/vinext/src/index.js"; import { safeJsonStringify } from "../packages/vinext/src/server/html.js"; import { buildPagesNextDataScript } from "../packages/vinext/src/server/pages-page-response.js"; import type { Plugin } from "vite-plus"; +import type { NextPageContext } from "next"; import type { NextRouter } from "../packages/vinext/src/shims/router.js"; import type { CacheHandler, @@ -21732,6 +21734,8 @@ describe("next/legacy/image shim", () => { }); describe("next/error shim", () => { + const asNextPageContext = (context: unknown): NextPageContext => context as NextPageContext; + it("renders 404 error page", async () => { const React = await import("react"); const { renderToStaticMarkup } = await import("react-dom/server"); @@ -21838,19 +21842,23 @@ describe("next/error shim", () => { const ErrorComponent = (await import("../packages/vinext/src/shims/error.js")).default; expect(ErrorComponent.origGetInitialProps).toBe(ErrorComponent.getInitialProps); - expect(ErrorComponent.getInitialProps({ res: { statusCode: 500 } })).toEqual({ - statusCode: 500, - hostname: undefined, - }); - expect(ErrorComponent.getInitialProps({ err: { statusCode: 401 } })).toEqual({ - statusCode: 401, - hostname: undefined, - }); - expect(ErrorComponent.getInitialProps({ err: {} })).toEqual({ + expect(ErrorComponent.getInitialProps(asNextPageContext({ res: { statusCode: 500 } }))).toEqual( + { + statusCode: 500, + hostname: undefined, + }, + ); + expect(ErrorComponent.getInitialProps(asNextPageContext({ err: { statusCode: 401 } }))).toEqual( + { + statusCode: 401, + hostname: undefined, + }, + ); + expect(ErrorComponent.getInitialProps(asNextPageContext({ err: {} }))).toEqual({ statusCode: undefined, hostname: undefined, }); - expect(ErrorComponent.getInitialProps({})).toEqual({ + expect(ErrorComponent.getInitialProps(asNextPageContext({}))).toEqual({ statusCode: 404, hostname: undefined, }); @@ -21896,21 +21904,75 @@ describe("next/error shim", () => { expect(errorDeclaration).toContain("static origGetInitialProps:"); }); + it("allows the upstream CustomError static getInitialProps override", async () => { + // Ported from Next.js: test/e2e/typescript/pages/_error.tsx + // https://github.com/vercel/next.js/blob/v16.3.0-canary.80/test/e2e/typescript/pages/_error.tsx + const ts = await import("typescript"); + const tempDir = await mkdtemp(path.join(os.tmpdir(), "vinext-next-error-types-")); + const fixturePath = path.join(tempDir, "_error.tsx"); + const declarationPath = new URL("../packages/vinext/src/shims/next-shims.d.ts", import.meta.url) + .pathname; + + try { + await writeFile( + fixturePath, + `import type { NextPageContext } from "next"; +import ErrorComponent from "next/error"; + +class CustomError extends ErrorComponent { + static getInitialProps({ res }: NextPageContext) { + const statusCode = res?.statusCode ?? 500; + return { statusCode, title: "CustomError" }; + } +} + +export default CustomError; +`, + ); + + const program = ts.createProgram([fixturePath, declarationPath], { + esModuleInterop: true, + jsx: ts.JsxEmit.ReactJSX, + module: ts.ModuleKind.ESNext, + moduleResolution: ts.ModuleResolutionKind.Bundler, + noEmit: true, + skipLibCheck: true, + strict: true, + target: ts.ScriptTarget.ES2022, + }); + const diagnostics = ts + .getPreEmitDiagnostics(program) + .filter((diagnostic) => diagnostic.file?.fileName.startsWith(tempDir)); + + expect( + diagnostics.map((diagnostic) => + ts.flattenDiagnosticMessageText(diagnostic.messageText, "\n"), + ), + ).toEqual([]); + } finally { + await rm(tempDir, { recursive: true, force: true }); + } + }); + it("derives the Error hostname from the server request", async () => { const ErrorComponent = (await import("../packages/vinext/src/shims/error.js")).default; expect( - ErrorComponent.getInitialProps({ - req: { url: "https://preview.example.com:8443/failure" }, - }), + ErrorComponent.getInitialProps( + asNextPageContext({ + req: { url: "https://preview.example.com:8443/failure" }, + }), + ), ).toEqual({ statusCode: 404, hostname: "preview.example.com", }); expect( - ErrorComponent.getInitialProps({ - req: { url: "/failure", headers: { host: "fallback.example.com:3000" } }, - }), + ErrorComponent.getInitialProps( + asNextPageContext({ + req: { url: "/failure", headers: { host: "fallback.example.com:3000" } }, + }), + ), ).toEqual({ statusCode: 404, hostname: "fallback.example.com", From af70356935df482984cdb34ab1a90c946f0bfb67 Mon Sep 17 00:00:00 2001 From: James Date: Wed, 8 Jul 2026 19:52:53 +0100 Subject: [PATCH 07/17] fix(pages): isolate error context types --- packages/vinext/src/shims/error.tsx | 10 ++++++++-- packages/vinext/src/shims/next-shims.d.ts | 20 +++++++++++++++++--- 2 files changed, 25 insertions(+), 5 deletions(-) diff --git a/packages/vinext/src/shims/error.tsx b/packages/vinext/src/shims/error.tsx index 7f2b7c2951..ed18f7436d 100644 --- a/packages/vinext/src/shims/error.tsx +++ b/packages/vinext/src/shims/error.tsx @@ -10,7 +10,7 @@ * `next/error`'s public surface. */ import React from "react"; -import type { NextPageContext } from "next"; +import type { IncomingMessage, ServerResponse } from "node:http"; import Head from "./head.js"; import { isNextRouterError } from "./navigation.js"; import { useUntrackedPathname } from "./internal/navigation-untracked.js"; @@ -31,7 +31,13 @@ export type ErrorProps = { withDarkMode?: boolean; }; -function getErrorInitialProps({ err, req, res }: NextPageContext): ErrorProps { +type ErrorPageContext = { + err?: (Error & { statusCode?: number }) | null; + req?: IncomingMessage; + res?: ServerResponse; +}; + +function getErrorInitialProps({ err, req, res }: ErrorPageContext): ErrorProps { const statusCode = res?.statusCode ? res.statusCode : err ? err.statusCode! : 404; let hostname: string | undefined; diff --git a/packages/vinext/src/shims/next-shims.d.ts b/packages/vinext/src/shims/next-shims.d.ts index 60f6b50dbb..5f1ff267ab 100644 --- a/packages/vinext/src/shims/next-shims.d.ts +++ b/packages/vinext/src/shims/next-shims.d.ts @@ -424,9 +424,23 @@ declare module "next/legacy/image" { } declare module "next/error" { + import type { IncomingMessage, ServerResponse } from "node:http"; + import type { ParsedUrlQuery } from "node:querystring"; import * as React from "react"; import { ComponentType, ReactNode } from "react"; - import type { NextPageContext } from "next"; + + type ErrorPageContext = { + err?: (Error & { statusCode?: number }) | null; + req?: IncomingMessage; + res?: ServerResponse; + pathname: string; + query: ParsedUrlQuery; + asPath?: string; + locale?: string; + locales?: readonly string[]; + defaultLocale?: string; + AppTree: ComponentType<{ pageProps: unknown; [name: string]: unknown }>; + }; export type ErrorProps = { statusCode: number; @@ -437,8 +451,8 @@ declare module "next/error" { export default class ErrorComponent

extends React.Component

{ static displayName: string; - static getInitialProps: (context: NextPageContext) => ErrorProps | Promise; - static origGetInitialProps: (context: NextPageContext) => ErrorProps | Promise; + static getInitialProps: (context: ErrorPageContext) => ErrorProps | Promise; + static origGetInitialProps: (context: ErrorPageContext) => ErrorProps | Promise; render(): React.ReactNode; } From 4ea704559b67b14f147ab71035753ce8c150574d Mon Sep 17 00:00:00 2001 From: James Date: Wed, 8 Jul 2026 20:11:32 +0100 Subject: [PATCH 08/17] test(pages): cover default error production hydration --- .../default-error.browser.spec.ts | 142 ++++++++++++++++++ 1 file changed, 142 insertions(+) create mode 100644 tests/e2e/pages-router-prod/default-error.browser.spec.ts diff --git a/tests/e2e/pages-router-prod/default-error.browser.spec.ts b/tests/e2e/pages-router-prod/default-error.browser.spec.ts new file mode 100644 index 0000000000..69bed88674 --- /dev/null +++ b/tests/e2e/pages-router-prod/default-error.browser.spec.ts @@ -0,0 +1,142 @@ +import fs from "node:fs/promises"; +import type { Server } from "node:http"; +import os from "node:os"; +import path from "node:path"; +import { pathToFileURL } from "node:url"; +import { test as base, expect } from "../fixtures"; +import { waitForHydration } from "../helpers"; + +type ProductionApp = { + baseUrl: string; +}; + +async function closeServer(server: Server): Promise { + const closed = new Promise((resolve) => server.close(() => resolve())); + server.closeIdleConnections(); + server.closeAllConnections(); + await closed; +} + +async function buildAndServeProductionFixture(): Promise<{ + fixtureRoot: string; + server: Server; + app: ProductionApp; +}> { + const fixtureRoot = await fs.mkdtemp(path.join(os.tmpdir(), "vinext-pages-default-error-")); + const pagesDir = path.join(fixtureRoot, "pages"); + + await fs.symlink( + path.resolve(process.cwd(), "tests/fixtures/pages-basic/node_modules"), + path.join(fixtureRoot, "node_modules"), + "junction", + ); + await fs.mkdir(pagesDir, { recursive: true }); + await fs.writeFile(path.join(fixtureRoot, "package.json"), '{"type":"module"}\n'); + await fs.writeFile( + path.join(pagesDir, "index.tsx"), + `export default function Home() { return

Home
; }\n`, + ); + await fs.writeFile( + path.join(pagesDir, "server-error.tsx"), + `export async function getServerSideProps() { + throw new Error("intentional production error"); +} + +export default function ServerError() { + return
unreachable
; +} +`, + ); + + const configFile = path.join(fixtureRoot, "vite.config.ts"); + const vinextSource = path.resolve(process.cwd(), "packages/vinext/src/index.ts"); + await fs.writeFile( + configFile, + `import { defineConfig } from "vite"; +import vinext from ${JSON.stringify(pathToFileURL(vinextSource).href)}; + +export default defineConfig({ + plugins: [vinext()], +}); +`, + ); + + const { createBuilder } = await import("vite"); + const builder = await createBuilder({ + root: fixtureRoot, + configFile, + logLevel: "silent", + }); + await builder.buildApp(); + + const { startProdServer } = await import( + pathToFileURL(path.resolve(process.cwd(), "packages/vinext/dist/server/prod-server.js")).href + ); + const started = await startProdServer({ + host: "127.0.0.1", + port: 0, + outDir: path.join(fixtureRoot, "dist"), + noCompression: true, + }); + + return { + fixtureRoot, + server: started.server, + app: { baseUrl: `http://127.0.0.1:${started.port}` }, + }; +} + +/* oxlint-disable eslint-plugin-react-hooks/rules-of-hooks -- Playwright fixture `use`, not a React hook */ +const test = base.extend<{ productionApp: ProductionApp }>({ + productionApp: async ({ page }, use) => { + const { fixtureRoot, server, app } = await buildAndServeProductionFixture(); + + try { + await use(app); + } finally { + await page.close(); + await closeServer(server); + await fs.rm(fixtureRoot, { recursive: true, force: true }); + } + }, +}); +/* oxlint-enable eslint-plugin-react-hooks/rules-of-hooks */ + +test.setTimeout(60_000); + +test.describe("Pages Router framework error page production fallback", () => { + test("renders and hydrates the framework error page without custom error files", async ({ + page, + productionApp, + consoleErrors, + }) => { + const notFoundResponse = await page.goto(`${productionApp.baseUrl}/missing`, { + waitUntil: "load", + }); + + expect(notFoundResponse?.status()).toBe(404); + await expect(page.locator("h1")).toHaveText("404"); + await expect(page.locator("h2")).toHaveText("This page could not be found."); + await expect(page).toHaveTitle("404: This page could not be found"); + await waitForHydration(page); + + const errorResponse = await page.goto(`${productionApp.baseUrl}/server-error`, { + waitUntil: "load", + }); + + expect(errorResponse?.status()).toBe(500); + await expect(page.locator("h1")).toHaveText("500"); + await expect(page.locator("h2")).toHaveText("Internal Server Error."); + await expect(page).toHaveTitle("500: Internal Server Error"); + await waitForHydration(page); + + const unexpectedErrors = consoleErrors.filter( + (message) => + !/^Failed to load resource: the server responded with a status of (404|500) \(.+\)$/.test( + message, + ), + ); + expect(unexpectedErrors).toEqual([]); + consoleErrors.length = 0; + }); +}); From 6002fcf22b158f8562172e80fb1f7df206d3b981 Mon Sep 17 00:00:00 2001 From: James Date: Wed, 8 Jul 2026 20:29:25 +0100 Subject: [PATCH 09/17] test(pages): cover default error hydration in dev --- packages/vinext/src/server/dev-server.ts | 51 ++++++- .../default-error-dev.browser.spec.ts | 130 ++++++++++++++++++ 2 files changed, 178 insertions(+), 3 deletions(-) create mode 100644 tests/e2e/pages-router-prod/default-error-dev.browser.spec.ts diff --git a/packages/vinext/src/server/dev-server.ts b/packages/vinext/src/server/dev-server.ts index 9c7ddf0c98..c1c3dbcd12 100644 --- a/packages/vinext/src/server/dev-server.ts +++ b/packages/vinext/src/server/dev-server.ts @@ -2206,20 +2206,64 @@ async function renderErrorPage( [appAssetPath, errorAssetPath], nonceAttr, ); + const errorPage = candidate === "_error" ? "/_error" : `/${candidate}`; + const errorModuleSource = errorAssetPath + ? createPagesDevModuleUrl(server.config.root, errorAssetPath, "/") + : "next/error"; + const appModuleSource = appAssetPath + ? createPagesDevModuleUrl(server.config.root, appAssetPath, "/") + : null; + const errorNextDataScript = ``; + const errorHydrationScript = ` +`; + const errorScripts = `${errorNextDataScript}\n${errorHydrationScript}`; if (DocumentComponent) { - const errorPathname = candidate === "_error" ? "/_error" : `/${candidate}`; await streamPageToResponse(res, element, { url, server, fontHeadHTML: "", assetHeadHTML, - scripts: "", + scripts: errorScripts, DocumentComponent, statusCode, documentContext: { err, - pathname: errorPathname, + pathname: errorPage, query: parseQuery(url), asPath: url, req, @@ -2254,6 +2298,7 @@ async function renderErrorPage(
${bodyHtml}
+ ${errorScripts} `; const transformedHtml = await server.transformIndexHtml(url, html); diff --git a/tests/e2e/pages-router-prod/default-error-dev.browser.spec.ts b/tests/e2e/pages-router-prod/default-error-dev.browser.spec.ts new file mode 100644 index 0000000000..2f87b25e8b --- /dev/null +++ b/tests/e2e/pages-router-prod/default-error-dev.browser.spec.ts @@ -0,0 +1,130 @@ +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { pathToFileURL } from "node:url"; +import type { ViteDevServer } from "vite"; +import { test as base, expect } from "../fixtures"; +import { waitForHydration } from "../helpers"; + +type DevelopmentApp = { + baseUrl: string; +}; + +async function createDevelopmentFixture(): Promise<{ + fixtureRoot: string; + server: ViteDevServer; + app: DevelopmentApp; +}> { + const fixtureRoot = await fs.mkdtemp(path.join(os.tmpdir(), "vinext-pages-default-error-dev-")); + const pagesDir = path.join(fixtureRoot, "pages"); + + await fs.symlink( + path.resolve(process.cwd(), "tests/fixtures/pages-basic/node_modules"), + path.join(fixtureRoot, "node_modules"), + "junction", + ); + await fs.mkdir(pagesDir, { recursive: true }); + await fs.writeFile(path.join(fixtureRoot, "package.json"), '{"type":"module"}\n'); + await fs.writeFile( + path.join(pagesDir, "_app.tsx"), + `import { useEffect, useState } from "react"; + +export default function App({ Component, pageProps, customEnvelope }) { + const [hydrated, setHydrated] = useState(false); + useEffect(() => setHydrated(true), []); + + return ( +
+ +
+ ); +} + +App.getInitialProps = async () => ({ customEnvelope: "preserved" }); +`, + ); + await fs.writeFile( + path.join(pagesDir, "index.tsx"), + `export default function Home() { return
Home
; }\n`, + ); + + const configFile = path.join(fixtureRoot, "vite.config.ts"); + const vinextSource = path.resolve(process.cwd(), "packages/vinext/src/index.ts"); + await fs.writeFile( + configFile, + `import { defineConfig } from "vite"; +import vinext from ${JSON.stringify(pathToFileURL(vinextSource).href)}; + +export default defineConfig({ + plugins: [vinext()], +}); +`, + ); + + const { createServer } = await import("vite"); + const server = await createServer({ + root: fixtureRoot, + configFile, + logLevel: "silent", + server: { host: "127.0.0.1", port: 0 }, + }); + await server.listen(); + + const baseUrl = server.resolvedUrls?.local[0]; + if (!baseUrl) { + await server.close(); + await fs.rm(fixtureRoot, { recursive: true, force: true }); + throw new Error("Vite dev server did not expose a local URL"); + } + + return { fixtureRoot, server, app: { baseUrl } }; +} + +/* oxlint-disable eslint-plugin-react-hooks/rules-of-hooks -- Playwright fixture `use`, not a React hook */ +const test = base.extend<{ developmentApp: DevelopmentApp }>({ + developmentApp: async ({ page }, use) => { + const { fixtureRoot, server, app } = await createDevelopmentFixture(); + + try { + await use(app); + } finally { + await page.close(); + await server.close(); + await fs.rm(fixtureRoot, { recursive: true, force: true }); + } + }, +}); +/* oxlint-enable eslint-plugin-react-hooks/rules-of-hooks */ + +test.setTimeout(30_000); + +test.describe("Pages Router framework error page development fallback", () => { + test("preserves a custom app envelope without pageProps", async ({ + page, + developmentApp, + consoleErrors, + }) => { + const response = await page.goto(`${developmentApp.baseUrl}missing`, { waitUntil: "load" }); + + expect(response?.status()).toBe(404); + await expect(page.locator("h1")).toHaveCount(0); + await expect(page.locator("h2")).toHaveText( + "Application error: a client-side exception has occurred (see the browser console for more information).", + ); + await expect(page.getByTestId("custom-app")).toHaveAttribute("data-envelope", "preserved"); + await waitForHydration(page); + await expect(page.getByTestId("custom-app")).toHaveAttribute("data-hydrated", "true"); + + const nextData = await page.evaluate(() => window.__NEXT_DATA__); + expect(nextData.props).toEqual({ customEnvelope: "preserved" }); + + const unexpectedErrors = consoleErrors.filter( + (message) => + !/^Failed to load resource: the server responded with a status of 404 \(.+\)$/.test( + message, + ), + ); + expect(unexpectedErrors).toEqual([]); + consoleErrors.length = 0; + }); +}); From 76fbeb9f4d2990600c31d91d3181843e7ecd6063 Mon Sep 17 00:00:00 2001 From: James Date: Wed, 8 Jul 2026 20:38:43 +0100 Subject: [PATCH 10/17] fix(pages): hydrate dev error responses --- packages/vinext/src/server/dev-server.ts | 82 ++++++++++++++----- .../default-error-dev.browser.spec.ts | 9 +- 2 files changed, 67 insertions(+), 24 deletions(-) diff --git a/packages/vinext/src/server/dev-server.ts b/packages/vinext/src/server/dev-server.ts index c1c3dbcd12..66bc3cfbca 100644 --- a/packages/vinext/src/server/dev-server.ts +++ b/packages/vinext/src/server/dev-server.ts @@ -720,7 +720,19 @@ export function createSSRHandler( return; } // No route matched — try to render custom 404 page - await renderErrorPage(server, runner, req, res, url, pagesDir, 404, undefined, matcher); + await renderErrorPage( + server, + runner, + req, + res, + url, + pagesDir, + 404, + undefined, + matcher, + undefined, + reactStrictMode, + ); return; } @@ -918,6 +930,8 @@ export function createSSRHandler( 404, routerShim.wrapWithRouterContext, matcher, + undefined, + reactStrictMode, ); return; } @@ -1075,6 +1089,9 @@ export function createSSRHandler( pagesDir, 404, routerShim.wrapWithRouterContext, + undefined, + undefined, + reactStrictMode, ); return; } @@ -1527,6 +1544,9 @@ export function createSSRHandler( pagesDir, 404, routerShim.wrapWithRouterContext, + undefined, + undefined, + reactStrictMode, ); return; } @@ -2050,6 +2070,7 @@ hydrate(); undefined, matcher, e instanceof Error ? e : new Error(String(e)), + reactStrictMode, ); } catch (fallbackErr) { // If error page itself fails, fall back to plain text. @@ -2084,6 +2105,7 @@ async function renderErrorPage( wrapWithRouterContext?: ((el: React.ReactElement) => React.ReactElement) | null, fileMatcher?: ValidFileMatcher, err?: Error, + reactStrictMode = false, ): Promise { attachPagesRequestCookies(req); const matcher = fileMatcher ?? createValidFileMatcher(); @@ -2116,12 +2138,18 @@ async function renderErrorPage( const createElement = React.createElement; res.statusCode = statusCode; + const errorPage = candidate === "_error" ? "/_error" : `/${candidate}`; + const errorRouter = { + pathname: errorPage, + query: parseQuery(url), + asPath: url, + }; const initialErrorProps = await loadPagesGetInitialProps(ErrorComponent, { req, res, err, - pathname: candidate === "_error" ? "/_error" : `/${candidate}`, - query: parseQuery(url), + pathname: errorPage, + query: errorRouter.query, asPath: url, }); if (res.headersSent || res.writableEnded) return; @@ -2135,17 +2163,13 @@ async function renderErrorPage( Component: ErrorComponent, }), Component: ErrorComponent, - router: { - pathname: candidate === "_error" ? "/_error" : `/${candidate}`, - query: parseQuery(url), - asPath: url, - }, + router: errorRouter, ctx: { req, res, err, - pathname: candidate === "_error" ? "/_error" : `/${candidate}`, - query: parseQuery(url), + pathname: errorPage, + query: errorRouter.query, asPath: url, }, }); @@ -2156,13 +2180,11 @@ async function renderErrorPage( // If the caller didn't supply wrapWithRouterContext, load it now. // runner.import() caches internally so the cost is negligible. let wrapFn = wrapWithRouterContext; - if (!wrapFn) { - try { - const errRouterShim = await importModule(runner, "next/router"); - wrapFn = errRouterShim.wrapWithRouterContext; - } catch { - // router shim not available — continue without it - } + try { + const errRouterShim = await importModule(runner, "next/router"); + wrapFn ??= errRouterShim.wrapWithRouterContext; + } catch { + // router shim not available — continue without it } // Try custom _document @@ -2188,6 +2210,7 @@ async function renderErrorPage( ? createElement(FinalApp, { ...renderProps, Component: FinalComponent, + router: errorRouter, }) : createElement(FinalComponent, errorProps); if (wrapFn) errorElement = wrapFn(errorElement); @@ -2206,7 +2229,6 @@ async function renderErrorPage( [appAssetPath, errorAssetPath], nonceAttr, ); - const errorPage = candidate === "_error" ? "/_error" : `/${candidate}`; const errorModuleSource = errorAssetPath ? createPagesDevModuleUrl(server.config.root, errorAssetPath, "/") : "next/error"; @@ -2226,11 +2248,17 @@ async function renderErrorPage( `; const errorScripts = `${errorNextDataScript}\n${errorHydrationScript}`; diff --git a/tests/e2e/pages-router-prod/default-error-dev.browser.spec.ts b/tests/e2e/pages-router-prod/default-error-dev.browser.spec.ts index 2f87b25e8b..d5aec569a5 100644 --- a/tests/e2e/pages-router-prod/default-error-dev.browser.spec.ts +++ b/tests/e2e/pages-router-prod/default-error-dev.browser.spec.ts @@ -29,12 +29,12 @@ async function createDevelopmentFixture(): Promise<{ path.join(pagesDir, "_app.tsx"), `import { useEffect, useState } from "react"; -export default function App({ Component, pageProps, customEnvelope }) { +export default function App({ Component, pageProps, customEnvelope, router }) { const [hydrated, setHydrated] = useState(false); useEffect(() => setHydrated(true), []); return ( -
+
); @@ -107,11 +107,12 @@ test.describe("Pages Router framework error page development fallback", () => { const response = await page.goto(`${developmentApp.baseUrl}missing`, { waitUntil: "load" }); expect(response?.status()).toBe(404); - await expect(page.locator("h1")).toHaveCount(0); - await expect(page.locator("h2")).toHaveText( + await expect(page.locator("#__next h1")).toHaveCount(0); + await expect(page.locator("#__next h2")).toHaveText( "Application error: a client-side exception has occurred (see the browser console for more information).", ); await expect(page.getByTestId("custom-app")).toHaveAttribute("data-envelope", "preserved"); + await expect(page.getByTestId("custom-app")).toHaveAttribute("data-has-router", "true"); await waitForHydration(page); await expect(page.getByTestId("custom-app")).toHaveAttribute("data-hydrated", "true"); From 02abf13ff6d650346642810553f6659ccb581ace Mon Sep 17 00:00:00 2001 From: James Date: Wed, 8 Jul 2026 20:43:57 +0100 Subject: [PATCH 11/17] fix(pages): set dev error router context --- packages/vinext/src/server/dev-server.ts | 24 ++++++++++++++----- .../default-error-dev.browser.spec.ts | 21 +++++++++++++++- 2 files changed, 38 insertions(+), 7 deletions(-) diff --git a/packages/vinext/src/server/dev-server.ts b/packages/vinext/src/server/dev-server.ts index 66bc3cfbca..ba084293e7 100644 --- a/packages/vinext/src/server/dev-server.ts +++ b/packages/vinext/src/server/dev-server.ts @@ -2114,6 +2114,8 @@ async function renderErrorPage( statusCode === 404 ? ["404", "_error"] : statusCode === 500 ? ["500", "_error"] : ["_error"]; for (const candidate of candidates) { + // oxlint-disable-next-line typescript/no-explicit-any + let errorRouterShim: any = null; try { const errorAssetPath = findFileWithExts(pagesDir, candidate, matcher); if (!errorAssetPath && candidate !== "_error") continue; @@ -2144,6 +2146,17 @@ async function renderErrorPage( query: parseQuery(url), asPath: url, }; + try { + errorRouterShim = await importModule(runner, "next/router"); + if (typeof errorRouterShim.setSSRContext === "function") { + errorRouterShim.setSSRContext({ + ...errorRouter, + navigationIsReady: true, + }); + } + } catch { + // router shim not available — continue without it + } const initialErrorProps = await loadPagesGetInitialProps(ErrorComponent, { req, res, @@ -2180,12 +2193,7 @@ async function renderErrorPage( // If the caller didn't supply wrapWithRouterContext, load it now. // runner.import() caches internally so the cost is negligible. let wrapFn = wrapWithRouterContext; - try { - const errRouterShim = await importModule(runner, "next/router"); - wrapFn ??= errRouterShim.wrapWithRouterContext; - } catch { - // router shim not available — continue without it - } + wrapFn ??= errorRouterShim?.wrapWithRouterContext; // Try custom _document // oxlint-disable-next-line typescript/no-explicit-any @@ -2352,6 +2360,10 @@ window.__NEXT_HYDRATED_CB?.(); if (res.headersSent || res.writableEnded) return; // This candidate doesn't exist, try next continue; + } finally { + if (typeof errorRouterShim?.setSSRContext === "function") { + errorRouterShim.setSSRContext(null); + } } } diff --git a/tests/e2e/pages-router-prod/default-error-dev.browser.spec.ts b/tests/e2e/pages-router-prod/default-error-dev.browser.spec.ts index d5aec569a5..4b120862c0 100644 --- a/tests/e2e/pages-router-prod/default-error-dev.browser.spec.ts +++ b/tests/e2e/pages-router-prod/default-error-dev.browser.spec.ts @@ -28,13 +28,23 @@ async function createDevelopmentFixture(): Promise<{ await fs.writeFile( path.join(pagesDir, "_app.tsx"), `import { useEffect, useState } from "react"; +import { useRouter } from "next/router"; export default function App({ Component, pageProps, customEnvelope, router }) { const [hydrated, setHydrated] = useState(false); + const contextRouter = useRouter(); useEffect(() => setHydrated(true), []); return ( -
+
); @@ -113,6 +123,15 @@ test.describe("Pages Router framework error page development fallback", () => { ); await expect(page.getByTestId("custom-app")).toHaveAttribute("data-envelope", "preserved"); await expect(page.getByTestId("custom-app")).toHaveAttribute("data-has-router", "true"); + await expect(page.getByTestId("custom-app")).toHaveAttribute("data-router-pathname", "/_error"); + await expect(page.getByTestId("custom-app")).toHaveAttribute( + "data-context-pathname", + "/_error", + ); + await expect(page.getByTestId("custom-app")).toHaveAttribute( + "data-context-as-path", + "/missing", + ); await waitForHydration(page); await expect(page.getByTestId("custom-app")).toHaveAttribute("data-hydrated", "true"); From 5473cf5e72c8a57bf21a9785251b8bc918a4013c Mon Sep 17 00:00:00 2001 From: James Date: Wed, 8 Jul 2026 20:49:57 +0100 Subject: [PATCH 12/17] fix(pages): isolate dev error router state --- packages/vinext/src/server/dev-server.ts | 50 ++++++++++--------- .../default-error-dev.browser.spec.ts | 25 +++++++++- 2 files changed, 51 insertions(+), 24 deletions(-) diff --git a/packages/vinext/src/server/dev-server.ts b/packages/vinext/src/server/dev-server.ts index ba084293e7..47030099e4 100644 --- a/packages/vinext/src/server/dev-server.ts +++ b/packages/vinext/src/server/dev-server.ts @@ -720,19 +720,23 @@ export function createSSRHandler( return; } // No route matched — try to render custom 404 page - await renderErrorPage( - server, - runner, - req, - res, - url, - pagesDir, - 404, - undefined, - matcher, - undefined, - reactStrictMode, - ); + const requestContext = createRequestContext(); + await runWithRequestContext(requestContext, async () => { + await _alsRegistration; + await renderErrorPage( + server, + runner, + req, + res, + url, + pagesDir, + 404, + undefined, + matcher, + undefined, + reactStrictMode, + ); + }); return; } @@ -2157,6 +2161,8 @@ async function renderErrorPage( } catch { // router shim not available — continue without it } + const serverRouter = errorRouterShim?.default ?? errorRouter; + const wrapFn = wrapWithRouterContext ?? errorRouterShim?.wrapWithRouterContext; const initialErrorProps = await loadPagesGetInitialProps(ErrorComponent, { req, res, @@ -2170,13 +2176,16 @@ async function renderErrorPage( let renderProps: Record = { pageProps: errorProps }; if (AppComponent && hasPagesGetInitialProps(AppComponent)) { const appInitialProps = await loadPagesGetInitialProps(AppComponent, { - AppTree: (appTreeProps: Record) => - createElement(AppComponent, { + AppTree: (appTreeProps: Record) => { + const appTree = createElement(AppComponent, { ...appTreeProps, Component: ErrorComponent, - }), + router: serverRouter, + }); + return wrapFn ? wrapFn(appTree) : appTree; + }, Component: ErrorComponent, - router: errorRouter, + router: serverRouter, ctx: { req, res, @@ -2190,11 +2199,6 @@ async function renderErrorPage( if (appInitialProps) renderProps = appInitialProps; } - // If the caller didn't supply wrapWithRouterContext, load it now. - // runner.import() caches internally so the cost is negligible. - let wrapFn = wrapWithRouterContext; - wrapFn ??= errorRouterShim?.wrapWithRouterContext; - // Try custom _document // oxlint-disable-next-line typescript/no-explicit-any let DocumentComponent: any = null; @@ -2218,7 +2222,7 @@ async function renderErrorPage( ? createElement(FinalApp, { ...renderProps, Component: FinalComponent, - router: errorRouter, + router: serverRouter, }) : createElement(FinalComponent, errorProps); if (wrapFn) errorElement = wrapFn(errorElement); diff --git a/tests/e2e/pages-router-prod/default-error-dev.browser.spec.ts b/tests/e2e/pages-router-prod/default-error-dev.browser.spec.ts index 4b120862c0..9429ca0dfd 100644 --- a/tests/e2e/pages-router-prod/default-error-dev.browser.spec.ts +++ b/tests/e2e/pages-router-prod/default-error-dev.browser.spec.ts @@ -28,6 +28,7 @@ async function createDevelopmentFixture(): Promise<{ await fs.writeFile( path.join(pagesDir, "_app.tsx"), `import { useEffect, useState } from "react"; +import { renderToStaticMarkup } from "react-dom/server"; import { useRouter } from "next/router"; export default function App({ Component, pageProps, customEnvelope, router }) { @@ -50,7 +51,17 @@ export default function App({ Component, pageProps, customEnvelope, router }) { ); } -App.getInitialProps = async () => ({ customEnvelope: "preserved" }); +App.getInitialProps = async ({ AppTree, ctx, router }) => { + if (ctx.asPath === "/missing-a") await new Promise((resolve) => setTimeout(resolve, 50)); + const appTreeHtml = renderToStaticMarkup(); + const appTreeHasRouter = appTreeHtml.includes('data-router-pathname="/_error"') + && appTreeHtml.includes('data-context-as-path="' + ctx.asPath + '"'); + return { + customEnvelope: router.route === "/_error" && router.isReady === true && appTreeHasRouter + ? "preserved" + : "broken", + }; +}; `, ); await fs.writeFile( @@ -147,4 +158,16 @@ test.describe("Pages Router framework error page development fallback", () => { expect(unexpectedErrors).toEqual([]); consoleErrors.length = 0; }); + + test("isolates router context across concurrent missing routes", async ({ developmentApp }) => { + const [first, second] = await Promise.all([ + fetch(`${developmentApp.baseUrl}missing-a`).then((response) => response.text()), + fetch(`${developmentApp.baseUrl}missing-b`).then((response) => response.text()), + ]); + + expect(first).toContain('data-context-as-path="/missing-a"'); + expect(second).toContain('data-context-as-path="/missing-b"'); + expect(first).toContain('data-envelope="preserved"'); + expect(second).toContain('data-envelope="preserved"'); + }); }); From b786df456ce5d1f1d9d604516e44f772c9184ad5 Mon Sep 17 00:00:00 2001 From: James Date: Wed, 8 Jul 2026 20:52:42 +0100 Subject: [PATCH 13/17] fix(pages): align dev error router hydration --- packages/vinext/src/server/dev-server.ts | 3 ++- tests/e2e/pages-router-prod/default-error-dev.browser.spec.ts | 3 +++ 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/packages/vinext/src/server/dev-server.ts b/packages/vinext/src/server/dev-server.ts index 47030099e4..b819a2b664 100644 --- a/packages/vinext/src/server/dev-server.ts +++ b/packages/vinext/src/server/dev-server.ts @@ -2279,7 +2279,8 @@ ${ ? `const appModule = await import("${appModuleSource}"); const AppComponent = appModule.default; window.__VINEXT_APP__ = AppComponent; -element = React.createElement(AppComponent, { ...props, Component: PageComponent, pageProps: props.pageProps, router: Router });` +const initialRouter = { ...Router, isReady: true }; +element = React.createElement(AppComponent, { ...props, Component: PageComponent, pageProps: props.pageProps, router: initialRouter });` : `element = React.createElement(PageComponent, props.pageProps ?? {});` } let resolveHydrationCommit; diff --git a/tests/e2e/pages-router-prod/default-error-dev.browser.spec.ts b/tests/e2e/pages-router-prod/default-error-dev.browser.spec.ts index 9429ca0dfd..fba6e09aae 100644 --- a/tests/e2e/pages-router-prod/default-error-dev.browser.spec.ts +++ b/tests/e2e/pages-router-prod/default-error-dev.browser.spec.ts @@ -43,6 +43,7 @@ export default function App({ Component, pageProps, customEnvelope, router }) { data-hydrated={hydrated} data-has-router={Boolean(router)} data-router-pathname={router?.pathname} + data-router-ready={String(router?.isReady)} data-context-pathname={contextRouter.pathname} data-context-as-path={contextRouter.asPath} > @@ -135,6 +136,7 @@ test.describe("Pages Router framework error page development fallback", () => { await expect(page.getByTestId("custom-app")).toHaveAttribute("data-envelope", "preserved"); await expect(page.getByTestId("custom-app")).toHaveAttribute("data-has-router", "true"); await expect(page.getByTestId("custom-app")).toHaveAttribute("data-router-pathname", "/_error"); + await expect(page.getByTestId("custom-app")).toHaveAttribute("data-router-ready", "true"); await expect(page.getByTestId("custom-app")).toHaveAttribute( "data-context-pathname", "/_error", @@ -145,6 +147,7 @@ test.describe("Pages Router framework error page development fallback", () => { ); await waitForHydration(page); await expect(page.getByTestId("custom-app")).toHaveAttribute("data-hydrated", "true"); + await expect(page.getByTestId("custom-app")).toHaveAttribute("data-router-ready", "true"); const nextData = await page.evaluate(() => window.__NEXT_DATA__); expect(nextData.props).toEqual({ customEnvelope: "preserved" }); From 02ce04952bb7459b9de743e479d865825dd8df3e Mon Sep 17 00:00:00 2001 From: James Date: Wed, 8 Jul 2026 20:56:03 +0100 Subject: [PATCH 14/17] fix(pages): keep error router ready during hydration --- packages/vinext/src/server/dev-server.ts | 2 +- packages/vinext/src/shims/router.ts | 13 +++++++------ .../default-error-dev.browser.spec.ts | 9 +++++++-- 3 files changed, 15 insertions(+), 9 deletions(-) diff --git a/packages/vinext/src/server/dev-server.ts b/packages/vinext/src/server/dev-server.ts index b819a2b664..5ff78c2412 100644 --- a/packages/vinext/src/server/dev-server.ts +++ b/packages/vinext/src/server/dev-server.ts @@ -2266,7 +2266,7 @@ import Router, { wrapWithRouterContext, _initializePagesRouterReadyFromNextData const nextDataElement = document.getElementById("__NEXT_DATA__"); window.__NEXT_DATA__ = JSON.parse(nextDataElement.textContent); const props = window.__NEXT_DATA__.props; -_initializePagesRouterReadyFromNextData(window.__NEXT_DATA__); +_initializePagesRouterReadyFromNextData(window.__NEXT_DATA__, true); window.__VINEXT_PAGE_LOADERS__ = { [window.__NEXT_DATA__.page]: () => import("${errorModuleSource}") }; window.__VINEXT_PAGE_PATTERNS__ = [window.__NEXT_DATA__.page]; window.__VINEXT_APP_LOADER__ = ${appModuleSource ? `() => import("${appModuleSource}")` : "undefined"}; diff --git a/packages/vinext/src/shims/router.ts b/packages/vinext/src/shims/router.ts index de3478a1fb..80756d0e62 100644 --- a/packages/vinext/src/shims/router.ts +++ b/packages/vinext/src/shims/router.ts @@ -1366,13 +1366,14 @@ function markPagesRouterReady(): boolean { return true; } -function initializePagesRouterReadyFromNextData(nextData: VinextNextData): void { +function initializePagesRouterReadyFromNextData( + nextData: VinextNextData, + forceReady = false, +): void { if (typeof window === "undefined") return; - routerRuntimeState.pagesRouterReady = getPagesNavigationIsReadyFromSerializedState( - nextData.page, - window.location.search, - nextData, - ); + routerRuntimeState.pagesRouterReady = + forceReady || + getPagesNavigationIsReadyFromSerializedState(nextData.page, window.location.search, nextData); } function markPagesRouterHydrated(): void { diff --git a/tests/e2e/pages-router-prod/default-error-dev.browser.spec.ts b/tests/e2e/pages-router-prod/default-error-dev.browser.spec.ts index fba6e09aae..0dfa5815b6 100644 --- a/tests/e2e/pages-router-prod/default-error-dev.browser.spec.ts +++ b/tests/e2e/pages-router-prod/default-error-dev.browser.spec.ts @@ -46,6 +46,7 @@ export default function App({ Component, pageProps, customEnvelope, router }) { data-router-ready={String(router?.isReady)} data-context-pathname={contextRouter.pathname} data-context-as-path={contextRouter.asPath} + data-context-ready={String(contextRouter.isReady)} >
@@ -126,7 +127,9 @@ test.describe("Pages Router framework error page development fallback", () => { developmentApp, consoleErrors, }) => { - const response = await page.goto(`${developmentApp.baseUrl}missing`, { waitUntil: "load" }); + const response = await page.goto(`${developmentApp.baseUrl}missing?x=1`, { + waitUntil: "load", + }); expect(response?.status()).toBe(404); await expect(page.locator("#__next h1")).toHaveCount(0); @@ -143,11 +146,13 @@ test.describe("Pages Router framework error page development fallback", () => { ); await expect(page.getByTestId("custom-app")).toHaveAttribute( "data-context-as-path", - "/missing", + "/missing?x=1", ); + await expect(page.getByTestId("custom-app")).toHaveAttribute("data-context-ready", "true"); await waitForHydration(page); await expect(page.getByTestId("custom-app")).toHaveAttribute("data-hydrated", "true"); await expect(page.getByTestId("custom-app")).toHaveAttribute("data-router-ready", "true"); + await expect(page.getByTestId("custom-app")).toHaveAttribute("data-context-ready", "true"); const nextData = await page.evaluate(() => window.__NEXT_DATA__); expect(nextData.props).toEqual({ customEnvelope: "preserved" }); From 42344826268001b74b46aa5f6e95441b75f3648a Mon Sep 17 00:00:00 2001 From: James Date: Wed, 8 Jul 2026 21:03:19 +0100 Subject: [PATCH 15/17] fix(pages): clean up error fallback handling --- .../vinext/src/entries/pages-server-entry.ts | 18 ++++++++---------- packages/vinext/src/server/dev-server.ts | 13 ++++++------- 2 files changed, 14 insertions(+), 17 deletions(-) diff --git a/packages/vinext/src/entries/pages-server-entry.ts b/packages/vinext/src/entries/pages-server-entry.ts index 104d7faa01..e4bcc8d70e 100644 --- a/packages/vinext/src/entries/pages-server-entry.ts +++ b/packages/vinext/src/entries/pages-server-entry.ts @@ -301,16 +301,14 @@ export const pageRoutes = [ ${pageRouteEntries.join(",\n")} ]; const _pageRouteTrie = _buildRouteTrie(pageRoutes); -const _errorPageRoute = ErrorPageModule - ? { - pattern: "/_error", - patternParts: ["_error"], - isDynamic: false, - params: [], - module: ErrorPageModule, - filePath: ${errorAssetPathJson}, - } - : null; +const _errorPageRoute = { + pattern: "/_error", + patternParts: ["_error"], + isDynamic: false, + params: [], + module: ErrorPageModule, + filePath: ${errorAssetPathJson}, +}; const apiRoutes = [ ${apiRouteEntries.join(",\n")} diff --git a/packages/vinext/src/server/dev-server.ts b/packages/vinext/src/server/dev-server.ts index 5ff78c2412..783c8a0d40 100644 --- a/packages/vinext/src/server/dev-server.ts +++ b/packages/vinext/src/server/dev-server.ts @@ -2173,7 +2173,7 @@ async function renderErrorPage( }); if (res.headersSent || res.writableEnded) return; const errorProps = { ...initialErrorProps, statusCode }; - let renderProps: Record = { pageProps: errorProps }; + let renderProps: Record; if (AppComponent && hasPagesGetInitialProps(AppComponent)) { const appInitialProps = await loadPagesGetInitialProps(AppComponent, { AppTree: (appTreeProps: Record) => { @@ -2196,7 +2196,9 @@ async function renderErrorPage( }, }); if (res.headersSent || res.writableEnded) return; - if (appInitialProps) renderProps = appInitialProps; + renderProps = appInitialProps ?? {}; + } else { + renderProps = { pageProps: errorProps }; } // Try custom _document @@ -2372,11 +2374,8 @@ window.__NEXT_HYDRATED_CB?.(); } } - // No custom error page found — fall back to vinext's default. The 404 case - // renders the canonical Next.js HTML body (matching `pages/_error.tsx`) so - // dev-server responses include "This page could not be found." just like - // production. Other status codes keep the plain-text fallback because - // Next.js's `_error.tsx` defaults already handle those cases when present. + // Defensive fallback for a missing or invalid framework error module. The + // normal no-user-error-file path resolves `next/error` in the candidate loop. if (statusCode === 404) { const defaultResponse = buildDefaultPagesNotFoundResponse(); const headers: Record = {}; From 67d074f18fc22d849a2e6b44f9a8f5fba06f6ca4 Mon Sep 17 00:00:00 2001 From: James Date: Thu, 9 Jul 2026 10:45:20 +0100 Subject: [PATCH 16/17] fix(pages): load custom error page on navigation --- .../vinext/src/entries/pages-client-entry.ts | 9 +- .../custom-error-navigation.browser.spec.ts | 125 ++++++++++++++++++ tests/entry-templates.test.ts | 29 ++++ 3 files changed, 160 insertions(+), 3 deletions(-) create mode 100644 tests/e2e/pages-router-prod/custom-error-navigation.browser.spec.ts diff --git a/packages/vinext/src/entries/pages-client-entry.ts b/packages/vinext/src/entries/pages-client-entry.ts index b06d7c6778..bd91e808c8 100644 --- a/packages/vinext/src/entries/pages-client-entry.ts +++ b/packages/vinext/src/entries/pages-client-entry.ts @@ -65,6 +65,7 @@ export async function generateClientEntry( const apiRoutes = await apiRouter(pagesDir, nextConfig?.pageExtensions, fileMatcher); const appFilePath = findFileWithExts(pagesDir, "_app", fileMatcher); + const errorFilePath = findFileWithExts(pagesDir, "_error", fileMatcher); const hasApp = appFilePath !== null; const appPrefetchRoutes = options.appPrefetchRoutes ?? []; const pagesPrefetchRoutes: VinextPagesLinkPrefetchRoute[] = [ @@ -102,9 +103,11 @@ export async function generateClientEntry( // lgtm[js/bad-code-sanitization] return ` ${JSON.stringify(nextFormatPattern)}: () => import(${JSON.stringify(absPath)})`; }); - if (!pageRoutes.some((route) => route.pattern === "/_error")) { - loaderEntries.push(' "/_error": () => import("next/error")'); - } + loaderEntries.push( + errorFilePath !== null + ? ` "/_error": () => import(${JSON.stringify(errorFilePath)})` + : ' "/_error": () => import("next/error")', + ); const appFileBase = appFilePath ?? undefined; diff --git a/tests/e2e/pages-router-prod/custom-error-navigation.browser.spec.ts b/tests/e2e/pages-router-prod/custom-error-navigation.browser.spec.ts new file mode 100644 index 0000000000..e0a3402c7d --- /dev/null +++ b/tests/e2e/pages-router-prod/custom-error-navigation.browser.spec.ts @@ -0,0 +1,125 @@ +import fs from "node:fs/promises"; +import type { Server } from "node:http"; +import os from "node:os"; +import path from "node:path"; +import { pathToFileURL } from "node:url"; +import { test as base, expect } from "../fixtures"; +import { waitForHydration } from "../helpers"; + +type ProductionApp = { + baseUrl: string; +}; + +async function closeServer(server: Server): Promise { + const closed = new Promise((resolve) => server.close(() => resolve())); + server.closeIdleConnections(); + server.closeAllConnections(); + await closed; +} + +async function buildAndServeProductionFixture(): Promise<{ + fixtureRoot: string; + server: Server; + app: ProductionApp; +}> { + const fixtureRoot = await fs.mkdtemp(path.join(os.tmpdir(), "vinext-pages-custom-error-")); + const pagesDir = path.join(fixtureRoot, "pages"); + + await fs.symlink( + path.resolve(process.cwd(), "tests/fixtures/pages-basic/node_modules"), + path.join(fixtureRoot, "node_modules"), + "junction", + ); + await fs.mkdir(pagesDir, { recursive: true }); + await fs.writeFile(path.join(fixtureRoot, "package.json"), '{"type":"module"}\n'); + await fs.writeFile( + path.join(pagesDir, "index.tsx"), + `import Link from "next/link"; + +export default function Home() { + return Missing; +} +`, + ); + await fs.writeFile( + path.join(pagesDir, "_error.tsx"), + `export default function CustomError({ statusCode }) { + return
Custom error {statusCode}
; +} + +CustomError.getInitialProps = ({ res, err }) => ({ + statusCode: res?.statusCode ?? err?.statusCode ?? 404, +}); +`, + ); + + const configFile = path.join(fixtureRoot, "vite.config.ts"); + const vinextSource = path.resolve(process.cwd(), "packages/vinext/src/index.ts"); + await fs.writeFile( + configFile, + `import { defineConfig } from "vite"; +import vinext from ${JSON.stringify(pathToFileURL(vinextSource).href)}; + +export default defineConfig({ + plugins: [vinext()], +}); +`, + ); + + const { createBuilder } = await import("vite"); + const builder = await createBuilder({ + root: fixtureRoot, + configFile, + logLevel: "silent", + }); + await builder.buildApp(); + + const { startProdServer } = await import( + pathToFileURL(path.resolve(process.cwd(), "packages/vinext/dist/server/prod-server.js")).href + ); + const started = await startProdServer({ + host: "127.0.0.1", + port: 0, + outDir: path.join(fixtureRoot, "dist"), + noCompression: true, + }); + + return { + fixtureRoot, + server: started.server, + app: { baseUrl: `http://127.0.0.1:${started.port}` }, + }; +} + +/* oxlint-disable eslint-plugin-react-hooks/rules-of-hooks -- Playwright fixture `use`, not a React hook */ +const test = base.extend<{ productionApp: ProductionApp }>({ + productionApp: async ({ page }, use) => { + const { fixtureRoot, server, app } = await buildAndServeProductionFixture(); + + try { + await use(app); + } finally { + await page.close(); + await closeServer(server); + await fs.rm(fixtureRoot, { recursive: true, force: true }); + } + }, +}); +/* oxlint-enable eslint-plugin-react-hooks/rules-of-hooks */ + +test.setTimeout(60_000); + +// Ported from Next.js: test/e2e/no-page-props/no-page-props.test.ts +// https://github.com/vercel/next.js/blob/v16.3.0-canary.80/test/e2e/no-page-props/no-page-props.test.ts +test("client navigation loads the custom pages/_error component", async ({ + page, + productionApp, +}) => { + await page.goto(productionApp.baseUrl, { waitUntil: "load" }); + await waitForHydration(page); + + await page.getByTestId("missing-link").click(); + + await expect(page.getByTestId("custom-error")).toHaveText("Custom error 404"); + await expect(page).toHaveURL(`${productionApp.baseUrl}/missing`); +}); diff --git a/tests/entry-templates.test.ts b/tests/entry-templates.test.ts index 8e7a3c4992..e16b1b5bf3 100644 --- a/tests/entry-templates.test.ts +++ b/tests/entry-templates.test.ts @@ -1212,6 +1212,35 @@ describe("Pages Router entry template", () => { } }); + // Ported from Next.js: test/e2e/no-page-props/no-page-props.test.ts + // https://github.com/vercel/next.js/blob/v16.3.0-canary.80/test/e2e/no-page-props/no-page-props.test.ts + it("uses a custom error page in the client entry across configured page extensions", async () => { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "vinext-pages-custom-error-entry-")); + const pagesDir = path.join(tmpDir, "pages"); + + try { + fs.mkdirSync(pagesDir, { recursive: true }); + fs.writeFileSync( + path.join(pagesDir, "index.tsx"), + "export default function Page() { return null; }", + ); + const errorFilePath = path.join(pagesDir, "_error.page.tsx"); + fs.writeFileSync(errorFilePath, "export default function ErrorPage() { return null; }"); + + const nextConfig = await resolveNextConfig({ pageExtensions: ["page.tsx", "tsx"] }); + const clientCode = await generateClientEntry( + pagesDir, + nextConfig, + createValidFileMatcher(nextConfig.pageExtensions), + ); + + expect(clientCode).toContain(`"/_error": () => import(${JSON.stringify(errorFilePath)})`); + expect(clientCode).not.toContain('"/_error": () => import("next/error")'); + } finally { + fs.rmSync(tmpDir, { recursive: true, force: true }); + } + }); + // Refs #1474: Pages Router client entry must import the user's // `instrumentation-client.ts` (at the project root) as a side-effect import // before calling `hydrateRoot()`. Mirrors Next.js's `page-bootstrap.ts` From 79074bfe5c8e2c2b905aa53fd98e0345e5756d92 Mon Sep 17 00:00:00 2001 From: James Date: Thu, 9 Jul 2026 16:09:39 +0100 Subject: [PATCH 17/17] refactor(pages): share dev hydration entry --- packages/vinext/src/server/dev-server.ts | 143 +++--------------- .../vinext/src/server/pages-dev-hydration.ts | 104 +++++++++++++ tests/pages-dev-hydration.test.ts | 52 +++++++ 3 files changed, 173 insertions(+), 126 deletions(-) create mode 100644 packages/vinext/src/server/pages-dev-hydration.ts create mode 100644 tests/pages-dev-hydration.test.ts diff --git a/packages/vinext/src/server/dev-server.ts b/packages/vinext/src/server/dev-server.ts index 572471fcbb..eda6106883 100644 --- a/packages/vinext/src/server/dev-server.ts +++ b/packages/vinext/src/server/dev-server.ts @@ -69,6 +69,7 @@ import { type PagesStaticPathsEntry, } from "./pages-page-data.js"; import { createPagesDevAssetUrl, createPagesDevModuleUrl } from "./pages-dev-module-url.js"; +import { createPagesDevHydrationScript } from "./pages-dev-hydration.js"; import { getManifestFilesForModule } from "./pages-asset-tags.js"; import { isSerializableProps } from "./pages-serializable-props.js"; import { @@ -1856,83 +1857,13 @@ export function createSSRHandler( }, }; - // Hydration entry: inline script that imports the page and hydrates. - // Stores the React root and page loader for client-side navigation. - const hydrationScript = ` -`; + const hydrationScript = createPagesDevHydrationScript({ + appModuleSource, + pageModuleSource, + reactStrictMode: reactStrictMode === true, + replaceFallbackRoute: true, + scriptNonce, + }); const nextDataScript = ``; - const errorHydrationScript = ` -`; + const errorHydrationScript = createPagesDevHydrationScript({ + appModuleSource, + forceRouterReady: true, + normalizePageProps: false, + pageModuleSource: errorModuleSource, + reactStrictMode: reactStrictMode === true, + scriptNonce, + setPagePatternsFromNextData: true, + }); const errorScripts = `${errorNextDataScript}\n${errorHydrationScript}`; if (DocumentComponent) { diff --git a/packages/vinext/src/server/pages-dev-hydration.ts b/packages/vinext/src/server/pages-dev-hydration.ts new file mode 100644 index 0000000000..cfbf6c5f98 --- /dev/null +++ b/packages/vinext/src/server/pages-dev-hydration.ts @@ -0,0 +1,104 @@ +import { createNonceAttribute } from "./html.js"; + +export type PagesDevHydrationOptions = { + appModuleSource: string | null; + forceRouterReady?: boolean; + normalizePageProps?: boolean; + pageModuleSource: string; + reactStrictMode: boolean; + replaceFallbackRoute?: boolean; + scriptNonce?: string; + setPagePatternsFromNextData?: boolean; +}; + +export function createPagesDevHydrationScript(options: PagesDevHydrationOptions): string { + const nonceAttr = createNonceAttribute(options.scriptNonce); + const initializeRouter = options.forceRouterReady + ? "_initializePagesRouterReadyFromNextData(nextData, true);" + : "_initializePagesRouterReadyFromNextData(nextData);"; + const pagePatterns = options.setPagePatternsFromNextData + ? "window.__VINEXT_PAGE_PATTERNS__ = [nextData.page];" + : ""; + const pageProps = + options.normalizePageProps === false + ? "const pageProps = rawPageProps ?? {};" + : 'const pageProps = rawPageProps && typeof rawPageProps === "object" ? rawPageProps : {};'; + const fallbackReplacement = options.replaceFallbackRoute + ? ` + if (nextData.isFallback) { + await Router.replace(window.location.pathname + window.location.search + window.location.hash, undefined, { _h: 1, scroll: false }); + }` + : ""; + const createElement = options.appModuleSource + ? ` + const appModule = await import(${JSON.stringify(options.appModuleSource)}); + const AppComponent = appModule.default; + window.__VINEXT_APP__ = AppComponent; + const appRouter = ${options.forceRouterReady ? "{ ...Router, isReady: true }" : "Router"}; + element = React.createElement(AppComponent, { + ...props, + Component: PageComponent, + pageProps: rawPageProps, + router: appRouter, + }); + ` + : ` + element = React.createElement(PageComponent, pageProps); + `; + + return ` +`; +} diff --git a/tests/pages-dev-hydration.test.ts b/tests/pages-dev-hydration.test.ts new file mode 100644 index 0000000000..68396b159e --- /dev/null +++ b/tests/pages-dev-hydration.test.ts @@ -0,0 +1,52 @@ +import { describe, expect, it } from "vite-plus/test"; +import { createPagesDevHydrationScript } from "../packages/vinext/src/server/pages-dev-hydration.js"; + +describe("createPagesDevHydrationScript", () => { + it("generates the normal Pages Router hydration entry", () => { + const script = createPagesDevHydrationScript({ + appModuleSource: "/pages/_app.tsx", + pageModuleSource: "/pages/index.tsx", + reactStrictMode: true, + replaceFallbackRoute: true, + scriptNonce: "nonce-value", + }); + + expect(script).toContain('