Skip to content
Merged
Show file tree
Hide file tree
Changes from 17 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
6 changes: 6 additions & 0 deletions packages/vinext/src/entries/pages-client-entry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,7 @@
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[] = [
Expand Down Expand Up @@ -102,6 +103,11 @@
// lgtm[js/bad-code-sanitization]
return ` ${JSON.stringify(nextFormatPattern)}: () => import(${JSON.stringify(absPath)})`;
});
loaderEntries.push(
errorFilePath !== null
? ` "/_error": () => import(${JSON.stringify(errorFilePath)})`

Check warning

Code scanning / CodeQL

Improper code sanitization Medium

Code construction depends on an
improperly sanitized value
.
Comment thread
james-elicx marked this conversation as resolved.
Dismissed
: ' "/_error": () => import("next/error")',
);

const appFileBase = appFilePath ?? undefined;

Expand Down
20 changes: 9 additions & 11 deletions 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 Expand Up @@ -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")}
Expand Down
180 changes: 155 additions & 25 deletions packages/vinext/src/server/dev-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -720,7 +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);
const requestContext = createRequestContext();
await runWithRequestContext(requestContext, async () => {
await _alsRegistration;
await renderErrorPage(
server,
runner,
req,
res,
url,
pagesDir,
404,
undefined,
matcher,
undefined,
reactStrictMode,
);
});
return;
}

Expand Down Expand Up @@ -918,6 +934,8 @@ export function createSSRHandler(
404,
routerShim.wrapWithRouterContext,
matcher,
undefined,
reactStrictMode,
);
return;
}
Expand Down Expand Up @@ -1075,6 +1093,9 @@ export function createSSRHandler(
pagesDir,
404,
routerShim.wrapWithRouterContext,
undefined,
undefined,
reactStrictMode,
);
return;
}
Expand Down Expand Up @@ -1527,6 +1548,9 @@ export function createSSRHandler(
pagesDir,
404,
routerShim.wrapWithRouterContext,
undefined,
undefined,
reactStrictMode,
);
return;
}
Expand Down Expand Up @@ -2050,6 +2074,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.
Expand Down Expand Up @@ -2084,6 +2109,7 @@ async function renderErrorPage(
wrapWithRouterContext?: ((el: React.ReactElement) => React.ReactElement) | null,
fileMatcher?: ValidFileMatcher,
err?: Error,
reactStrictMode = false,
): Promise<void> {
attachPagesRequestCookies(req);
const matcher = fileMatcher ?? createValidFileMatcher();
Expand All @@ -2092,11 +2118,13 @@ 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) 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,27 +2143,62 @@ 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,
};
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 serverRouter = errorRouterShim?.default ?? errorRouter;
const wrapFn = wrapWithRouterContext ?? errorRouterShim?.wrapWithRouterContext;
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;
const errorProps = { ...initialErrorProps, statusCode };

// 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
}
let renderProps: Record<string, unknown>;
if (AppComponent && hasPagesGetInitialProps(AppComponent)) {
const appInitialProps = await loadPagesGetInitialProps(AppComponent, {
AppTree: (appTreeProps: Record<string, unknown>) => {
const appTree = createElement(AppComponent, {
...appTreeProps,
Component: ErrorComponent,
router: serverRouter,
});
return wrapFn ? wrapFn(appTree) : appTree;
},
Component: ErrorComponent,
router: serverRouter,
ctx: {
req,
res,
err,
pathname: errorPage,
query: errorRouter.query,
asPath: url,
},
});
if (res.headersSent || res.writableEnded) return;
renderProps = appInitialProps ?? {};
} else {
renderProps = { pageProps: errorProps };
}

// Try custom _document
Expand All @@ -2159,8 +2222,9 @@ async function renderErrorPage(
): React.ReactElement => {
let errorElement: React.ReactElement = FinalApp
? createElement(FinalApp, {
...renderProps,
Component: FinalComponent,
pageProps: errorProps,
router: serverRouter,
})
: createElement(FinalComponent, errorProps);
if (wrapFn) errorElement = wrapFn(errorElement);
Expand All @@ -2179,20 +2243,84 @@ async function renderErrorPage(
[appAssetPath, errorAssetPath],
nonceAttr,
);
const errorModuleSource = errorAssetPath
? createPagesDevModuleUrl(server.config.root, errorAssetPath, "/")
: "next/error";
const appModuleSource = appAssetPath
? createPagesDevModuleUrl(server.config.root, appAssetPath, "/")
: null;
const errorNextDataScript = `<script id="__NEXT_DATA__" type="application/json"${nonceAttr}>${safeJsonStringify(
{
props: renderProps,
page: errorPage,
query: parseQuery(url),
buildId: process.env.__VINEXT_BUILD_ID,
isFallback: false,
},
)}</script>`;
const errorHydrationScript = `
<script type="module"${nonceAttr}>
import React from "react";
import { hydrateRoot } from "react-dom/client";
import "vinext/instrumentation-client";
import Router, { wrapWithRouterContext, _initializePagesRouterReadyFromNextData } from "next/router";

const nextDataElement = document.getElementById("__NEXT_DATA__");
window.__NEXT_DATA__ = JSON.parse(nextDataElement.textContent);
const props = window.__NEXT_DATA__.props;
_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"};
window.__VINEXT_REACT_STRICT_MODE__ = ${JSON.stringify(reactStrictMode === true)};
const pageModule = await import("${errorModuleSource}");
const PageComponent = pageModule.default;
let element;
${
appModuleSource
? `const appModule = await import("${appModuleSource}");
const AppComponent = appModule.default;
window.__VINEXT_APP__ = AppComponent;
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;
const hydrationCommitted = new Promise((resolve) => { resolveHydrationCommit = resolve; });
element = wrapWithRouterContext(element, resolveHydrationCommit);
let hydrateRootOptions;
if (import.meta.env.DEV) {
const overlay = await import("vinext/dev-error-overlay");
overlay.installDevErrorOverlay();
overlay.installViteHmrErrorHandler(import.meta.hot);
overlay.reportInitialDevServerErrors();
hydrateRootOptions = {
onCaughtError: overlay.devOnCaughtError,
onUncaughtError: overlay.devOnUncaughtError,
};
}
window.__VINEXT_ROOT__ = hydrateRoot(document.getElementById("__next"), element, hydrateRootOptions);
await hydrationCommitted;
const hydratedAt = performance.now();
window.__VINEXT_HYDRATED_AT = hydratedAt;
window.__NEXT_HYDRATED = true;
window.__NEXT_HYDRATED_AT = hydratedAt;
window.__NEXT_HYDRATED_CB?.();
</script>`;
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,
Expand Down Expand Up @@ -2227,6 +2355,7 @@ async function renderErrorPage(
</head>
<body>
<div id="__next">${bodyHtml}</div>
${errorScripts}
</body>
</html>`;
const transformedHtml = await server.transformIndexHtml(url, html);
Expand All @@ -2238,14 +2367,15 @@ async function renderErrorPage(
if (res.headersSent || res.writableEnded) return;
// This candidate doesn't exist, try next
continue;
} finally {
if (typeof errorRouterShim?.setSSRContext === "function") {
errorRouterShim.setSSRContext(null);
}
}
}

// 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<string, string> = {};
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
Loading
Loading