diff --git a/packages/vinext/src/build/prerender.ts b/packages/vinext/src/build/prerender.ts
index 10ed20a20..0609943a8 100644
--- a/packages/vinext/src/build/prerender.ts
+++ b/packages/vinext/src/build/prerender.ts
@@ -608,6 +608,10 @@ export async function prerenderPages({
// Skip internal pages (_app, _document, _error, etc.)
const routeName = path.basename(route.filePath, path.extname(route.filePath));
if (routeName.startsWith("_")) continue;
+ // `/404` is rendered by the dedicated 404 block below. Production serves
+ // it with a 404 status, so the generic static-page loop must not treat
+ // that non-2xx response as a prerender failure.
+ if (route.pattern === "/404") continue;
// Cross-reference with file-system route scan.
const fsRoute = routes.find(
@@ -765,14 +769,13 @@ export async function prerenderPages({
results.push(...pageResults);
// ── Render 404 page ───────────────────────────────────────────────────
- const has404 =
- findFileWithExtensions(path.join(pagesDir, "404"), fileMatcher) ||
- findFileWithExtensions(path.join(pagesDir, "_error"), fileMatcher);
- if (has404) {
+ const hasCustom404 = findFileWithExtensions(path.join(pagesDir, "404"), fileMatcher);
+ const hasErrorPage = findFileWithExtensions(path.join(pagesDir, "_error"), fileMatcher);
+ if (hasCustom404 || hasErrorPage) {
try {
- const notFoundRes = await renderPage(NOT_FOUND_SENTINEL_PATH);
+ const notFoundRes = await renderPage(hasCustom404 ? "/404" : NOT_FOUND_SENTINEL_PATH);
const contentType = notFoundRes.headers.get("content-type") ?? "";
- if (contentType.includes("text/html")) {
+ if (notFoundRes.status === 404 && contentType.includes("text/html")) {
const html404 = await notFoundRes.text();
const fullPath = path.join(outDir, "404.html");
fs.writeFileSync(fullPath, html404, "utf-8");
diff --git a/packages/vinext/src/deploy.ts b/packages/vinext/src/deploy.ts
index da89af6f8..260667346 100644
--- a/packages/vinext/src/deploy.ts
+++ b/packages/vinext/src/deploy.ts
@@ -883,10 +883,23 @@ export default {
// ── 9. Page routes ────────────────────────────────────────────
let response: Response | undefined;
if (typeof renderPage === "function") {
- response = await renderPage(request, resolvedUrl, null, ctx);
+ const renderPageMatch =
+ typeof matchPageRoute === "function" ? matchPageRoute(resolvedPathname, request) : null;
+ const shouldDeferErrorPageOnMiss =
+ !isDataRequest && typeof matchPageRoute === "function" && !renderPageMatch;
+ const initialRenderOptions = shouldDeferErrorPageOnMiss
+ ? { renderErrorPageOnMiss: false }
+ : undefined;
+ response = await renderPage(request, resolvedUrl, null, ctx, undefined, initialRenderOptions);
// ── 10. Fallback rewrites (if SSR returned 404) ─────────────
- if (response && response.status === 404 && configRewrites.fallback?.length) {
+ let matchedFallbackRewrite = false;
+ if (
+ response &&
+ response.status === 404 &&
+ shouldDeferErrorPageOnMiss &&
+ configRewrites.fallback?.length
+ ) {
const fallbackRewrite = matchRewrite(
matchResolvedPathname(resolvedPathname),
configRewrites.fallback,
@@ -897,6 +910,7 @@ export default {
if (isExternalUrl(fallbackRewrite)) {
return proxyExternalRequest(request, fallbackRewrite);
}
+ matchedFallbackRewrite = true;
response = await renderPage(
request,
mergeRewriteQuery(resolvedUrl, fallbackRewrite),
@@ -905,6 +919,14 @@ export default {
);
}
}
+ if (
+ response &&
+ response.status === 404 &&
+ shouldDeferErrorPageOnMiss &&
+ !matchedFallbackRewrite
+ ) {
+ response = await renderPage(request, resolvedUrl, null, ctx);
+ }
}
if (!response) {
diff --git a/packages/vinext/src/entries/pages-server-entry.ts b/packages/vinext/src/entries/pages-server-entry.ts
index c6bef2a9c..417c644da 100644
--- a/packages/vinext/src/entries/pages-server-entry.ts
+++ b/packages/vinext/src/entries/pages-server-entry.ts
@@ -67,9 +67,10 @@ export async function generateServerEntry(
` { pattern: ${JSON.stringify(r.pattern)}, patternParts: ${JSON.stringify(r.patternParts)}, isDynamic: ${r.isDynamic}, params: ${JSON.stringify(r.params)}, module: api_${i} }`,
);
- // Check for _app and _document
+ // Check for _app, _document, and _error.
const appFilePath = findFileWithExts(pagesDir, "_app", fileMatcher);
const docFilePath = findFileWithExts(pagesDir, "_document", fileMatcher);
+ const errorFilePath = findFileWithExts(pagesDir, "_error", fileMatcher);
// Embed the resolved _app path (or null) so the runtime can look it up
// in the SSR manifest and include any CSS/JS chunks `_app` brings in
// (e.g. global stylesheets imported by `_app.tsx`) alongside the page's
@@ -87,6 +88,13 @@ export async function generateServerEntry(
? `import { default as DocumentComponent } from ${JSON.stringify(normalizePathSeparators(docFilePath))};`
: `const DocumentComponent = null;`;
+ const errorAssetPathJson =
+ errorFilePath !== null ? JSON.stringify(normalizePathSeparators(errorFilePath)) : "null";
+ const errorImportCode =
+ errorFilePath !== null
+ ? `import * as ErrorPageModule from ${JSON.stringify(normalizePathSeparators(errorFilePath))};`
+ : `const ErrorPageModule = null;`;
+
// Serialize i18n config for embedding in the server entry
const i18nConfigJson = nextConfig?.i18n
? JSON.stringify({
@@ -335,11 +343,22 @@ ${apiImports.join("\n")}
${appImportCode}
${docImportCode}
+${errorImportCode}
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 apiRoutes = [
${apiRouteEntries.join(",\n")}
@@ -399,11 +418,29 @@ function patternToNextFormat(pattern) {
.replace(/:([^\\/]+?)(?=\\/|$)/g, "[$1]");
}
-function collectAssetTags(manifest, moduleIds, scriptNonce) {
+function resolveSsrManifest(manifest) {
// Fall back to embedded manifest (set by vinext:cloudflare-build for Workers)
- const m = (manifest && Object.keys(manifest).length > 0)
+ return (manifest && Object.keys(manifest).length > 0)
? manifest
: (typeof globalThis !== "undefined" && globalThis.__VINEXT_SSR_MANIFEST__) || null;
+}
+
+function getManifestFilesForModule(manifest, moduleId) {
+ if (!manifest || !moduleId) return null;
+
+ var files = manifest[moduleId];
+ if (files) return files;
+
+ for (var key in manifest) {
+ if (moduleId.endsWith("/" + key) || moduleId === key) {
+ return manifest[key];
+ }
+ }
+ return null;
+}
+
+function collectAssetTags(manifest, moduleIds, scriptNonce) {
+ const m = resolveSsrManifest(manifest);
const tags = [];
const seen = new Set();
const nonceAttr = __createNonceAttribute(scriptNonce);
@@ -436,18 +473,7 @@ function collectAssetTags(manifest, moduleIds, scriptNonce) {
// Collect assets for the requested page modules
for (var mi = 0; mi < moduleIds.length; mi++) {
var id = moduleIds[mi];
- var files = m[id];
- if (!files) {
- // Absolute path didn't match — try matching by suffix.
- // Manifest keys are relative (e.g. "pages/about.tsx") while
- // moduleIds may be absolute (e.g. "/home/.../pages/about.tsx").
- for (var mk in m) {
- if (id.endsWith("/" + mk) || id === mk) {
- files = m[mk];
- break;
- }
- }
- }
+ var files = getManifestFilesForModule(m, id);
if (files) {
for (var fi = 0; fi < files.length; fi++) allFiles.push(files[fi]);
}
@@ -508,6 +534,18 @@ function collectAssetTags(manifest, moduleIds, scriptNonce) {
return tags.join("\\n ");
}
+function resolveClientModuleUrl(manifest, moduleId) {
+ const files = getManifestFilesForModule(resolveSsrManifest(manifest), moduleId);
+ if (!files) return undefined;
+ for (var i = 0; i < files.length; i++) {
+ var file = files[i];
+ if (!file || !file.endsWith(".js")) continue;
+ if (file.charAt(0) !== "/") file = "/" + file;
+ return file;
+ }
+ return undefined;
+}
+
export async function renderPage(request, url, manifest, ctx, middlewareHeaders, options) {
if (ctx) return _runWithExecutionContext(ctx, () => _renderPage(request, url, manifest, middlewareHeaders, options));
return _renderPage(request, url, manifest, middlewareHeaders, options);
@@ -530,6 +568,9 @@ async function _renderPage(request, url, manifest, middlewareHeaders, options) {
}
}
}
+ const statusCode = options && typeof options.statusCode === "number" ? options.statusCode : undefined;
+ const asPath = options && typeof options.asPath === "string" ? options.asPath : undefined;
+ const renderErrorPageOnMiss = !(options && options.renderErrorPageOnMiss === false);
const localeInfo = i18nConfig
? resolvePagesI18nRequest(
url,
@@ -551,13 +592,31 @@ async function _renderPage(request, url, manifest, middlewareHeaders, options) {
return new Response(null, { status: 307, headers: { Location: localeInfo.redirectUrl } });
}
- const match = matchRoute(routeUrl, pageRoutes);
+ let match = matchRoute(routeUrl, pageRoutes);
+ let renderStatusCodeOverride = statusCode;
+ let renderAsPath = asPath;
if (!match) {
if (isDataReq) {
return __buildNextDataNotFoundResponse();
}
- return new Response("
404 - Page not found
",
- { status: 404, headers: { "Content-Type": "text/html" } });
+ if (!renderErrorPageOnMiss) {
+ return new Response("404 - Page not found
",
+ { status: 404, headers: { "Content-Type": "text/html" } });
+ }
+ const notFoundMatch = matchRoute("/404", pageRoutes);
+ // matchRoute may match a catch-all (e.g. [...slug]) — only use the explicit pages/404 route.
+ if (notFoundMatch && notFoundMatch.route.pattern === "/404") {
+ match = notFoundMatch;
+ renderStatusCodeOverride = 404;
+ renderAsPath = routeUrl;
+ } else if (_errorPageRoute) {
+ match = { route: _errorPageRoute, params: {} };
+ renderStatusCodeOverride = 404;
+ renderAsPath = routeUrl;
+ } else {
+ return new Response("404 - Page not found
",
+ { status: 404, headers: { "Content-Type": "text/html" } });
+ }
}
const { route, params } = match;
@@ -568,12 +627,13 @@ async function _renderPage(request, url, manifest, middlewareHeaders, options) {
ensureFetchPatch();
try {
const routePattern = patternToNextFormat(route.pattern);
+ const renderStatusCode = renderStatusCodeOverride ?? (routePattern === "/404" ? 404 : undefined);
const query = mergeRouteParamsIntoQuery(parseQuery(routeUrl), params);
if (typeof setSSRContext === "function") {
setSSRContext({
pathname: routePattern,
query,
- asPath: routeUrl,
+ asPath: renderAsPath || routeUrl,
locale: locale,
locales: i18nConfig ? i18nConfig.locales : undefined,
defaultLocale: currentDefaultLocale,
@@ -596,6 +656,8 @@ async function _renderPage(request, url, manifest, middlewareHeaders, options) {
if (!PageComponent) {
return new Response("Page has no default export", { status: 500 });
}
+ const pageModuleUrl = resolveClientModuleUrl(manifest, route.filePath);
+ const appModuleUrl = resolveClientModuleUrl(manifest, _appAssetPath);
const scriptNonce = __getScriptNonceFromHeaderSources(request.headers, middlewareHeaders);
// Build font Link header early so it's available for ISR cached responses too.
// Font preloads are module-level state populated at import time and persist across requests.
@@ -616,7 +678,7 @@ async function _renderPage(request, url, manifest, middlewareHeaders, options) {
setSSRContext({
pathname: routePattern,
query,
- asPath: routeUrl,
+ asPath: renderAsPath || routeUrl,
locale: locale,
locales: i18nConfig ? i18nConfig.locales : undefined,
defaultLocale: currentDefaultLocale,
@@ -675,12 +737,20 @@ async function _renderPage(request, url, manifest, middlewareHeaders, options) {
safeJsonStringify,
sanitizeDestination: sanitizeDestinationLocal,
scriptNonce,
+ statusCode: renderStatusCode,
triggerBackgroundRegeneration,
+ vinext: {
+ pageModuleUrl,
+ appModuleUrl,
+ },
});
if (pageDataResult.kind === "response") {
return pageDataResult.response;
}
let pageProps = pageDataResult.pageProps;
+ if (routePattern === "/_error" && typeof renderStatusCode === "number") {
+ pageProps = { ...pageProps, statusCode: renderStatusCode };
+ }
var gsspRes = pageDataResult.gsspRes;
let isrRevalidateSeconds = pageDataResult.isrRevalidateSeconds;
const isFallbackRender = pageDataResult.isFallback === true;
@@ -693,7 +763,7 @@ async function _renderPage(request, url, manifest, middlewareHeaders, options) {
setSSRContext({
pathname: routePattern,
query,
- asPath: routeUrl,
+ asPath: renderAsPath || routeUrl,
locale: locale,
locales: i18nConfig ? i18nConfig.locales : undefined,
defaultLocale: currentDefaultLocale,
@@ -795,6 +865,11 @@ async function _renderPage(request, url, manifest, middlewareHeaders, options) {
routeUrl,
safeJsonStringify,
scriptNonce,
+ statusCode: renderStatusCode,
+ vinext: {
+ pageModuleUrl,
+ appModuleUrl,
+ },
});
} catch (e) {
console.error("[vinext] SSR error:", e);
diff --git a/packages/vinext/src/server/pages-page-data.ts b/packages/vinext/src/server/pages-page-data.ts
index c41ca451a..faefc1bef 100644
--- a/packages/vinext/src/server/pages-page-data.ts
+++ b/packages/vinext/src/server/pages-page-data.ts
@@ -1,4 +1,5 @@
import type { ReactNode } from "react";
+import type { VinextNextData } from "../client/vinext-next-data.js";
import type { Route } from "../routing/pages-router.js";
import { normalizeStaticPathname } from "../routing/route-pattern.js";
import type { CachedPagesValue, CacheControlMetadata } from "vinext/shims/cache";
@@ -93,6 +94,7 @@ type RenderPagesIsrHtmlOptions = {
renderIsrPassToStringAsync: (element: ReactNode) => Promise;
routePattern: string;
safeJsonStringify: (value: unknown) => string;
+ vinext?: VinextNextData["__vinext"];
};
export type ResolvePagesPageDataOptions = {
@@ -131,12 +133,14 @@ export type ResolvePagesPageDataOptions = {
safeJsonStringify: (value: unknown) => string;
sanitizeDestination: (destination: string) => string;
scriptNonce?: string;
+ statusCode?: number;
triggerBackgroundRegeneration: (
key: string,
renderFn: () => Promise,
errorContext?: { routerKind: "Pages Router"; routePath: string; routeType: "render" },
) => void;
renderIsrPassToStringAsync: (element: ReactNode) => Promise;
+ vinext?: VinextNextData["__vinext"];
};
type ResolvePagesPageDataRenderResult = {
@@ -226,6 +230,7 @@ function buildPagesCacheResponse(
revalidateSeconds?: number,
expireSeconds?: number,
cacheControl?: CacheControlMetadata,
+ status?: number,
): Response {
// Legacy cache entries written before cacheControl metadata existed can still
// hit this path without a persisted revalidate value; keep the historic
@@ -248,7 +253,7 @@ function buildPagesCacheResponse(
}
return new Response(html, {
- status: 200,
+ status: status ?? 200,
headers,
});
}
@@ -297,6 +302,7 @@ export async function renderPagesIsrHtml(options: RenderPagesIsrHtmlOptions): Pr
params: options.params,
routePattern: options.routePattern,
safeJsonStringify: options.safeJsonStringify,
+ vinext: options.vinext,
});
return rewritePagesCachedHtml(options.cachedHtml, freshBody, nextDataScript);
@@ -437,6 +443,7 @@ export async function resolvePagesPageData(
undefined,
options.expireSeconds,
cached.value.cacheControl,
+ cachedValue.status,
),
};
}
@@ -475,11 +482,12 @@ export async function resolvePagesPageData(
renderIsrPassToStringAsync: options.renderIsrPassToStringAsync,
routePattern: options.routePattern,
safeJsonStringify: options.safeJsonStringify,
+ vinext: options.vinext,
});
await options.isrSet(
cacheKey,
- buildPagesCacheValue(freshHtml, freshResult.props),
+ buildPagesCacheValue(freshHtml, freshResult.props, options.statusCode),
freshResult.revalidate,
undefined,
options.expireSeconds,
@@ -503,6 +511,7 @@ export async function resolvePagesPageData(
undefined,
options.expireSeconds,
cached.value.cacheControl,
+ cachedValue.status,
),
};
}
diff --git a/packages/vinext/src/server/pages-page-response.ts b/packages/vinext/src/server/pages-page-response.ts
index 45bb50921..01e6f0526 100644
--- a/packages/vinext/src/server/pages-page-response.ts
+++ b/packages/vinext/src/server/pages-page-response.ts
@@ -70,6 +70,8 @@ type RenderPagesPageResponseOptions = {
routeUrl: string;
safeJsonStringify: (value: unknown) => string;
scriptNonce?: string;
+ statusCode?: number;
+ vinext?: VinextNextData["__vinext"];
};
function buildPagesFontHeadHtml(
@@ -239,6 +241,7 @@ function schedulePagesIsrCacheWrite(options: {
routePattern: string;
shellPrefix: string;
shellSuffix: string;
+ status: number;
stream: ReadableStream;
setCache: RenderPagesPageResponseOptions["isrSet"];
}): void {
@@ -251,7 +254,7 @@ function schedulePagesIsrCacheWrite(options: {
html: options.shellPrefix + bodyHtml + options.shellSuffix,
pageData: options.pageData,
headers: undefined,
- status: undefined,
+ status: options.status,
},
options.revalidateSeconds,
undefined,
@@ -265,9 +268,13 @@ function schedulePagesIsrCacheWrite(options: {
getRequestExecutionContext()?.waitUntil(cacheWritePromise);
}
-function applyGsspHeaders(headers: Headers, gsspRes: PagesGsspResponse | null): number {
+function applyGsspHeaders(
+ headers: Headers,
+ gsspRes: PagesGsspResponse | null,
+ statusCode?: number,
+): number {
if (!gsspRes) {
- return 200;
+ return statusCode ?? 200;
}
const gsspHeaders = gsspRes.getHeaders();
@@ -289,7 +296,7 @@ function applyGsspHeaders(headers: Headers, gsspRes: PagesGsspResponse | null):
}
}
headers.set("Content-Type", "text/html");
- return gsspRes.statusCode;
+ return statusCode ?? gsspRes.statusCode;
}
export async function renderPagesPageResponse(
@@ -318,6 +325,7 @@ export async function renderPagesPageResponse(
routePattern: options.routePattern,
safeJsonStringify: options.safeJsonStringify,
scriptNonce: options.scriptNonce,
+ vinext: options.vinext,
});
const bodyMarker = "";
// Render the page FIRST so that and other SSR state collectors
@@ -339,6 +347,8 @@ export async function renderPagesPageResponse(
const markerIndex = shellHtml.indexOf(bodyMarker);
const shellPrefix = shellHtml.slice(0, markerIndex);
const shellSuffix = shellHtml.slice(markerIndex + bodyMarker.length);
+ const responseHeaders = new Headers({ "Content-Type": "text/html" });
+ const finalStatus = applyGsspHeaders(responseHeaders, options.gsspRes, options.statusCode);
let responseBodyStream = bodyStream;
if (
@@ -363,6 +373,7 @@ export async function renderPagesPageResponse(
setCache: options.isrSet,
shellPrefix,
shellSuffix,
+ status: finalStatus,
stream: cacheBodyStream,
});
}
@@ -373,9 +384,6 @@ export async function renderPagesPageResponse(
shellSuffix,
);
- const responseHeaders = new Headers({ "Content-Type": "text/html" });
- const finalStatus = applyGsspHeaders(responseHeaders, options.gsspRes);
-
if (options.scriptNonce) {
responseHeaders.set("Cache-Control", "no-store, must-revalidate");
} else if (options.isrRevalidateSeconds) {
diff --git a/packages/vinext/src/server/prod-server.ts b/packages/vinext/src/server/prod-server.ts
index 3d77a8dba..2eb3d0c6f 100644
--- a/packages/vinext/src/server/prod-server.ts
+++ b/packages/vinext/src/server/prod-server.ts
@@ -1971,18 +1971,31 @@ async function startPagesRouterServer(options: PagesRouterServerOptions) {
let response: Response | undefined;
if (typeof renderPage === "function") {
const middlewareResponseHeaders = toWebHeaders(middlewareHeaders);
- const renderOptions = isDataReq ? { isDataReq: true } : undefined;
+ const renderPageMatch = matchPageRoute
+ ? matchPageRoute(resolvedPathname, webRequest)
+ : null;
+ const shouldDeferErrorPageOnMiss = !isDataReq && !!matchPageRoute && !renderPageMatch;
+ const dataRenderOptions = isDataReq ? { isDataReq: true } : undefined;
+ const initialRenderOptions = shouldDeferErrorPageOnMiss
+ ? { renderErrorPageOnMiss: false }
+ : dataRenderOptions;
response = await renderPage(
webRequest,
resolvedUrl,
ssrManifest,
undefined,
middlewareResponseHeaders,
- renderOptions,
+ initialRenderOptions,
);
// ── 11. Fallback rewrites (if SSR returned 404) ─────────────
- if (response && response.status === 404 && configRewrites.fallback?.length) {
+ let matchedFallbackRewrite = false;
+ if (
+ response &&
+ response.status === 404 &&
+ shouldDeferErrorPageOnMiss &&
+ configRewrites.fallback?.length
+ ) {
const fallbackRewrite = matchRewrite(
matchResolvedPathname(resolvedPathname),
configRewrites.fallback,
@@ -1995,16 +2008,31 @@ async function startPagesRouterServer(options: PagesRouterServerOptions) {
await sendWebResponse(proxyResponse, req, res, compress);
return;
}
+ matchedFallbackRewrite = true;
response = await renderPage(
webRequest,
mergeRewriteQuery(resolvedUrl, fallbackRewrite),
ssrManifest,
undefined,
middlewareResponseHeaders,
- renderOptions,
+ dataRenderOptions,
);
}
}
+ if (
+ response &&
+ response.status === 404 &&
+ shouldDeferErrorPageOnMiss &&
+ !matchedFallbackRewrite
+ ) {
+ response = await renderPage(
+ webRequest,
+ resolvedUrl,
+ ssrManifest,
+ undefined,
+ middlewareResponseHeaders,
+ );
+ }
}
if (!response) {
diff --git a/packages/vinext/src/shims/router.ts b/packages/vinext/src/shims/router.ts
index 1b56ba1f3..be18f28b8 100644
--- a/packages/vinext/src/shims/router.ts
+++ b/packages/vinext/src/shims/router.ts
@@ -163,6 +163,8 @@ function resolveUrl(url: string | UrlObject): string {
* data fetching, as for the browser URL). We collapse them because vinext's
* navigateClient() fetches HTML from the target URL, so `as` must be a
* server-resolvable path. Purely decorative `as` values are not supported.
+ * Pages error routes are handled as a narrow exception below because Next.js
+ * treats their href as the component route while preserving `as` in history.
*/
function resolveNavigationTarget(
url: string | UrlObject,
@@ -180,6 +182,54 @@ function getCurrentUrlLocale(): string | undefined {
});
}
+function getLocalPathname(url: string): string | null {
+ if (typeof window === "undefined") return null;
+ if (isAbsoluteOrProtocolRelativeUrl(url)) {
+ const localPath = toSameOriginAppPath(url, __basePath);
+ if (localPath == null) return null;
+ return stripBasePath(new URL(localPath, window.location.href).pathname, __basePath);
+ }
+ try {
+ return stripBasePath(new URL(url, window.location.href).pathname, __basePath);
+ } catch {
+ return null;
+ }
+}
+
+function resolvePagesErrorHtmlFetchUrl(
+ url: string | UrlObject,
+ locale: string | undefined,
+): string | null {
+ const href = resolveUrl(url);
+ const errorRoutePathname = getLocalPathname(href);
+ if (errorRoutePathname !== "/404" && errorRoutePathname !== "/_error") return null;
+
+ const fetchHref = errorRoutePathname === "/_error" ? replaceUrlPathname(href, "/404") : href;
+ const resolvedUrl = applyNavigationLocale(fetchHref, locale);
+
+ let parsed: URL;
+ try {
+ parsed = new URL(resolvedUrl, window.location.href);
+ } catch {
+ return null;
+ }
+ const appPathname = stripBasePath(parsed.pathname, __basePath);
+ const fetchTarget = `${appPathname}${parsed.search}${parsed.hash}`;
+ return normalizePathTrailingSlash(
+ toBrowserNavigationHref(fetchTarget, window.location.href, __basePath),
+ __trailingSlash,
+ );
+}
+
+function replaceUrlPathname(url: string, pathname: string): string {
+ try {
+ const parsed = new URL(url, window.location.href);
+ return `${pathname}${parsed.search}${parsed.hash}`;
+ } catch {
+ return pathname;
+ }
+}
+
function resolveTransitionLocale(locale: TransitionOptions["locale"]): string | undefined {
if (typeof window === "undefined") return undefined;
if (locale === false) return window.__VINEXT_DEFAULT_LOCALE__;
@@ -578,6 +628,10 @@ function scheduleHardNavigationAndThrow(url: string, message: string): never {
throw new HardNavigationScheduledError(message);
}
+type NavigateClientOptions = {
+ allowNotFoundResponse?: boolean;
+};
+
/** Wire format of `/_next/data//.json` response bodies. */
type PagesDataResponse = {
pageProps?: Record;
@@ -773,6 +827,7 @@ async function navigateClientHtml(
controller: AbortController,
navId: number,
assertStillCurrent: () => void,
+ options: NavigateClientOptions = {},
): Promise {
const root = window.__VINEXT_ROOT__;
if (!root) {
@@ -797,7 +852,7 @@ async function navigateClientHtml(
}
assertStillCurrent();
- if (!res.ok) {
+ if (!res.ok && !(options.allowNotFoundResponse === true && res.status === 404)) {
// Set window.location.href first so the browser navigates to the correct
// page even if the caller suppresses the error. The assignment schedules
// the navigation asynchronously (as a task), so synchronous routeChangeError
@@ -918,7 +973,11 @@ async function navigateClientHtml(
* fixups). The JSON path derives its own URL from the browser-facing `url`
* because the data endpoint speaks the unprefixed path.
*/
-async function navigateClient(url: string, fetchUrl = url): Promise {
+async function navigateClient(
+ url: string,
+ fetchUrl = url,
+ options: NavigateClientOptions = {},
+): Promise {
if (typeof window === "undefined") return;
// Cancel any in-flight navigation (abort its fetch, mark it stale)
@@ -937,10 +996,10 @@ async function navigateClient(url: string, fetchUrl = url): Promise {
try {
const dataTarget = resolvePagesDataNavigationTarget(url, __basePath);
- if (dataTarget) {
+ if (dataTarget && options.allowNotFoundResponse !== true) {
await navigateClientData(url, dataTarget, controller, navId, assertStillCurrent);
} else {
- await navigateClientHtml(url, fetchUrl, controller, navId, assertStillCurrent);
+ await navigateClientHtml(url, fetchUrl, controller, navId, assertStillCurrent, options);
}
} finally {
// Clean up the abort controller if this navigation is still the active one
@@ -966,9 +1025,10 @@ async function runNavigateClient(
fullUrl: string,
resolvedUrl: string,
fetchUrl = fullUrl,
+ options: NavigateClientOptions = {},
): Promise<"completed" | "cancelled" | "failed"> {
try {
- await navigateClient(fullUrl, fetchUrl);
+ await navigateClient(fullUrl, fetchUrl, options);
return "completed";
} catch (err: unknown) {
routerEvents.emit("routeChangeError", err, resolvedUrl, { shallow: false });
@@ -1140,7 +1200,11 @@ async function performNavigation(
toBrowserNavigationHref(resolved, window.location.href, __basePath),
__trailingSlash,
);
- const htmlFetchUrl = getPagesHtmlFetchUrl(full, navigationLocale);
+ const errorRouteHtmlFetchUrl = resolvePagesErrorHtmlFetchUrl(url, navigationLocale);
+ const htmlFetchUrl = errorRouteHtmlFetchUrl ?? getPagesHtmlFetchUrl(full, navigationLocale);
+ const navigateOptions: NavigateClientOptions = errorRouteHtmlFetchUrl
+ ? { allowNotFoundResponse: true }
+ : {};
const shallow = options?.shallow ?? false;
const doScroll = options?.scroll !== false;
@@ -1161,7 +1225,7 @@ async function performNavigation(
routerEvents.emit("beforeHistoryChange", resolved, { shallow });
updateHistory(mode, full);
if (!shallow) {
- const result = await runNavigateClient(full, resolved, htmlFetchUrl);
+ const result = await runNavigateClient(full, resolved, htmlFetchUrl, navigateOptions);
if (result === "cancelled") return true;
if (result === "failed") return false;
}
diff --git a/tests/deploy.test.ts b/tests/deploy.test.ts
index 21c41578a..69785104c 100644
--- a/tests/deploy.test.ts
+++ b/tests/deploy.test.ts
@@ -908,6 +908,13 @@ describe("generatePagesRouterWorkerEntry", () => {
expect(content).toContain('typeof renderPage === "function"');
});
+ it("does not defer error page rendering for data requests", () => {
+ const content = generatePagesRouterWorkerEntry();
+ expect(content).toContain(
+ 'const shouldDeferErrorPageOnMiss =\n !isDataRequest && typeof matchPageRoute === "function" && !renderPageMatch;',
+ );
+ });
+
it("builds reqCtx before middleware runs", () => {
const content = generatePagesRouterWorkerEntry();
const reqCtxPos = content.indexOf("requestContextFromRequest(request)");
diff --git a/tests/pages-page-data.test.ts b/tests/pages-page-data.test.ts
index af4dc813b..1cd503128 100644
--- a/tests/pages-page-data.test.ts
+++ b/tests/pages-page-data.test.ts
@@ -162,7 +162,7 @@ describe("pages page data", () => {
it("serves stale ISR entries immediately and regenerates them through typed helpers", async () => {
let regenPromise: Promise | null = null;
const applyRequestContexts = vi.fn();
- const isrSet = vi.fn(async () => {});
+ const isrSet = vi.fn(async () => {});
const runInFreshUnifiedContext = vi.fn(
async (callback: () => Promise): Promise => callback(),
) as ResolvePagesPageDataOptions["runInFreshUnifiedContext"];
@@ -237,6 +237,71 @@ describe("pages page data", () => {
);
});
+ it("preserves vinext module metadata during stale ISR regeneration", async () => {
+ let regenPromise: Promise | null = null;
+ const isrSet = vi.fn(async () => {});
+ const triggerBackgroundRegeneration = vi.fn((_key: string, renderFn: () => Promise) => {
+ regenPromise = renderFn();
+ });
+
+ const result = await resolvePagesPageData(
+ createOptions({
+ isrGet: vi.fn().mockResolvedValue({
+ isStale: true,
+ value: {
+ lastModified: 1,
+ cacheState: "stale",
+ value: {
+ kind: "PAGES",
+ html: 'stale 404
',
+ pageData: { marker: "stale" },
+ headers: undefined,
+ status: 404,
+ },
+ },
+ }),
+ isrSet,
+ pageModule: {
+ async getStaticProps() {
+ return {
+ props: { marker: "fresh" },
+ revalidate: 60,
+ };
+ },
+ },
+ renderIsrPassToStringAsync: vi.fn(async () => "fresh 404"),
+ routePattern: "/404",
+ routeUrl: "/missing",
+ statusCode: 404,
+ triggerBackgroundRegeneration,
+ vinext: {
+ pageModuleUrl: "/assets/pages/404.js",
+ appModuleUrl: "/assets/pages/_app.js",
+ },
+ }),
+ );
+
+ expect(result.kind).toBe("response");
+ if (result.kind !== "response") {
+ throw new Error("expected response result");
+ }
+ expect(result.response.status).toBe(404);
+
+ if (!regenPromise) {
+ throw new Error("expected stale ISR regeneration to start");
+ }
+ const pendingRegen: Promise = regenPromise;
+ await pendingRegen;
+
+ expect(isrSet).toHaveBeenCalledOnce();
+ const regeneratedCacheValue = isrSet.mock.calls[0]?.[1];
+ expect(regeneratedCacheValue?.html).toContain("fresh 404");
+ expect(regeneratedCacheValue?.html).toContain('"__vinext"');
+ expect(regeneratedCacheValue?.html).toContain('"pageModuleUrl":"/assets/pages/404.js"');
+ expect(regeneratedCacheValue?.html).toContain('"appModuleUrl":"/assets/pages/_app.js"');
+ expect(regeneratedCacheValue?.status).toBe(404);
+ });
+
it("uses stored cache-control metadata for Pages Router cached HIT responses", async () => {
const result = await resolvePagesPageData(
createOptions({
diff --git a/tests/pages-router.test.ts b/tests/pages-router.test.ts
index 66f3a8123..332d97b10 100644
--- a/tests/pages-router.test.ts
+++ b/tests/pages-router.test.ts
@@ -2604,6 +2604,270 @@ export default function CounterPage() {
}
});
+ it("renders pages/404 for basePath route misses after stripping one basePath segment", async () => {
+ const tmpRoot = await fsp.mkdtemp(path.join(os.tmpdir(), "vinext-pages-basepath-404-"));
+ const rootNodeModules = path.resolve(import.meta.dirname, "../node_modules");
+ const fixtureOutDir = path.join(tmpRoot, "dist");
+
+ try {
+ await fsp.symlink(rootNodeModules, path.join(tmpRoot, "node_modules"), "junction");
+ await fsp.mkdir(path.join(tmpRoot, "pages"), { recursive: true });
+ await fsp.writeFile(path.join(tmpRoot, "package.json"), JSON.stringify({ type: "module" }));
+ await fsp.writeFile(
+ path.join(tmpRoot, "next.config.mjs"),
+ `export default { basePath: "/docs" };\n`,
+ );
+ await fsp.writeFile(path.join(tmpRoot, "pages", "_app.tsx"), PAGES_APP_COMPONENT);
+ await fsp.writeFile(
+ path.join(tmpRoot, "pages", "404.tsx"),
+ `export default function Custom404() {
+ return This page could not be found;
+}
+`,
+ );
+ await fsp.writeFile(
+ path.join(tmpRoot, "pages", "hello.tsx"),
+ `export default function Hello() {
+ return Hello World;
+}
+`,
+ );
+
+ await buildPagesFixtureToOutDir(tmpRoot, fixtureOutDir);
+
+ const { startProdServer } = await import("../packages/vinext/src/server/prod-server.js");
+ const prodServer = unwrapStartedProdServer(
+ await startProdServer({
+ port: 0,
+ host: "127.0.0.1",
+ outDir: fixtureOutDir,
+ }),
+ );
+
+ try {
+ const addr = prodServer.address() as { port: number };
+ const baseUrl = `http://127.0.0.1:${addr.port}`;
+
+ const res = await fetch(`${baseUrl}/docs/docs/other-page`);
+ expect(res.status).toBe(404);
+ const html = await res.text();
+ expect(html).toContain('id="custom-404"');
+ expect(html).toContain("This page could not be found");
+ expect(html).toContain('"page":"/404"');
+ } finally {
+ await new Promise((resolve) => prodServer.close(() => resolve()));
+ }
+ } finally {
+ fs.rmSync(tmpRoot, { recursive: true, force: true });
+ }
+ });
+
+ it("applies fallback rewrites before rendering custom 404 pages", async () => {
+ const tmpRoot = await fsp.mkdtemp(path.join(os.tmpdir(), "vinext-pages-fallback-before-404-"));
+ const rootNodeModules = path.resolve(import.meta.dirname, "../node_modules");
+ const fixtureOutDir = path.join(tmpRoot, "dist");
+
+ try {
+ await fsp.symlink(rootNodeModules, path.join(tmpRoot, "node_modules"), "junction");
+ await fsp.mkdir(path.join(tmpRoot, "pages"), { recursive: true });
+ await fsp.writeFile(path.join(tmpRoot, "package.json"), JSON.stringify({ type: "module" }));
+ await fsp.writeFile(
+ path.join(tmpRoot, "next.config.mjs"),
+ `export default {
+ basePath: "/docs",
+ async rewrites() {
+ return {
+ fallback: [{ source: "/:path*", destination: "/fallback" }],
+ };
+ },
+};
+`,
+ );
+ await fsp.writeFile(path.join(tmpRoot, "pages", "_app.tsx"), PAGES_APP_COMPONENT);
+ await fsp.writeFile(
+ path.join(tmpRoot, "pages", "404.tsx"),
+ `export default function Custom404() {
+ const shouldThrow = Boolean(
+ (globalThis as { __VINEXT_FALLBACK_REWRITE_TEST_RUNTIME?: boolean })
+ .__VINEXT_FALLBACK_REWRITE_TEST_RUNTIME,
+ );
+ if (shouldThrow) {
+ throw new Error("pages/404 should not execute before fallback rewrites");
+ }
+ return This page could not be found;
+}
+`,
+ );
+ await fsp.writeFile(
+ path.join(tmpRoot, "pages", "fallback.tsx"),
+ `export default function Fallback() {
+ return Fallback rewrite;
+}
+`,
+ );
+
+ await buildPagesFixtureToOutDir(tmpRoot, fixtureOutDir);
+
+ const { startProdServer } = await import("../packages/vinext/src/server/prod-server.js");
+ const prodServer = unwrapStartedProdServer(
+ await startProdServer({
+ port: 0,
+ host: "127.0.0.1",
+ outDir: fixtureOutDir,
+ }),
+ );
+
+ try {
+ const addr = prodServer.address() as { port: number };
+ const baseUrl = `http://127.0.0.1:${addr.port}`;
+
+ const explicitNotFoundRes = await fetch(`${baseUrl}/docs/404`);
+ expect(explicitNotFoundRes.status).toBe(404);
+ const explicitNotFoundHtml = await explicitNotFoundRes.text();
+ expect(explicitNotFoundHtml).toContain('id="custom-404"');
+ expect(explicitNotFoundHtml).toContain("This page could not be found");
+ expect(explicitNotFoundHtml).toContain('"page":"/404"');
+ expect(explicitNotFoundHtml).not.toContain('id="fallback"');
+
+ (
+ globalThis as { __VINEXT_FALLBACK_REWRITE_TEST_RUNTIME?: boolean }
+ ).__VINEXT_FALLBACK_REWRITE_TEST_RUNTIME = true;
+ const res = await fetch(`${baseUrl}/docs/missing`);
+ expect(res.status).toBe(200);
+ const html = await res.text();
+ expect(html).toContain('id="fallback"');
+ expect(html).toContain("Fallback rewrite");
+ expect(html).toContain('"page":"/fallback"');
+ expect(html).not.toContain("pages/404 should not execute before fallback rewrites");
+ } finally {
+ delete (globalThis as { __VINEXT_FALLBACK_REWRITE_TEST_RUNTIME?: boolean })
+ .__VINEXT_FALLBACK_REWRITE_TEST_RUNTIME;
+ await new Promise((resolve) => prodServer.close(() => resolve()));
+ }
+ } finally {
+ fs.rmSync(tmpRoot, { recursive: true, force: true });
+ }
+ });
+
+ it("falls back to pages/_error for route misses when pages/404 is absent", async () => {
+ const tmpRoot = await fsp.mkdtemp(path.join(os.tmpdir(), "vinext-pages-basepath-error-"));
+ const rootNodeModules = path.resolve(import.meta.dirname, "../node_modules");
+ const fixtureOutDir = path.join(tmpRoot, "dist");
+
+ try {
+ await fsp.symlink(rootNodeModules, path.join(tmpRoot, "node_modules"), "junction");
+ await fsp.mkdir(path.join(tmpRoot, "pages"), { recursive: true });
+ await fsp.writeFile(path.join(tmpRoot, "package.json"), JSON.stringify({ type: "module" }));
+ await fsp.writeFile(
+ path.join(tmpRoot, "next.config.mjs"),
+ `export default { basePath: "/docs" };\n`,
+ );
+ await fsp.writeFile(path.join(tmpRoot, "pages", "_app.tsx"), PAGES_APP_COMPONENT);
+ await fsp.writeFile(
+ path.join(tmpRoot, "pages", "_error.tsx"),
+ `export default function ErrorPage({ statusCode }: { statusCode?: number }) {
+ return Error status: {statusCode};
+}
+`,
+ );
+ await fsp.writeFile(
+ path.join(tmpRoot, "pages", "hello.tsx"),
+ `export default function Hello() {
+ return Hello World;
+}
+`,
+ );
+
+ await buildPagesFixtureToOutDir(tmpRoot, fixtureOutDir);
+
+ const { startProdServer } = await import("../packages/vinext/src/server/prod-server.js");
+ const prodServer = unwrapStartedProdServer(
+ await startProdServer({
+ port: 0,
+ host: "127.0.0.1",
+ outDir: fixtureOutDir,
+ }),
+ );
+
+ try {
+ const addr = prodServer.address() as { port: number };
+ const baseUrl = `http://127.0.0.1:${addr.port}`;
+
+ const res = await fetch(`${baseUrl}/docs/docs/other-page`);
+ expect(res.status).toBe(404);
+ const html = await res.text();
+ expect(html).toContain('id="custom-error"');
+ expect(html).toContain("Error status:");
+ expect(html).toContain("404");
+ expect(html).toContain('"page":"/_error"');
+ } finally {
+ await new Promise((resolve) => prodServer.close(() => resolve()));
+ }
+ } finally {
+ fs.rmSync(tmpRoot, { recursive: true, force: true });
+ }
+ });
+
+ it("preserves 404 status for cached ISR custom 404 route misses", async () => {
+ const tmpRoot = await fsp.mkdtemp(path.join(os.tmpdir(), "vinext-pages-isr-404-"));
+ const rootNodeModules = path.resolve(import.meta.dirname, "../node_modules");
+ const fixtureOutDir = path.join(tmpRoot, "dist");
+
+ try {
+ await fsp.symlink(rootNodeModules, path.join(tmpRoot, "node_modules"), "junction");
+ await fsp.mkdir(path.join(tmpRoot, "pages"), { recursive: true });
+ await fsp.writeFile(path.join(tmpRoot, "package.json"), JSON.stringify({ type: "module" }));
+ await fsp.writeFile(path.join(tmpRoot, "next.config.mjs"), `export default {};\n`);
+ await fsp.writeFile(path.join(tmpRoot, "pages", "_app.tsx"), PAGES_APP_COMPONENT);
+ await fsp.writeFile(
+ path.join(tmpRoot, "pages", "404.tsx"),
+ `export async function getStaticProps() {
+ return { props: { marker: "custom ISR 404" }, revalidate: 60 };
+}
+
+export default function Custom404({ marker }: { marker: string }) {
+ return {marker};
+}
+`,
+ );
+
+ await buildPagesFixtureToOutDir(tmpRoot, fixtureOutDir);
+
+ const { startProdServer } = await import("../packages/vinext/src/server/prod-server.js");
+ const prodServer = unwrapStartedProdServer(
+ await startProdServer({
+ port: 0,
+ host: "127.0.0.1",
+ outDir: fixtureOutDir,
+ }),
+ );
+
+ try {
+ const addr = prodServer.address() as { port: number };
+ const baseUrl = `http://127.0.0.1:${addr.port}`;
+ const missingUrl = `${baseUrl}/cached-custom-404-miss`;
+
+ const first = await fetch(missingUrl);
+ expect(first.status).toBe(404);
+ expect(first.headers.get("x-vinext-cache")).toBe("MISS");
+ const firstHtml = await first.text();
+ expect(firstHtml).toContain('id="custom-404"');
+ expect(firstHtml).toContain("custom ISR 404");
+
+ const second = await fetch(missingUrl);
+ expect(second.status).toBe(404);
+ expect(second.headers.get("x-vinext-cache")).toBe("HIT");
+ const secondHtml = await second.text();
+ expect(secondHtml).toContain('id="custom-404"');
+ expect(secondHtml).toContain("custom ISR 404");
+ } finally {
+ await new Promise((resolve) => prodServer.close(() => resolve()));
+ }
+ } finally {
+ fs.rmSync(tmpRoot, { recursive: true, force: true });
+ }
+ });
+
it("emits stylesheet and static asset URLs for backfilled inlined pages", async () => {
const tmpRoot = await fsp.mkdtemp(path.join(os.tmpdir(), "vinext-pages-inline-assets-"));
const rootNodeModules = path.resolve(import.meta.dirname, "../node_modules");
diff --git a/tests/prerender.test.ts b/tests/prerender.test.ts
index 4124b17d9..0e1293855 100644
--- a/tests/prerender.test.ts
+++ b/tests/prerender.test.ts
@@ -478,6 +478,7 @@ describe("prerenderPages — default mode (pages-basic)", () => {
it("renders 404 page", () => {
const r = findRoute(results, "/404");
+ expect(results.filter((result) => result.route === "/404")).toHaveLength(1);
expect(r).toMatchObject({ route: "/404", status: "rendered", revalidate: false });
if (r?.status === "rendered") {
expect(r.outputFiles).toContain("404.html");
diff --git a/tests/shims.test.ts b/tests/shims.test.ts
index 622eccdc9..4af31f616 100644
--- a/tests/shims.test.ts
+++ b/tests/shims.test.ts
@@ -11487,9 +11487,9 @@ describe("Pages Router concurrent navigation", () => {
},
__VINEXT_ROOT__: { render },
__VINEXT_APP__: undefined,
- __VINEXT_LOCALE__: undefined,
- __VINEXT_LOCALES__: undefined,
- __VINEXT_DEFAULT_LOCALE__: undefined,
+ __VINEXT_LOCALE__: undefined as string | undefined,
+ __VINEXT_LOCALES__: undefined as string[] | undefined,
+ __VINEXT_DEFAULT_LOCALE__: undefined as string | undefined,
};
// Make pushState update location to simulate real browser behavior
@@ -11644,6 +11644,223 @@ describe("Pages Router concurrent navigation", () => {
});
});
+ // Ported from Next.js:
+ // test/e2e/basepath/error-pages.test.ts
+ // https://github.com/vercel/next.js/blob/canary/test/e2e/basepath/error-pages.test.ts
+ it("Pages Router fetches the error route while preserving the masked URL under basePath", async () => {
+ const previousWindow = (globalThis as any).window;
+ const previousBasePath = process.env.__NEXT_ROUTER_BASEPATH;
+ const originalFetch = globalThis.fetch;
+ const { win } = createNavWindow();
+ const pageModuleUrl = path.resolve(import.meta.dirname, "fixtures/client-navigation-page.tsx");
+ win.location.pathname = "/docs/slug-1";
+ win.location.href = "http://localhost/docs/slug-1";
+ (globalThis as any).window = win;
+ process.env.__NEXT_ROUTER_BASEPATH = "/docs";
+
+ const fetch = vi.fn(
+ async () =>
+ new Response(buildNavHtml("/404", pageModuleUrl), {
+ status: 404,
+ }),
+ );
+ globalThis.fetch = fetch;
+
+ try {
+ vi.resetModules();
+ const routerModule = await import("../packages/vinext/src/shims/router.js");
+ const Router = routerModule.default;
+
+ const result = await Router.push("/404", "/slug-2");
+
+ expect(result).toBe(true);
+ expect(fetch).toHaveBeenCalledWith("/docs/404", expect.any(Object));
+ expect(win.history.pushState).toHaveBeenCalledWith({}, "", "/docs/slug-2");
+ expect(win.location.pathname).toBe("/docs/slug-2");
+ expect(win.__NEXT_DATA__.page).toBe("/404");
+ } finally {
+ if (previousBasePath === undefined) {
+ delete process.env.__NEXT_ROUTER_BASEPATH;
+ } else {
+ process.env.__NEXT_ROUTER_BASEPATH = previousBasePath;
+ }
+ vi.resetModules();
+ if (previousWindow === undefined) {
+ delete (globalThis as any).window;
+ } else {
+ (globalThis as any).window = previousWindow;
+ }
+ globalThis.fetch = originalFetch;
+ }
+ });
+
+ it("Pages Router maps masked /_error client navigations to the 404 page under basePath", async () => {
+ const previousWindow = (globalThis as any).window;
+ const previousBasePath = process.env.__NEXT_ROUTER_BASEPATH;
+ const originalFetch = globalThis.fetch;
+ const { win } = createNavWindow();
+ const pageModuleUrl = path.resolve(import.meta.dirname, "fixtures/client-navigation-page.tsx");
+ win.location.pathname = "/docs/slug-1";
+ win.location.href = "http://localhost/docs/slug-1";
+ (globalThis as any).window = win;
+ process.env.__NEXT_ROUTER_BASEPATH = "/docs";
+
+ const fetch = vi.fn(
+ async () =>
+ new Response(buildNavHtml("/404", pageModuleUrl), {
+ status: 404,
+ }),
+ );
+ globalThis.fetch = fetch;
+
+ try {
+ vi.resetModules();
+ const routerModule = await import("../packages/vinext/src/shims/router.js");
+ const Router = routerModule.default;
+
+ const result = await Router.push("/_error", "/slug-2");
+
+ expect(result).toBe(true);
+ expect(fetch).toHaveBeenCalledWith("/docs/404", expect.any(Object));
+ expect(win.history.pushState).toHaveBeenCalledWith({}, "", "/docs/slug-2");
+ expect(win.location.pathname).toBe("/docs/slug-2");
+ expect(win.__NEXT_DATA__.page).toBe("/404");
+ } finally {
+ if (previousBasePath === undefined) {
+ delete process.env.__NEXT_ROUTER_BASEPATH;
+ } else {
+ process.env.__NEXT_ROUTER_BASEPATH = previousBasePath;
+ }
+ vi.resetModules();
+ if (previousWindow === undefined) {
+ delete (globalThis as any).window;
+ } else {
+ (globalThis as any).window = previousWindow;
+ }
+ globalThis.fetch = originalFetch;
+ }
+ });
+
+ it("Pages Router fetches /404 through a non-default locale while preserving the masked URL", async () => {
+ const previousWindow = (globalThis as any).window;
+ const previousBasePath = process.env.__NEXT_ROUTER_BASEPATH;
+ const originalFetch = globalThis.fetch;
+ const { win } = createNavWindow();
+ const pageModuleUrl = path.resolve(import.meta.dirname, "fixtures/client-navigation-page.tsx");
+ win.location.pathname = "/docs/fr/slug-1";
+ win.location.href = "http://localhost/docs/fr/slug-1";
+ win.__VINEXT_LOCALE__ = "fr";
+ win.__VINEXT_LOCALES__ = ["en", "fr"];
+ win.__VINEXT_DEFAULT_LOCALE__ = "en";
+ (globalThis as any).window = win;
+ process.env.__NEXT_ROUTER_BASEPATH = "/docs";
+
+ const fetch = vi.fn(
+ async () =>
+ new Response(
+ buildNavHtml(
+ "/404",
+ pageModuleUrl,
+ {},
+ {
+ locale: "fr",
+ locales: ["en", "fr"],
+ defaultLocale: "en",
+ },
+ ),
+ { status: 404 },
+ ),
+ );
+ globalThis.fetch = fetch;
+
+ try {
+ vi.resetModules();
+ const routerModule = await import("../packages/vinext/src/shims/router.js");
+ const Router = routerModule.default;
+
+ const result = await Router.push("/404", "/slug-2");
+
+ expect(result).toBe(true);
+ expect(fetch).toHaveBeenCalledWith("/docs/fr/404", expect.any(Object));
+ expect(win.history.pushState).toHaveBeenCalledWith({}, "", "/docs/fr/slug-2");
+ expect(win.location.pathname).toBe("/docs/fr/slug-2");
+ expect(win.__NEXT_DATA__.page).toBe("/404");
+ } finally {
+ if (previousBasePath === undefined) {
+ delete process.env.__NEXT_ROUTER_BASEPATH;
+ } else {
+ process.env.__NEXT_ROUTER_BASEPATH = previousBasePath;
+ }
+ vi.resetModules();
+ if (previousWindow === undefined) {
+ delete (globalThis as any).window;
+ } else {
+ (globalThis as any).window = previousWindow;
+ }
+ globalThis.fetch = originalFetch;
+ }
+ });
+
+ it("Pages Router maps /_error through a non-default locale while preserving the masked URL", async () => {
+ const previousWindow = (globalThis as any).window;
+ const previousBasePath = process.env.__NEXT_ROUTER_BASEPATH;
+ const originalFetch = globalThis.fetch;
+ const { win } = createNavWindow();
+ const pageModuleUrl = path.resolve(import.meta.dirname, "fixtures/client-navigation-page.tsx");
+ win.location.pathname = "/docs/fr/slug-1";
+ win.location.href = "http://localhost/docs/fr/slug-1";
+ win.__VINEXT_LOCALE__ = "fr";
+ win.__VINEXT_LOCALES__ = ["en", "fr"];
+ win.__VINEXT_DEFAULT_LOCALE__ = "en";
+ (globalThis as any).window = win;
+ process.env.__NEXT_ROUTER_BASEPATH = "/docs";
+
+ const fetch = vi.fn(
+ async () =>
+ new Response(
+ buildNavHtml(
+ "/404",
+ pageModuleUrl,
+ {},
+ {
+ locale: "fr",
+ locales: ["en", "fr"],
+ defaultLocale: "en",
+ },
+ ),
+ { status: 404 },
+ ),
+ );
+ globalThis.fetch = fetch;
+
+ try {
+ vi.resetModules();
+ const routerModule = await import("../packages/vinext/src/shims/router.js");
+ const Router = routerModule.default;
+
+ const result = await Router.push("/_error", "/slug-2");
+
+ expect(result).toBe(true);
+ expect(fetch).toHaveBeenCalledWith("/docs/fr/404", expect.any(Object));
+ expect(win.history.pushState).toHaveBeenCalledWith({}, "", "/docs/fr/slug-2");
+ expect(win.location.pathname).toBe("/docs/fr/slug-2");
+ expect(win.__NEXT_DATA__.page).toBe("/404");
+ } finally {
+ if (previousBasePath === undefined) {
+ delete process.env.__NEXT_ROUTER_BASEPATH;
+ } else {
+ process.env.__NEXT_ROUTER_BASEPATH = previousBasePath;
+ }
+ vi.resetModules();
+ if (previousWindow === undefined) {
+ delete (globalThis as any).window;
+ } else {
+ (globalThis as any).window = previousWindow;
+ }
+ globalThis.fetch = originalFetch;
+ }
+ });
+
it("last push() wins when two overlap — superseded navigation does not render", async () => {
const previousWindow = (globalThis as any).window;
const originalFetch = globalThis.fetch;