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;