diff --git a/packages/vinext/src/shims/router.ts b/packages/vinext/src/shims/router.ts index f3bb4ea6..0df3bd1d 100644 --- a/packages/vinext/src/shims/router.ts +++ b/packages/vinext/src/shims/router.ts @@ -340,11 +340,59 @@ function getPathnameAndQuery(): { return { pathname, query, asPath }; } +/** + * Error thrown when a navigation is superseded by a newer one. + * Matches Next.js's convention of an Error with `.cancelled = true`. + */ +class NavigationCancelledError extends Error { + cancelled = true; + constructor(route: string) { + super(`Abort fetching component for route: "${route}"`); + this.name = "NavigationCancelledError"; + } +} + +/** + * Error thrown after queueing a hard navigation fallback for a known failure + * mode. Callers can use this to avoid scheduling the same hard navigation twice. + */ +class HardNavigationScheduledError extends Error { + hardNavigationScheduled = true; + constructor(message: string) { + super(message); + this.name = "HardNavigationScheduledError"; + } +} + +/** + * Monotonically increasing ID for tracking the current navigation. + * Each call to navigateClient() increments this and captures the value. + * After each async boundary, the navigation checks whether it is still + * the active one. If a newer navigation has started, the stale one + * throws NavigationCancelledError so the caller can emit routeChangeError + * and skip routeChangeComplete. + * + * Replaces the old boolean `_navInProgress` guard which silently dropped + * the second navigation, causing URL/content mismatch. + */ +let _navigationId = 0; + +/** AbortController for the in-flight fetch, so superseded navigations abort network I/O. */ +let _activeAbortController: AbortController | null = null; + +function scheduleHardNavigationAndThrow(url: string, message: string): never { + window.location.href = url; + throw new HardNavigationScheduledError(message); +} + /** * Perform client-side navigation: fetch the target page's HTML, * extract __NEXT_DATA__, and re-render the React root. + * + * Throws NavigationCancelledError if a newer navigation supersedes this one. + * Throws on hard-navigation failures (non-OK response, missing data) so the + * caller can distinguish success from failure for event emission. */ -let _navInProgress = false; async function navigateClient(url: string): Promise { if (typeof window === "undefined") return; @@ -355,30 +403,62 @@ async function navigateClient(url: string): Promise { return; } - // Prevent re-entrant navigation (e.g., double popstate events) - if (_navInProgress) return; - _navInProgress = true; + // Cancel any in-flight navigation (abort its fetch, mark it stale) + _activeAbortController?.abort(); + const controller = new AbortController(); + _activeAbortController = controller; + + const navId = ++_navigationId; + + /** Check if this navigation is still the active one. If not, throw. */ + function assertStillCurrent(): void { + if (navId !== _navigationId) { + throw new NavigationCancelledError(url); + } + } try { // Fetch the target page's SSR HTML - const res = await fetch(url, { headers: { Accept: "text/html" } }); + let res: Response; + try { + res = await fetch(url, { + headers: { Accept: "text/html" }, + signal: controller.signal, + }); + } catch (err: unknown) { + // AbortError means a newer navigation cancelled this fetch + if (err instanceof DOMException && err.name === "AbortError") { + throw new NavigationCancelledError(url); + } + throw err; + } + assertStillCurrent(); + if (!res.ok) { - window.location.href = url; - return; + // 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 + // listeners still run — and observe the error — before the page unloads. + // Contract: routeChangeError listeners MUST be synchronous; async listeners + // will not fire before the navigation completes. Callers (runNavigateClient) + // must NOT schedule a second hard navigation — this assignment already queues one. + scheduleHardNavigationAndThrow(url, `Navigation failed: ${res.status} ${res.statusText}`); } const html = await res.text(); + assertStillCurrent(); // Extract __NEXT_DATA__ from the HTML const match = html.match(/`; + } + + /** + * Create a deferred promise for controlling fetch timing. + */ + function createDeferred() { + let resolve!: (value: T) => void; + let reject!: (reason: unknown) => void; + const promise = new Promise((res, rej) => { + resolve = res; + reject = rej; + }); + return { promise, resolve, reject }; + } + + function trackHrefAssignments(win: { + location: { + href: string; + }; + }): string[] { + let currentHref = win.location.href; + const assignments: string[] = []; + + Object.defineProperty(win.location, "href", { + configurable: true, + enumerable: true, + get: () => currentHref, + set: (value: string) => { + currentHref = value; + assignments.push(value); + }, + }); + + return assignments; + } + + it("last push() wins when two overlap — superseded navigation does not render", async () => { + const previousWindow = (globalThis as any).window; + const originalFetch = globalThis.fetch; + const { win } = createNavWindow(); + (globalThis as any).window = win; + + // Two deferred fetches so we control resolution order + const fetchA = createDeferred(); + const fetchB = createDeferred(); + let fetchCount = 0; + + globalThis.fetch = async (_url: any, _init: any) => { + fetchCount++; + if (fetchCount === 1) return fetchA.promise; + return fetchB.promise; + }; + + try { + const routerModule = await import("../packages/vinext/src/shims/router.js"); + const Router = routerModule.default; + + // Start two navigations — don't await yet + const navA = Router.push("/page-a"); + // Let microtask queue process so navA's fetch has been called + await Promise.resolve(); + const navB = Router.push("/page-b"); + + // Resolve B first (the winning navigation) + fetchB.resolve(new Response(buildNavHtml("/page-b", "/@fs/pages/page-b.js"))); + + // Resolve A after B (stale — should be ignored) + fetchA.resolve(new Response(buildNavHtml("/page-a", "/@fs/pages/page-a.js"))); + + await Promise.allSettled([navA, navB]); + + // The superseded navigation (page-a) must NOT have committed its data. + // In a real browser page-b would render; in the test env B's dynamic import + // may fail, so we verify the important invariant: A never writes. + expect(win.__NEXT_DATA__.page).not.toBe("/page-a"); + } finally { + if (previousWindow === undefined) { + delete (globalThis as any).window; + } else { + (globalThis as any).window = previousWindow; + } + globalThis.fetch = originalFetch; + } + }); + + it("routeChangeComplete does not fire for the superseded navigation", async () => { + const previousWindow = (globalThis as any).window; + const originalFetch = globalThis.fetch; + const { win } = createNavWindow(); + (globalThis as any).window = win; + + const fetchA = createDeferred(); + const fetchB = createDeferred(); + let fetchCount = 0; + + globalThis.fetch = async (_url: any, _init: any) => { + fetchCount++; + if (fetchCount === 1) return fetchA.promise; + return fetchB.promise; + }; + + const completedUrls: string[] = []; + const onRouteChangeComplete = (...args: unknown[]) => { + completedUrls.push(String(args[0])); + }; + + try { + const routerModule = await import("../packages/vinext/src/shims/router.js"); + const Router = routerModule.default; + + Router.events.on("routeChangeComplete", onRouteChangeComplete); + + // Start two overlapping navigations + const navA = Router.push("/page-a"); + await Promise.resolve(); + const navB = Router.push("/page-b"); + + // Resolve B first, then A + fetchB.resolve(new Response(buildNavHtml("/page-b", "/@fs/pages/page-b.js"))); + fetchA.resolve(new Response(buildNavHtml("/page-a", "/@fs/pages/page-a.js"))); + + await Promise.allSettled([navA, navB]); + + // The superseded navigation (page-a) must NOT fire routeChangeComplete. + // page-b may or may not complete fully (dynamic import may fail in test + // env), but that's a separate concern. The critical fix is that the + // cancelled navigation never fires routeChangeComplete. + expect(completedUrls).not.toContain("/page-a"); + } finally { + const routerModule = await import("../packages/vinext/src/shims/router.js"); + const Router = routerModule.default; + Router.events.off("routeChangeComplete", onRouteChangeComplete); + if (previousWindow === undefined) { + delete (globalThis as any).window; + } else { + (globalThis as any).window = previousWindow; + } + globalThis.fetch = originalFetch; + } + }); + + it("routeChangeError fires for superseded navigation with cancelled error", async () => { + const previousWindow = (globalThis as any).window; + const originalFetch = globalThis.fetch; + const { win } = createNavWindow(); + (globalThis as any).window = win; + + const fetchA = createDeferred(); + const fetchB = createDeferred(); + let fetchCount = 0; + + globalThis.fetch = async (_url: any, _init: any) => { + fetchCount++; + if (fetchCount === 1) return fetchA.promise; + return fetchB.promise; + }; + + const errors: Array<{ err: unknown; url: string }> = []; + const onRouteChangeError = (...args: unknown[]) => { + errors.push({ err: args[0], url: String(args[1]) }); + }; + + try { + const routerModule = await import("../packages/vinext/src/shims/router.js"); + const Router = routerModule.default; + + Router.events.on("routeChangeError", onRouteChangeError); + + // Start two overlapping navigations + const navA = Router.push("/page-a"); + await Promise.resolve(); + const navB = Router.push("/page-b"); + + // Resolve both + fetchB.resolve(new Response(buildNavHtml("/page-b", "/@fs/pages/page-b.js"))); + fetchA.resolve(new Response(buildNavHtml("/page-a", "/@fs/pages/page-a.js"))); + + await Promise.allSettled([navA, navB]); + + // The superseded navigation (page-a) should emit routeChangeError + // with a cancelled error, matching Next.js behavior + const cancelledError = errors.find((e) => e.url === "/page-a"); + expect(cancelledError).toBeDefined(); + const errObj = cancelledError?.err; + expect(errObj).toHaveProperty("cancelled", true); + } finally { + const routerModule = await import("../packages/vinext/src/shims/router.js"); + const Router = routerModule.default; + Router.events.off("routeChangeError", onRouteChangeError); + if (previousWindow === undefined) { + delete (globalThis as any).window; + } else { + (globalThis as any).window = previousWindow; + } + globalThis.fetch = originalFetch; + } + }); + + it("failed navigation (non-OK response) does not emit routeChangeComplete", async () => { + const previousWindow = (globalThis as any).window; + const originalFetch = globalThis.fetch; + const { win } = createNavWindow(); + (globalThis as any).window = win; + + globalThis.fetch = async (_url: any, _init: any) => + new Response("Internal Server Error", { status: 500 }); + + const completedUrls: string[] = []; + const errorUrls: string[] = []; + const onRouteChangeComplete = (...args: unknown[]) => { + completedUrls.push(String(args[0])); + }; + const onRouteChangeError = (...args: unknown[]) => { + errorUrls.push(String(args[1])); + }; + + try { + const routerModule = await import("../packages/vinext/src/shims/router.js"); + const Router = routerModule.default; + + Router.events.on("routeChangeComplete", onRouteChangeComplete); + Router.events.on("routeChangeError", onRouteChangeError); + + await Router.push("/failing-page"); + + // Should NOT have fired routeChangeComplete for a failed navigation + expect(completedUrls).not.toContain("/failing-page"); + // Should have fired routeChangeError + expect(errorUrls).toContain("/failing-page"); + } finally { + const routerModule = await import("../packages/vinext/src/shims/router.js"); + const Router = routerModule.default; + Router.events.off("routeChangeComplete", onRouteChangeComplete); + Router.events.off("routeChangeError", onRouteChangeError); + if (previousWindow === undefined) { + delete (globalThis as any).window; + } else { + (globalThis as any).window = previousWindow; + } + globalThis.fetch = originalFetch; + } + }); + + it("known navigation failures schedule a single hard navigation", async () => { + const previousWindow = (globalThis as any).window; + const originalFetch = globalThis.fetch; + const { win } = createNavWindow(); + const hrefAssignments = trackHrefAssignments(win); + (globalThis as any).window = win; + + globalThis.fetch = async (_url: any, _init: any) => + new Response("Internal Server Error", { status: 500 }); + + try { + const routerModule = await import("../packages/vinext/src/shims/router.js"); + const Router = routerModule.default; + + const result = await Router.push("/failing-page"); + + expect(result).toBe(false); + expect(hrefAssignments.filter((value) => value.endsWith("/failing-page"))).toHaveLength(2); + } finally { + if (previousWindow === undefined) { + delete (globalThis as any).window; + } else { + (globalThis as any).window = previousWindow; + } + globalThis.fetch = originalFetch; + } + }); + + it("replace() also cancels superseded navigation", async () => { + const previousWindow = (globalThis as any).window; + const originalFetch = globalThis.fetch; + const { win } = createNavWindow(); + (globalThis as any).window = win; + + const fetchA = createDeferred(); + const fetchB = createDeferred(); + let fetchCount = 0; + + globalThis.fetch = async (_url: any, _init: any) => { + fetchCount++; + if (fetchCount === 1) return fetchA.promise; + return fetchB.promise; + }; + + const completedUrls: string[] = []; + const errors: Array<{ err: unknown; url: string }> = []; + const onRouteChangeComplete = (...args: unknown[]) => { + completedUrls.push(String(args[0])); + }; + const onRouteChangeError = (...args: unknown[]) => { + errors.push({ err: args[0], url: String(args[1]) }); + }; + + try { + const routerModule = await import("../packages/vinext/src/shims/router.js"); + const Router = routerModule.default; + + Router.events.on("routeChangeComplete", onRouteChangeComplete); + Router.events.on("routeChangeError", onRouteChangeError); + + // First push, then replace overlapping + const navA = Router.push("/page-a"); + await Promise.resolve(); + const navB = Router.replace("/page-b"); + + fetchB.resolve(new Response(buildNavHtml("/page-b", "/@fs/pages/page-b.js"))); + fetchA.resolve(new Response(buildNavHtml("/page-a", "/@fs/pages/page-a.js"))); + + await Promise.allSettled([navA, navB]); + + // The superseded push (page-a) should be cancelled, not completed + expect(completedUrls).not.toContain("/page-a"); + // page-a should have a cancelled error + const cancelledA = errors.find((e) => e.url === "/page-a"); + expect(cancelledA).toBeDefined(); + expect(cancelledA?.err).toHaveProperty("cancelled", true); + } finally { + const routerModule = await import("../packages/vinext/src/shims/router.js"); + const Router = routerModule.default; + Router.events.off("routeChangeComplete", onRouteChangeComplete); + Router.events.off("routeChangeError", onRouteChangeError); + if (previousWindow === undefined) { + delete (globalThis as any).window; + } else { + (globalThis as any).window = previousWindow; + } + globalThis.fetch = originalFetch; + } + }); + + it("abort signal fires when navigation is superseded — AbortError becomes NavigationCancelledError", async () => { + // Verify that the AbortController signal passed to fetch actually fires when a + // newer navigation starts, and that the resulting AbortError is converted into + // a NavigationCancelledError (the routeChangeError path, not a plain rejection). + const previousWindow = (globalThis as any).window; + const originalFetch = globalThis.fetch; + const { win } = createNavWindow(); + (globalThis as any).window = win; + + const fetchA = createDeferred(); + const fetchB = createDeferred(); + let fetchCount = 0; + + // Signal-aware mock: the first fetch rejects with AbortError when its signal fires. + globalThis.fetch = async (_url: any, _init: any) => { + fetchCount++; + if (fetchCount === 1) { + return new Promise((resolve, reject) => { + fetchA.promise.then(resolve, reject); + _init?.signal?.addEventListener("abort", () => { + reject(new DOMException("The operation was aborted.", "AbortError")); + }); + }); + } + return fetchB.promise; + }; + + const errors: Array<{ err: unknown; url: string }> = []; + const onRouteChangeError = (...args: unknown[]) => { + errors.push({ err: args[0], url: String(args[1]) }); + }; + + try { + const routerModule = await import("../packages/vinext/src/shims/router.js"); + const Router = routerModule.default; + + Router.events.on("routeChangeError", onRouteChangeError); + + // Start navigation A then immediately supersede it with B + const navA = Router.push("/page-a"); + await Promise.resolve(); + const navB = Router.push("/page-b"); + + // Resolve B; A is aborted via its signal — no manual resolution needed + fetchB.resolve(new Response(buildNavHtml("/page-b", "/@fs/pages/page-b.js"))); + + await Promise.allSettled([navA, navB]); + + // navA's fetch was aborted via signal → AbortError → NavigationCancelledError + const cancelledError = errors.find((e) => e.url === "/page-a"); + expect(cancelledError).toBeDefined(); + expect(cancelledError?.err).toHaveProperty("cancelled", true); + } finally { + const routerModule = await import("../packages/vinext/src/shims/router.js"); + const Router = routerModule.default; + Router.events.off("routeChangeError", onRouteChangeError); + if (previousWindow === undefined) { + delete (globalThis as any).window; + } else { + (globalThis as any).window = previousWindow; + } + globalThis.fetch = originalFetch; + } + }); + + it("stale response arriving first does not render before the winning navigation", async () => { + // fetchA (stale, page-a) resolves before fetchB (winning, page-b). + // assertStillCurrent() in navigateClient must catch the stale navigation + // after it processes the response, so page-a's data never reaches the DOM. + const previousWindow = (globalThis as any).window; + const originalFetch = globalThis.fetch; + const { win } = createNavWindow(); + (globalThis as any).window = win; + + const fetchA = createDeferred(); + const fetchB = createDeferred(); + let fetchCount = 0; + + globalThis.fetch = async (_url: any, _init: any) => { + fetchCount++; + if (fetchCount === 1) return fetchA.promise; + return fetchB.promise; + }; + + const completedUrls: string[] = []; + const onRouteChangeComplete = (...args: unknown[]) => { + completedUrls.push(String(args[0])); + }; + + try { + const routerModule = await import("../packages/vinext/src/shims/router.js"); + const Router = routerModule.default; + + Router.events.on("routeChangeComplete", onRouteChangeComplete); + + // Start two navigations + const navA = Router.push("/page-a"); + await Promise.resolve(); + const navB = Router.push("/page-b"); + + // Stale fetch (A) resolves first this time + fetchA.resolve(new Response(buildNavHtml("/page-a", "/@fs/pages/page-a.js"))); + // Winning fetch (B) resolves after + fetchB.resolve(new Response(buildNavHtml("/page-b", "/@fs/pages/page-b.js"))); + + await Promise.allSettled([navA, navB]); + + // Stale navigation must not have committed its data to the DOM. + // B may also fail at dynamic import in test env, so we only verify A never wrote. + expect(win.__NEXT_DATA__.page).not.toBe("/page-a"); + // Stale navigation must not fire routeChangeComplete + expect(completedUrls).not.toContain("/page-a"); + } finally { + const routerModule = await import("../packages/vinext/src/shims/router.js"); + const Router = routerModule.default; + Router.events.off("routeChangeComplete", onRouteChangeComplete); + if (previousWindow === undefined) { + delete (globalThis as any).window; + } else { + (globalThis as any).window = previousWindow; + } + globalThis.fetch = originalFetch; + } + }); + + it("__NEXT_DATA__ is not stale when routeChangeError fires for a cancelled navigation", async () => { + // Regression test: __NEXT_DATA__ must not reflect the cancelled route's data + // at the moment routeChangeError fires. The fix defers the global write until + // just before root.render(), after all assertStillCurrent() checks pass. + const previousWindow = (globalThis as any).window; + const originalFetch = globalThis.fetch; + const { win } = createNavWindow(); + (globalThis as any).window = win; + + const fetchA = createDeferred(); + const fetchB = createDeferred(); + let fetchCount = 0; + + globalThis.fetch = async (_url: any, _init: any) => { + fetchCount++; + if (fetchCount === 1) return fetchA.promise; + return fetchB.promise; + }; + + // Track __NEXT_DATA__.page at the moment of each routeChangeError + const nextDataPageAtError: string[] = []; + const onRouteChangeError = () => { + nextDataPageAtError.push(win.__NEXT_DATA__.page); + }; + + try { + const routerModule = await import("../packages/vinext/src/shims/router.js"); + const Router = routerModule.default; + + Router.events.on("routeChangeError", onRouteChangeError); + + // Start two navigations — A will be superseded by B + const navA = Router.push("/page-a"); + await Promise.resolve(); + const navB = Router.push("/page-b"); + + // Resolve stale (A) first, then winning (B) + fetchA.resolve(new Response(buildNavHtml("/page-a", "/@fs/pages/page-a.js"))); + fetchB.resolve(new Response(buildNavHtml("/page-b", "/@fs/pages/page-b.js"))); + + await Promise.allSettled([navA, navB]); + + // At the moment routeChangeError fired for nav A, __NEXT_DATA__ must NOT + // have been overwritten with page-a's data + for (const page of nextDataPageAtError) { + expect(page).not.toBe("/page-a"); + } + } finally { + const routerModule = await import("../packages/vinext/src/shims/router.js"); + const Router = routerModule.default; + Router.events.off("routeChangeError", onRouteChangeError); + if (previousWindow === undefined) { + delete (globalThis as any).window; + } else { + (globalThis as any).window = previousWindow; + } + globalThis.fetch = originalFetch; + } + }); +}); + describe("next/server enhancements", () => { it("NextRequest.ip extracts from x-forwarded-for header", async () => { const { NextRequest } = await import("../packages/vinext/src/shims/server.js");