Skip to content
Merged
Show file tree
Hide file tree
Changes from 7 commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
1f5abf4
fix(pages): preserve missing page props on errors
james-elicx Jul 8, 2026
36ed1ff
fix(pages): complete default error props contract
james-elicx Jul 8, 2026
8635f98
fix(pages): match default error component contract
james-elicx Jul 8, 2026
d863fe3
fix(pages): complete next error parity
james-elicx Jul 8, 2026
438a17a
fix(pages): make next error subclassable
james-elicx Jul 8, 2026
de064d8
fix(pages): align next error context types
james-elicx Jul 8, 2026
af70356
fix(pages): isolate error context types
james-elicx Jul 8, 2026
4ea7045
test(pages): cover default error production hydration
james-elicx Jul 8, 2026
6002fcf
test(pages): cover default error hydration in dev
james-elicx Jul 8, 2026
76fbeb9
fix(pages): hydrate dev error responses
james-elicx Jul 8, 2026
02abf13
fix(pages): set dev error router context
james-elicx Jul 8, 2026
5473cf5
fix(pages): isolate dev error router state
james-elicx Jul 8, 2026
b786df4
fix(pages): align dev error router hydration
james-elicx Jul 8, 2026
02ce049
fix(pages): keep error router ready during hydration
james-elicx Jul 8, 2026
4234482
fix(pages): clean up error fallback handling
james-elicx Jul 8, 2026
8876103
Merge remote-tracking branch 'origin/main' into codex/fix-pages-no-pa…
james-elicx Jul 9, 2026
67d074f
fix(pages): load custom error page on navigation
james-elicx Jul 9, 2026
375d9d8
Merge remote-tracking branch 'origin/main' into codex/fix-pages-no-pa…
james-elicx Jul 9, 2026
79074bf
refactor(pages): share dev hydration entry
james-elicx Jul 9, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions packages/vinext/src/entries/pages-client-entry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down
2 changes: 1 addition & 1 deletion packages/vinext/src/entries/pages-server-entry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
33 changes: 30 additions & 3 deletions packages/vinext/src/server/dev-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand All @@ -2115,6 +2115,7 @@ async function renderErrorPage(
}

const createElement = React.createElement;
res.statusCode = statusCode;
const initialErrorProps = await loadPagesGetInitialProps(ErrorComponent, {
req,
res,
Expand All @@ -2125,6 +2126,32 @@ async function renderErrorPage(
});
if (res.headersSent || res.writableEnded) return;
const errorProps = { ...initialErrorProps, statusCode };
let renderProps: Record<string, unknown> = { pageProps: errorProps };
if (AppComponent && hasPagesGetInitialProps(AppComponent)) {
const appInitialProps = await loadPagesGetInitialProps(AppComponent, {
AppTree: (appTreeProps: Record<string, unknown>) =>
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.
Expand Down Expand Up @@ -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);
Expand Down
15 changes: 12 additions & 3 deletions packages/vinext/src/server/pages-page-handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down Expand Up @@ -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 };
}
Expand Down
156 changes: 108 additions & 48 deletions packages/vinext/src/shims/error.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,74 +10,134 @@
* `next/error`'s public surface.
*/
import React from "react";
import type { IncomingMessage, ServerResponse } from "node:http";
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 = {
const statusCodes: Record<number, string> = {
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;
};

function ErrorComponent({ statusCode, title }: ErrorProps): React.ReactElement {
const defaultTitle =
statusCode === 404 ? "This page could not be found" : "Internal Server Error";
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;

const displayTitle = title ?? defaultTitle;
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.
}
}

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",
},
},
React.createElement(
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 };
}

const styles: Record<string, React.CSSProperties> = {
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" },
};

class ErrorComponent<P = {}> extends React.Component<P & ErrorProps> {
static displayName = "ErrorPage";
static getInitialProps = getErrorInitialProps;
static origGetInitialProps = getErrorInitialProps;

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",
null,
{ style: styles.error },
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,
Head,
null,
React.createElement(
"title",
null,
statusCode
? `${statusCode}: ${title}`
: "Application error: a client-side exception has occurred",
),
),
React.createElement(
"div",
{ style: { display: "inline-block" } },
React.createElement(
"h2",
{
style: {
fontSize: 14,
fontWeight: 400,
lineHeight: "49px",
margin: 0,
},
{ 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)}}"
: ""
}`,
},
displayTitle + ".",
}),
statusCode
? React.createElement("h1", { className: "next-error-h1", style: styles.h1 }, statusCode)
: null,
React.createElement(
"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).`,
),
),
),
),
);
);
}
}

export default ErrorComponent;
Expand Down
41 changes: 38 additions & 3 deletions packages/vinext/src/shims/next-shims.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string | string[]>;
body: unknown;
Expand Down Expand Up @@ -410,16 +424,37 @@ 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";

type ErrorProps = {
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;
hostname?: string;
title?: string;
withDarkMode?: boolean;
};

const ErrorComponent: ComponentType<ErrorProps>;
export default ErrorComponent;
export default class ErrorComponent<P = {}> extends React.Component<P & ErrorProps> {
static displayName: string;
static getInitialProps: (context: ErrorPageContext) => ErrorProps | Promise<ErrorProps>;
static origGetInitialProps: (context: ErrorPageContext) => ErrorProps | Promise<ErrorProps>;
render(): React.ReactNode;
}

export type ErrorInfo = {
error: unknown;
Expand Down
25 changes: 25 additions & 0 deletions tests/entry-templates.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand Down
Loading
Loading