diff --git a/packages/vinext/src/client/vinext-next-data.ts b/packages/vinext/src/client/vinext-next-data.ts index d42674a87..a22347b73 100644 --- a/packages/vinext/src/client/vinext-next-data.ts +++ b/packages/vinext/src/client/vinext-next-data.ts @@ -21,6 +21,8 @@ export type VinextNextData = { pageModuleUrl?: string; /** Absolute URL of the `_app` module for dynamic import. */ appModuleUrl?: string; + /** True when the Pages Router server has middleware/proxy configured. */ + hasMiddleware?: boolean; }; } & NEXT_DATA; diff --git a/packages/vinext/src/entries/pages-server-entry.ts b/packages/vinext/src/entries/pages-server-entry.ts index c6bef2a9c..dd57f9e53 100644 --- a/packages/vinext/src/entries/pages-server-entry.ts +++ b/packages/vinext/src/entries/pages-server-entry.ts @@ -253,6 +253,7 @@ const i18nConfig = ${i18nConfigJson}; // match _next/data requests against the embedded buildId without needing // to load next.config.js at runtime. export const buildId = ${buildIdJson}; +const __hasMiddleware = ${JSON.stringify(Boolean(middlewarePath))}; // Full resolved config for production server (embedded at build time) export const vinextConfig = ${vinextConfigJson}; @@ -675,6 +676,7 @@ async function _renderPage(request, url, manifest, middlewareHeaders, options) { safeJsonStringify, sanitizeDestination: sanitizeDestinationLocal, scriptNonce, + vinext: { hasMiddleware: __hasMiddleware }, triggerBackgroundRegeneration, }); if (pageDataResult.kind === "response") { @@ -795,6 +797,7 @@ async function _renderPage(request, url, manifest, middlewareHeaders, options) { routeUrl, safeJsonStringify, scriptNonce, + vinext: { hasMiddleware: __hasMiddleware }, }); } catch (e) { console.error("[vinext] SSR error:", e); diff --git a/packages/vinext/src/global.d.ts b/packages/vinext/src/global.d.ts index f9ada2616..c0f9561e3 100644 --- a/packages/vinext/src/global.d.ts +++ b/packages/vinext/src/global.d.ts @@ -60,7 +60,10 @@ declare global { * client-side navigation. */ __VINEXT_APP__: - | React.ComponentType<{ Component: React.ComponentType; pageProps: unknown }> + | React.ComponentType<{ + Component: React.ComponentType>; + pageProps: unknown; + }> | undefined; /** diff --git a/packages/vinext/src/index.ts b/packages/vinext/src/index.ts index 3762521dd..f708f8df9 100644 --- a/packages/vinext/src/index.ts +++ b/packages/vinext/src/index.ts @@ -3317,6 +3317,7 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] { fileMatcher, nextConfig?.basePath ?? "", nextConfig?.trailingSlash ?? false, + middlewarePath !== null, ); const mwStatus = req.__vinextMiddlewareStatus; diff --git a/packages/vinext/src/server/dev-server.ts b/packages/vinext/src/server/dev-server.ts index 1bc910d13..763c0c6ae 100644 --- a/packages/vinext/src/server/dev-server.ts +++ b/packages/vinext/src/server/dev-server.ts @@ -252,6 +252,7 @@ export function createSSRHandler( fileMatcher?: ValidFileMatcher, basePath = "", trailingSlash = false, + hasMiddleware = false, ) { const matcher = fileMatcher ?? createValidFileMatcher(); @@ -758,6 +759,7 @@ export function createSSRHandler( __vinext: { pageModuleUrl: regenPageUrl, appModuleUrl: regenAppUrl, + hasMiddleware, }, })}${i18nConfig ? `;window.__VINEXT_LOCALE__=${safeJsonStringify(locale ?? currentDefaultLocale)};window.__VINEXT_LOCALES__=${safeJsonStringify(i18nConfig.locales)};window.__VINEXT_DEFAULT_LOCALE__=${safeJsonStringify(currentDefaultLocale)}` : ""}`; @@ -1032,6 +1034,7 @@ hydrate(); __vinext: { pageModuleUrl, appModuleUrl, + hasMiddleware, }, })}${i18nConfig ? `;window.__VINEXT_LOCALE__=${safeJsonStringify(locale ?? currentDefaultLocale)};window.__VINEXT_LOCALES__=${safeJsonStringify(i18nConfig.locales)};window.__VINEXT_DEFAULT_LOCALE__=${safeJsonStringify(currentDefaultLocale)}` : ""}`, scriptNonce, diff --git a/packages/vinext/src/server/pages-page-data.ts b/packages/vinext/src/server/pages-page-data.ts index c41ca451a..a1be8cf85 100644 --- a/packages/vinext/src/server/pages-page-data.ts +++ b/packages/vinext/src/server/pages-page-data.ts @@ -1,5 +1,6 @@ import type { ReactNode } from "react"; import type { Route } from "../routing/pages-router.js"; +import type { VinextNextData } from "../client/vinext-next-data.js"; import { normalizeStaticPathname } from "../routing/route-pattern.js"; import type { CachedPagesValue, CacheControlMetadata } from "vinext/shims/cache"; import { buildCachedRevalidateCacheControl } from "./cache-control.js"; @@ -93,6 +94,7 @@ type RenderPagesIsrHtmlOptions = { renderIsrPassToStringAsync: (element: ReactNode) => Promise; routePattern: string; safeJsonStringify: (value: unknown) => string; + vinext?: VinextNextData["__vinext"]; }; export type ResolvePagesPageDataOptions = { @@ -131,6 +133,7 @@ export type ResolvePagesPageDataOptions = { safeJsonStringify: (value: unknown) => string; sanitizeDestination: (destination: string) => string; scriptNonce?: string; + vinext?: VinextNextData["__vinext"]; triggerBackgroundRegeneration: ( key: string, renderFn: () => Promise, @@ -297,6 +300,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); @@ -475,6 +479,7 @@ export async function resolvePagesPageData( renderIsrPassToStringAsync: options.renderIsrPassToStringAsync, routePattern: options.routePattern, safeJsonStringify: options.safeJsonStringify, + vinext: options.vinext, }); await options.isrSet( diff --git a/packages/vinext/src/server/pages-page-response.ts b/packages/vinext/src/server/pages-page-response.ts index 45bb50921..bcb434316 100644 --- a/packages/vinext/src/server/pages-page-response.ts +++ b/packages/vinext/src/server/pages-page-response.ts @@ -70,6 +70,7 @@ type RenderPagesPageResponseOptions = { routeUrl: string; safeJsonStringify: (value: unknown) => string; scriptNonce?: string; + vinext?: VinextNextData["__vinext"]; }; function buildPagesFontHeadHtml( @@ -318,6 +319,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 diff --git a/packages/vinext/src/shims/router.ts b/packages/vinext/src/shims/router.ts index 1b56ba1f3..69443fea7 100644 --- a/packages/vinext/src/shims/router.ts +++ b/packages/vinext/src/shims/router.ts @@ -28,13 +28,16 @@ import { resolvePagesDataNavigationTarget, type PagesDataTarget, } from "./internal/pages-data-target.js"; +import { buildPagesDataHref } from "./internal/pages-data-url.js"; import { installWindowNext, type PagesRouterPublicInstance } from "../client/window-next.js"; +import { isUnknownRecord } from "../utils/record.js"; import { isAbsoluteOrProtocolRelativeUrl, isHashOnlyBrowserUrlChange, normalizePathTrailingSlash, toBrowserNavigationHref, toSameOriginAppPath, + getWindowOrigin, } from "./url-utils.js"; import { stripBasePath } from "../utils/base-path.js"; import { @@ -587,6 +590,121 @@ type PagesDataResponse = { [key: string]: unknown; }; +function isPageComponent(value: unknown): value is ComponentType> { + if (typeof value === "function") return true; + if (!isUnknownRecord(value)) return false; + return ( + value.$$typeof === Symbol.for("react.forward_ref") || + value.$$typeof === Symbol.for("react.memo") + ); +} + +function isAppComponent(value: unknown): value is NonNullable { + return isPageComponent(value); +} + +function resolveSameOriginRedirectedUrl(responseUrl: string): string | null { + const appPath = toSameOriginAppPath(responseUrl, __basePath); + if (appPath === null) return null; + return normalizePathTrailingSlash( + toBrowserNavigationHref(appPath, window.location.href, __basePath), + __trailingSlash, + ); +} + +function stripLocalePrefixForApiRedirect(appPath: string): string { + const locales = window.__VINEXT_LOCALES__; + if (!locales || locales.length === 0) return appPath; + + try { + const parsed = new URL(appPath, "http://vinext.local"); + const pathname = stripBasePath(parsed.pathname, __basePath); + const firstSegment = pathname.split("/")[1]; + if (!firstSegment || !locales.includes(firstSegment)) return appPath; + + const withoutLocale = pathname.slice(firstSegment.length + 1) || "/"; // +1 for leading `/` + if (withoutLocale !== "/api" && !withoutLocale.startsWith("/api/")) { + return appPath; + } + + return `${withoutLocale}${parsed.search}${parsed.hash}`; + } catch { + return appPath; + } +} + +function resolveLocalRedirectUrl(location: string): string | null { + let appPath: string | null; + if (location.startsWith("/") && !location.startsWith("//")) { + try { + // Data redirect headers can already be browser paths with basePath. + // Convert back to app paths before toBrowserNavigationHref re-applies it. + const parsed = new URL(location, "http://vinext.local"); + appPath = stripBasePath(parsed.pathname, __basePath) + parsed.search + parsed.hash; + } catch { + appPath = location; + } + } else { + appPath = toSameOriginAppPath(location, __basePath); + } + + if (appPath === null) return null; + return normalizePathTrailingSlash( + toBrowserNavigationHref( + stripLocalePrefixForApiRedirect(appPath), + window.location.href, + __basePath, + ), + __trailingSlash, + ); +} + +function hasVinextMiddleware(nextData: unknown): boolean { + if (!isUnknownRecord(nextData)) return false; + const vinext = nextData.__vinext; + return isUnknownRecord(vinext) && vinext.hasMiddleware === true; +} + +function getMiddlewarePagesDataFetchUrl(browserUrl: string): string | null { + const nextData = window.__NEXT_DATA__; + if (!nextData || !hasVinextMiddleware(nextData)) return null; + const buildId = nextData.buildId; + if (typeof buildId !== "string" || buildId.length === 0) return null; + + let parsed: URL; + try { + parsed = new URL(browserUrl, window.location.href); + } catch { + return null; + } + if (parsed.origin !== getWindowOrigin()) return null; + + const appPathname = stripBasePath(parsed.pathname, __basePath); + return buildPagesDataHref(__basePath, buildId, appPathname, parsed.search); +} + +async function resolveMiddlewareDataRedirect( + browserUrl: string, + signal: AbortSignal, +): Promise { + const dataUrl = getMiddlewarePagesDataFetchUrl(browserUrl); + if (!dataUrl) return null; + + try { + const res = await fetch(dataUrl, { + headers: { + Accept: "application/json", + "x-nextjs-data": "1", + }, + signal, + }); + return res.headers.get("x-nextjs-redirect"); + } catch (err: unknown) { + if (err instanceof DOMException && err.name === "AbortError") throw err; + return null; + } +} + /** * Perform client-side navigation via the `/_next/data//.json` * endpoint. Used when `__VINEXT_PAGE_LOADERS__` has a matching code-split @@ -630,12 +748,18 @@ async function navigateClientData( assertStillCurrent(); // Soft-redirect protocol: the data endpoint emits 200 + x-nextjs-redirect - // when middleware (or gSSP/gSP) chose a redirect for this URL. For now we - // hard-reload to the target; a future iteration can wire this through - // Router.replace so the redirect stays a client-side navigation. + // when middleware (or gSSP/gSP) chose a redirect for this URL. const softRedirect = res.headers.get("x-nextjs-redirect"); if (softRedirect) { - scheduleHardNavigationAndThrow(softRedirect, "Navigation soft-redirected by data endpoint"); + const redirectedUrl = resolveLocalRedirectUrl(softRedirect); + if (!redirectedUrl) { + scheduleHardNavigationAndThrow(softRedirect, "Navigation redirected externally"); + } + + window.history.replaceState(window.history.state ?? {}, "", redirectedUrl); + _lastPathnameAndSearch = window.location.pathname + window.location.search; + await navigateClientHtml(redirectedUrl, redirectedUrl, controller, navId, assertStillCurrent); + return; } if (!res.ok) { @@ -669,11 +793,11 @@ async function navigateClientData( } assertStillCurrent(); - const PageComponent = pageModule.default as React.ComponentType | undefined; - if (!PageComponent) { + const PageComponent = pageModule.default; + if (!isPageComponent(PageComponent)) { scheduleHardNavigationAndThrow( url, - "Data navigation failed: page module has no default export", + "Data navigation failed: page module default export is not a component", ); } @@ -682,7 +806,7 @@ async function navigateClientData( if (!AppComponent && typeof window.__VINEXT_APP_LOADER__ === "function") { try { const appModule = await window.__VINEXT_APP_LOADER__(); - AppComponent = appModule.default as Window["__VINEXT_APP__"]; + AppComponent = isAppComponent(appModule.default) ? appModule.default : undefined; if (AppComponent) window.__VINEXT_APP__ = AppComponent; } catch { // _app load failed — fall through and render without it. This matches @@ -695,14 +819,14 @@ async function navigateClientData( const React = (await import("react")).default; assertStillCurrent(); - let element: React.ReactElement; + let element: ReactElement; if (AppComponent) { element = React.createElement(AppComponent, { - Component: PageComponent as React.ComponentType, + Component: PageComponent, pageProps, }); } else { - element = React.createElement(PageComponent as React.ComponentType, pageProps); + element = React.createElement(PageComponent, pageProps); } element = wrapWithRouterContext(element); @@ -774,10 +898,12 @@ async function navigateClientHtml( navId: number, assertStillCurrent: () => void, ): Promise { + let browserUrl = url; + let pendingRedirectHistoryUrl: string | null = fetchUrl === url ? null : url; const root = window.__VINEXT_ROOT__; if (!root) { // No React root yet — fall back to hard navigation - window.location.href = url; + window.location.href = browserUrl; return; } @@ -797,6 +923,14 @@ async function navigateClientHtml( } assertStillCurrent(); + if (res.redirected && res.url) { + const redirectedUrl = resolveSameOriginRedirectedUrl(res.url); + if (redirectedUrl) { + browserUrl = redirectedUrl; + pendingRedirectHistoryUrl = redirectedUrl; + } + } + if (!res.ok) { // Set window.location.href first so the browser navigates to the correct // page even if the caller suppresses the error. The assignment schedules @@ -807,7 +941,10 @@ async function navigateClientHtml( // must NOT schedule a second hard navigation — this assignment already queues // the browser fallback, and the helper-level HardNavigationScheduledError // makes that contract explicit to callers. - scheduleHardNavigationAndThrow(url, `Navigation failed: ${res.status} ${res.statusText}`); + scheduleHardNavigationAndThrow( + browserUrl, + `Navigation failed: ${res.status} ${res.statusText}`, + ); } const html = await res.text(); @@ -836,25 +973,32 @@ async function navigateClientHtml( pageModuleUrl = moduleMatch?.[1] ?? altMatch?.[1] ?? undefined; } + let pageModule: { default?: unknown; [key: string]: unknown }; if (!pageModuleUrl) { - scheduleHardNavigationAndThrow(url, "Navigation failed: no page module URL found"); - } + const loader = window.__VINEXT_PAGE_LOADERS__?.[nextData.page]; + if (!loader) { + scheduleHardNavigationAndThrow(browserUrl, "Navigation failed: no page module URL found"); + } + pageModule = await loader(); + } else { + // Validate the module URL before importing — defense-in-depth against + // unexpected __NEXT_DATA__ or malformed HTML responses + if (!isValidModulePath(pageModuleUrl)) { + console.error("[vinext] Blocked import of invalid page module path:", pageModuleUrl); + scheduleHardNavigationAndThrow(browserUrl, "Navigation failed: invalid page module path"); + } - // Validate the module URL before importing — defense-in-depth against - // unexpected __NEXT_DATA__ or malformed HTML responses - if (!isValidModulePath(pageModuleUrl)) { - console.error("[vinext] Blocked import of invalid page module path:", pageModuleUrl); - scheduleHardNavigationAndThrow(url, "Navigation failed: invalid page module path"); + // Dynamically import the new page module + pageModule = await import(/* @vite-ignore */ pageModuleUrl); } - - // Dynamically import the new page module - const pageModule = await import(/* @vite-ignore */ pageModuleUrl); assertStillCurrent(); const PageComponent = pageModule.default; - - if (!PageComponent) { - scheduleHardNavigationAndThrow(url, "Navigation failed: page module has no default export"); + if (!isPageComponent(PageComponent)) { + scheduleHardNavigationAndThrow( + browserUrl, + "Navigation failed: page module default export is not a component", + ); } // Import React for createElement @@ -871,7 +1015,7 @@ async function navigateClientHtml( } else { try { const appModule = await import(/* @vite-ignore */ appModuleUrl); - AppComponent = appModule.default; + AppComponent = isAppComponent(appModule.default) ? appModule.default : undefined; window.__VINEXT_APP__ = AppComponent; } catch { // _app not available — continue without it @@ -899,6 +1043,10 @@ async function navigateClientHtml( // checkpoint immediately after the optional _app import) through // root.render() is synchronous. If any step here ever becomes async, add // another assertStillCurrent() before writing __NEXT_DATA__. + if (pendingRedirectHistoryUrl) { + window.history.replaceState(window.history.state ?? {}, "", pendingRedirectHistoryUrl); + _lastPathnameAndSearch = window.location.pathname + window.location.search; + } window.__NEXT_DATA__ = nextData; applyVinextLocaleGlobals(window, nextData); root.render(element); @@ -936,11 +1084,36 @@ async function navigateClient(url: string, fetchUrl = url): Promise { } try { - const dataTarget = resolvePagesDataNavigationTarget(url, __basePath); + let browserUrl = url; + let htmlFetchUrl = fetchUrl; + const dataTarget = resolvePagesDataNavigationTarget(browserUrl, __basePath); + if (!dataTarget) { + let redirectLocation: string | null; + try { + redirectLocation = await resolveMiddlewareDataRedirect(browserUrl, controller.signal); + } catch (err: unknown) { + if (err instanceof DOMException && err.name === "AbortError") { + throw new NavigationCancelledError(browserUrl); + } + throw err; + } + assertStillCurrent(); + if (redirectLocation) { + const redirectedUrl = resolveLocalRedirectUrl(redirectLocation); + if (!redirectedUrl) { + scheduleHardNavigationAndThrow(redirectLocation, "Navigation redirected externally"); + } + window.history.replaceState(window.history.state ?? {}, "", redirectedUrl); + _lastPathnameAndSearch = window.location.pathname + window.location.search; + browserUrl = redirectedUrl; + htmlFetchUrl = redirectedUrl; + } + } + if (dataTarget) { - await navigateClientData(url, dataTarget, controller, navId, assertStillCurrent); + await navigateClientData(browserUrl, dataTarget, controller, navId, assertStillCurrent); } else { - await navigateClientHtml(url, fetchUrl, controller, navId, assertStillCurrent); + await navigateClientHtml(browserUrl, htmlFetchUrl, controller, navId, assertStillCurrent); } } finally { // Clean up the abort controller if this navigation is still the active one diff --git a/packages/vinext/src/shims/url-utils.ts b/packages/vinext/src/shims/url-utils.ts index 2ce3ad5f3..c9cb81eb0 100644 --- a/packages/vinext/src/shims/url-utils.ts +++ b/packages/vinext/src/shims/url-utils.ts @@ -22,16 +22,28 @@ export function isAbsoluteOrProtocolRelativeUrl(url: string): boolean { return isAbsoluteUrl(url) || url.startsWith("//"); } +export function getWindowOrigin(): string | null { + if (typeof window === "undefined") return null; + const { origin, href } = window.location; + if (origin) return origin; + try { + return new URL(href).origin; + } catch { + return null; + } +} + /** * If `url` is an absolute same-origin URL, return the local path * (pathname + search + hash). Returns null for truly external URLs * or on the server (where origin is unknown). */ export function toSameOriginPath(url: string): string | null { - if (typeof window === "undefined") return null; + const origin = getWindowOrigin(); + if (!origin) return null; try { - const parsed = url.startsWith("//") ? new URL(url, window.location.origin) : new URL(url); - if (parsed.origin === window.location.origin) { + const parsed = url.startsWith("//") ? new URL(url, origin) : new URL(url); + if (parsed.origin === origin) { return parsed.pathname + parsed.search + parsed.hash; } } catch { diff --git a/tests/link.test.ts b/tests/link.test.ts index 99d480626..8683687b2 100644 --- a/tests/link.test.ts +++ b/tests/link.test.ts @@ -1023,6 +1023,28 @@ describe("toSameOriginAppPath", () => { } }); + it("falls back to location.href when location.origin is unavailable", () => { + const originalWindow = globalThis.window; + (globalThis as any).window = { + location: { + href: "http://localhost:3000/base/posts/1", + }, + }; + + try { + expect(toSameOriginAppPath("http://localhost:3000/base/about", "/base")).toBe("/about"); + expect(toSameOriginAppPath("//localhost:3000/base/about?tab=1#top", "/base")).toBe( + "/about?tab=1#top", + ); + } finally { + if (originalWindow === undefined) { + delete (globalThis as any).window; + } else { + (globalThis as any).window = originalWindow; + } + } + }); + it("treats same-origin URLs outside the configured basePath as external", () => { const originalWindow = globalThis.window; (globalThis as any).window = { diff --git a/tests/pages-page-data.test.ts b/tests/pages-page-data.test.ts index af4dc813b..9da9362e6 100644 --- a/tests/pages-page-data.test.ts +++ b/tests/pages-page-data.test.ts @@ -83,6 +83,7 @@ describe("pages page data", () => { safeJsonStringify(value: unknown) { return JSON.stringify(value); }, + vinext: { hasMiddleware: true }, }); expect(html).toContain("
fresh-body
"); @@ -90,6 +91,7 @@ describe("pages page data", () => { expect(html).toContain(''); expect(html).toContain('"page":"/posts/[slug]"'); expect(html).toContain('"slug":"post"'); + expect(html).toContain('"__vinext":{"hasMiddleware":true}'); }); it("returns an HTML 404 when getStaticPaths excludes a dynamic path", async () => { @@ -198,6 +200,7 @@ describe("pages page data", () => { }, runInFreshUnifiedContext, triggerBackgroundRegeneration, + vinext: { hasMiddleware: true }, }), ); @@ -235,6 +238,17 @@ describe("pages page data", () => { undefined, 300, ); + expect(isrSet).toHaveBeenCalledWith( + "pages:/posts/post", + expect.objectContaining({ + kind: "PAGES", + html: expect.stringContaining('"__vinext":{"hasMiddleware":true}'), + pageData: { title: "fresh" }, + }), + 15, + undefined, + 300, + ); }); it("uses stored cache-control metadata for Pages Router cached HIT responses", async () => { diff --git a/tests/shims.test.ts b/tests/shims.test.ts index 622eccdc9..f1e382773 100644 --- a/tests/shims.test.ts +++ b/tests/shims.test.ts @@ -11546,6 +11546,28 @@ describe("Pages Router concurrent navigation", () => { return `${nextDataScript}`; } + function buildNavHtmlWithVinext( + page: string, + vinext: { pageModuleUrl?: string; appModuleUrl?: string; hasMiddleware?: boolean }, + ): string { + const nextDataScript = buildPagesNextDataScript({ + buildId: null, + i18n: {}, + pageProps: { page }, + params: {}, + routePattern: page, + safeJsonStringify, + vinext, + }); + return `${nextDataScript}`; + } + + function getFetchHref(url: RequestInfo | URL): string { + if (typeof url === "string") return url; + if (url instanceof URL) return url.href; + return url.url; + } + /** * Create a deferred promise for controlling fetch timing. */ @@ -12188,6 +12210,259 @@ describe("Pages Router concurrent navigation", () => { } }); + it("handles Pages Router middleware internal redirects as client-side redirects", async () => { + const previousWindow = (globalThis as any).window; + const originalFetch = globalThis.fetch; + const { win, replaceState, render } = createNavWindow(); + const pageModuleUrl = path.resolve(import.meta.dirname, "fixtures/client-navigation-page.tsx"); + Object.assign(win.location, { origin: "http://localhost" }); + Object.assign(win, { + __VINEXT_LOCALE__: "en", + __VINEXT_LOCALES__: ["en", "fr", "nl", "es"], + __VINEXT_DEFAULT_LOCALE__: "en", + }); + Object.assign(win.__NEXT_DATA__, { + buildId: "build-1", + __vinext: { ...win.__NEXT_DATA__.__vinext, hasMiddleware: true }, + }); + Object.assign(win, { + __VINEXT_PAGE_LOADERS__: { + "/new-home": async () => import(pageModuleUrl), + }, + }); + (globalThis as any).window = win; + + const fetch = vi.fn(async (url: RequestInfo | URL) => { + const href = getFetchHref(url); + if (href === "/_next/data/build-1/old-home.json") { + return new Response("{}", { + headers: { "x-nextjs-redirect": "/new-home" }, + status: 200, + }); + } + if (href === "/new-home") { + return new Response(buildNavHtmlWithVinext("/new-home", { hasMiddleware: true })); + } + throw new Error(`Unexpected fetch: ${href}`); + }); + 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("/old-home"); + + expect(result).toBe(true); + expect(fetch).toHaveBeenNthCalledWith( + 1, + "/_next/data/build-1/old-home.json", + expect.objectContaining({ + headers: expect.objectContaining({ "x-nextjs-data": "1" }), + }), + ); + expect(fetch).toHaveBeenNthCalledWith(2, "/new-home", expect.any(Object)); + expect(replaceState).toHaveBeenLastCalledWith({}, "", "/new-home"); + expect(win.location.pathname).toBe("/new-home"); + expect(render).toHaveBeenCalled(); + } finally { + vi.resetModules(); + if (previousWindow === undefined) { + delete (globalThis as any).window; + } else { + (globalThis as any).window = previousWindow; + } + globalThis.fetch = originalFetch; + } + }); + + it("does not double-prefix basePath for middleware data redirects", async () => { + const previousWindow = (globalThis as any).window; + const originalFetch = globalThis.fetch; + const previousBasePath = process.env.__NEXT_ROUTER_BASEPATH; + const { win, replaceState, render } = createNavWindow(); + const pageModuleUrl = path.resolve(import.meta.dirname, "fixtures/client-navigation-page.tsx"); + Object.assign(win.location, { + origin: "http://localhost", + pathname: "/docs", + href: "http://localhost/docs", + }); + Object.assign(win.__NEXT_DATA__, { + buildId: "build-1", + __vinext: { ...win.__NEXT_DATA__.__vinext, hasMiddleware: true }, + }); + Object.assign(win, { + __VINEXT_PAGE_LOADERS__: { + "/new-home": async () => import(pageModuleUrl), + }, + }); + process.env.__NEXT_ROUTER_BASEPATH = "/docs"; + (globalThis as any).window = win; + + const fetch = vi.fn(async (url: RequestInfo | URL) => { + const href = getFetchHref(url); + if (href === "/docs/_next/data/build-1/old-home.json") { + return new Response("{}", { + headers: { "x-nextjs-redirect": "/docs/new-home" }, + status: 200, + }); + } + if (href === "/docs/new-home") { + return new Response(buildNavHtmlWithVinext("/new-home", { hasMiddleware: true })); + } + throw new Error(`Unexpected fetch: ${href}`); + }); + 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("/old-home"); + + expect(result).toBe(true); + expect(fetch).toHaveBeenNthCalledWith( + 1, + "/docs/_next/data/build-1/old-home.json", + expect.objectContaining({ + headers: expect.objectContaining({ "x-nextjs-data": "1" }), + }), + ); + expect(fetch).toHaveBeenNthCalledWith(2, "/docs/new-home", expect.any(Object)); + expect(fetch).not.toHaveBeenCalledWith("/docs/docs/new-home", expect.any(Object)); + expect(replaceState).toHaveBeenLastCalledWith({}, "", "/docs/new-home"); + expect(win.location.href).toBe("http://localhost/docs/new-home"); + expect(render).toHaveBeenCalled(); + } finally { + vi.resetModules(); + if (previousBasePath === undefined) { + delete process.env.__NEXT_ROUTER_BASEPATH; + } else { + process.env.__NEXT_ROUTER_BASEPATH = previousBasePath; + } + if (previousWindow === undefined) { + delete (globalThis as any).window; + } else { + (globalThis as any).window = previousWindow; + } + globalThis.fetch = originalFetch; + } + }); + + it("falls through to normal page navigation when the middleware data probe fails", async () => { + const previousWindow = (globalThis as any).window; + const originalFetch = globalThis.fetch; + const { win, render } = createNavWindow(); + const pageModuleUrl = path.resolve(import.meta.dirname, "fixtures/client-navigation-page.tsx"); + Object.assign(win.location, { origin: "http://localhost" }); + Object.assign(win.__NEXT_DATA__, { + buildId: "build-1", + __vinext: { ...win.__NEXT_DATA__.__vinext, hasMiddleware: true }, + }); + (globalThis as any).window = win; + + const fetch = vi.fn(async (url: RequestInfo | URL) => { + const href = getFetchHref(url); + if (href === "/_next/data/build-1/old-home.json") { + throw new TypeError("probe failed"); + } + if (href === "/old-home") { + return new Response(buildNavHtml("/old-home", pageModuleUrl)); + } + throw new Error(`Unexpected fetch: ${href}`); + }); + 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("/old-home"); + + expect(result).toBe(true); + expect(fetch).toHaveBeenNthCalledWith( + 1, + "/_next/data/build-1/old-home.json", + expect.objectContaining({ + headers: expect.objectContaining({ "x-nextjs-data": "1" }), + }), + ); + expect(fetch).toHaveBeenNthCalledWith(2, "/old-home", expect.any(Object)); + expect(win.location.href).toBe("http://localhost/old-home"); + expect(render).toHaveBeenCalled(); + } finally { + vi.resetModules(); + if (previousWindow === undefined) { + delete (globalThis as any).window; + } else { + (globalThis as any).window = previousWindow; + } + globalThis.fetch = originalFetch; + } + }); + + it("hard-navigates to the final middleware redirect URL when it is not a page", async () => { + const previousWindow = (globalThis as any).window; + const originalFetch = globalThis.fetch; + const { win } = createNavWindow(); + const hrefAssignments = trackHrefAssignments(win); + Object.assign(win.location, { origin: "http://localhost" }); + Object.assign(win, { + __VINEXT_LOCALE__: "en", + __VINEXT_LOCALES__: ["en", "fr", "nl", "es"], + __VINEXT_DEFAULT_LOCALE__: "en", + }); + Object.assign(win.__NEXT_DATA__, { + buildId: "build-1", + __vinext: { ...win.__NEXT_DATA__.__vinext, hasMiddleware: true }, + }); + (globalThis as any).window = win; + + const fetch = vi.fn(async (url: RequestInfo | URL) => { + const href = getFetchHref(url); + if (href === "/_next/data/build-1/nl/to.json?pathname=/api/ok") { + return new Response("{}", { + headers: { "x-nextjs-redirect": "/nl/api/ok" }, + status: 200, + }); + } + if (href === "/api/ok") return new Response("ok", { status: 200 }); + throw new Error(`Unexpected fetch: ${href}`); + }); + 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("/to?pathname=/api/ok", undefined, { locale: "nl" }); + + expect(result).toBe(false); + expect(fetch).toHaveBeenNthCalledWith( + 1, + "/_next/data/build-1/nl/to.json?pathname=/api/ok", + expect.objectContaining({ + headers: expect.objectContaining({ "x-nextjs-data": "1" }), + }), + ); + expect(hrefAssignments).toContain("http://localhost/nl/to?pathname=/api/ok"); + expect(hrefAssignments).toContain("/api/ok"); + expect(hrefAssignments).not.toContain("/nl/api/ok"); + } finally { + vi.resetModules(); + if (previousWindow === undefined) { + delete (globalThis as any).window; + } else { + (globalThis as any).window = previousWindow; + } + globalThis.fetch = originalFetch; + } + }); + async function expectBasePathHashOnlyPush({ browserPath, target,