Files
cloudflare__vinext/tests/pages-router-app-prefetch-detection.test.ts
T
James Anderson feb27e8422 fix(client): isolate Pages Router module graph (#2380)
* fix(client): isolate Pages Router module graph

* fix(client): trim hybrid route ownership graph

* fix(router): preserve document-only hard navigation
2026-06-26 20:52:13 +01:00

327 lines
12 KiB
TypeScript

/**
* Pages Router → App Router prefetch detection.
*
* Ported from Next.js: test/e2e/app-dir/app/index.test.ts
* https://github.com/vercel/next.js/blob/canary/test/e2e/app-dir/app/index.test.ts
* it('should successfully detect app route during prefetch', ...)
*
* In a hybrid Pages Router + App Router app, when the Pages Router prefetches
* a `<Link href="/path">` whose target is actually an App Router route, Next.js
* marks the route on the Pages Router singleton with
* `router.components[urlPathname] = { __appRouter: true }` so the subsequent
* navigation handoff to the App Router can be detected.
*
* See: .nextjs-ref/packages/next/src/shared/lib/router/router.ts:2525 — when
* the bloom-filter `_bfl` check matches (i.e., the target is an app route),
* Next.js records the route on `this.components` with `__appRouter: true`.
*
* Vinext does not use a bloom filter; the App Router prefetch route manifest
* (`__VINEXT_LINK_PREFETCH_ROUTES__`) is available on the client and the same
* pattern matcher used by Link's App Router auto-prefetch decides if the
* target is an app route. This file pins that signal.
*
* Issue: https://github.com/cloudflare/vinext/issues/1526
*/
import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test";
afterEach(() => {
vi.resetModules();
delete (globalThis as Record<symbol, unknown>)[Symbol.for("vinext.pagesRouter.components")];
delete (globalThis as { window?: unknown }).window;
delete (globalThis as { document?: unknown }).document;
});
type FakeWindow = {
location: {
pathname: string;
search: string;
hash: string;
href: string;
origin: string;
assign: ReturnType<typeof vi.fn>;
replace: ReturnType<typeof vi.fn>;
};
history: { state: null; pushState(): void; replaceState(): void };
addEventListener(): void;
dispatchEvent(): void;
__VINEXT_LINK_PREFETCH_ROUTES__: Array<{
canPrefetchLoadingShell: boolean;
patternParts: string[];
isDynamic: boolean;
}>;
__VINEXT_PAGES_LINK_PREFETCH_ROUTES__?: Array<{
canPrefetchLoadingShell: boolean;
documentOnly?: boolean;
patternParts: string[];
isDynamic: boolean;
}>;
__VINEXT_LOCALES__?: string[];
__VINEXT_DEFAULT_LOCALE__?: string;
next?: unknown;
};
function installFakeBrowserGlobals(
prefetchRoutes: Array<{
canPrefetchLoadingShell: boolean;
patternParts: string[];
isDynamic: boolean;
}>,
): FakeWindow {
// Minimal fake window/document so importing shims/router.ts (which touches
// window at module load to attach popstate) does not crash.
const fakeWindow: FakeWindow = {
location: {
pathname: "/",
search: "",
hash: "",
href: "http://localhost/",
origin: "http://localhost",
assign: vi.fn(),
replace: vi.fn(),
},
history: { state: null, pushState() {}, replaceState() {} },
addEventListener() {},
dispatchEvent() {},
__VINEXT_LINK_PREFETCH_ROUTES__: prefetchRoutes,
};
(globalThis as { window?: unknown }).window = fakeWindow;
(globalThis as { document?: unknown }).document = {
addEventListener() {},
createElement: () => ({
setAttribute() {},
set rel(_: string) {},
set href(_: string) {},
set as(_: string) {},
set crossOrigin(_: string) {},
}),
head: { appendChild() {} },
};
return fakeWindow;
}
describe("Pages Router records app routes as detected on prefetch", () => {
beforeEach(() => {
vi.resetModules();
});
it("exposes a `components` object on the Pages Router singleton", async () => {
installFakeBrowserGlobals([]);
const routerModule = await import("../packages/vinext/src/shims/router.js");
const Router = routerModule.default;
expect(Router.components).toBeDefined();
expect(typeof Router.components).toBe("object");
});
it("marks a prefetched App Router target as { __appRouter: true } on components", async () => {
installFakeBrowserGlobals([
// /dashboard is a static App Router route
{ canPrefetchLoadingShell: false, patternParts: ["dashboard"], isDynamic: false },
]);
const routerModule = await import("../packages/vinext/src/shims/router.js");
const Router = routerModule.default;
await Router.prefetch("/dashboard");
expect(Router.components["/dashboard"]).toEqual({ __appRouter: true });
});
it("does not mark unrelated routes that are not in the App Router manifest", async () => {
installFakeBrowserGlobals([
{ canPrefetchLoadingShell: false, patternParts: ["dashboard"], isDynamic: false },
]);
const routerModule = await import("../packages/vinext/src/shims/router.js");
const Router = routerModule.default;
await Router.prefetch("/some-random-pages-route");
expect(Router.components["/some-random-pages-route"]).toBeUndefined();
});
it("is exposed via window.next.router.components", async () => {
installFakeBrowserGlobals([
{ canPrefetchLoadingShell: false, patternParts: ["dashboard"], isDynamic: false },
]);
const routerModule = await import("../packages/vinext/src/shims/router.js");
const Router = routerModule.default;
await Router.prefetch("/dashboard");
// The Next.js test reads through window.next.router.components — verify
// the singleton wired into window.next is the same object.
const win = (
globalThis as { window: { next?: { router?: { components?: Record<string, unknown> } } } }
).window;
expect(win.next?.router?.components?.["/dashboard"]).toEqual({ __appRouter: true });
expect(win.next?.router).toBe(Router);
});
it("stores the marker under the trailing-slash-stripped key (trailingSlash: true compat)", async () => {
// When trailingSlash:true, link.tsx normalises /about → /about/ before
// calling markAppRouteDetectedOnPrefetch. The key must still be stored
// without the trailing slash so the read-side lookup in performNavigation
// (which also strips) always hits regardless of which form was written.
installFakeBrowserGlobals([
{ canPrefetchLoadingShell: false, patternParts: ["about"], isDynamic: false },
]);
const routerModule = await import("../packages/vinext/src/shims/router.js");
const Router = routerModule.default;
// Simulate the call that link.tsx makes when trailingSlash:true adds a slash.
await Router.prefetch("/about/");
// Key must be the stripped form, not "/about/".
expect(Router.components["/about"]).toEqual({ __appRouter: true });
expect(Router.components["/about/"]).toBeUndefined();
});
it("fast-paths to window.location.assign when the __appRouter marker is set", async () => {
// This pins the new performNavigation fast-path: after a prefetch that
// records the __appRouter marker, the next navigation must call
// window.location.assign (hard nav) rather than falling through to the
// Pages Router SPA fetch cycle.
const fakeWindow = installFakeBrowserGlobals([
{ canPrefetchLoadingShell: false, patternParts: ["about"], isDynamic: false },
]);
const routerModule = await import("../packages/vinext/src/shims/router.js");
const Router = routerModule.default;
// 1. Prefetch records the marker.
await Router.prefetch("/about");
expect(Router.components["/about"]).toEqual({ __appRouter: true });
// 2. Navigate — the fast-path must invoke window.location.assign.
// We don't await the returned Promise because the fast-path returns a
// never-resolving Promise (matching Next.js's own "freeze" pattern).
// There is no `await` between performNavigation entry and the
// fast-path return, so `assign` must have fired synchronously by the
// time `push()` returns — assert immediately to pin that synchronicity
// (the hard navigation must not lose a race against the SPA path).
void Router.push("/about");
expect(fakeWindow.location.assign).toHaveBeenCalledWith(expect.stringContaining("/about"));
});
it("hard-navigates to an App Router route even when prefetch has not completed", async () => {
// Ported from Next.js:
// test/e2e/app-dir/pages-to-app-routing/pages-to-app-routing.test.ts
// https://github.com/vercel/next.js/blob/canary/test/e2e/app-dir/pages-to-app-routing/pages-to-app-routing.test.ts
//
// Link viewport prefetch is asynchronous. A user can click before it has
// written the __appRouter marker, so navigation must also consult the
// generated App Router route manifest directly.
const fakeWindow = installFakeBrowserGlobals([
{ canPrefetchLoadingShell: false, patternParts: ["about"], isDynamic: false },
]);
const routerModule = await import("../packages/vinext/src/shims/router.js");
const Router = routerModule.default;
expect(Router.components["/about"]).toBeUndefined();
void Router.push("/about");
expect(fakeWindow.location.assign).toHaveBeenCalledWith(expect.stringContaining("/about"));
});
it("hard-navigates pure-Pages document-only routes", async () => {
const fakeWindow = installFakeBrowserGlobals([]);
fakeWindow.__VINEXT_PAGES_LINK_PREFETCH_ROUTES__ = [
{
canPrefetchLoadingShell: false,
documentOnly: true,
patternParts: ["api", ":slug"],
isDynamic: true,
},
];
const routerModule = await import("../packages/vinext/src/shims/router.js");
void routerModule.default.push("/api/foo");
expect(fakeWindow.location.assign).toHaveBeenCalledWith(expect.stringContaining("/api/foo"));
});
it.each([
["static route", "/about", ["about"]],
["dynamic route", "/blog/hello", ["blog", ":slug"]],
["route-group route", "/grouped", ["grouped"]],
["trailing slash", "/about/", ["about"]],
["query and hash", "/about?from=pages#details", ["about"]],
["interception target", "/photos/123", ["photos", ":id"]],
])("detects %s destinations", async (_label, href, patternParts) => {
installFakeBrowserGlobals([
{
canPrefetchLoadingShell: false,
patternParts,
isDynamic: patternParts.some((part) => part.startsWith(":")),
},
]);
const { getPagesRouterComponentsMap, markAppRouteDetectedOnPrefetch } =
await import("../packages/vinext/src/shims/internal/app-route-detection.js");
await markAppRouteDetectedOnPrefetch(href, "");
expect(
getPagesRouterComponentsMap()[new URL(href, "http://localhost").pathname.replace(/\/$/, "")],
).toEqual({
__appRouter: true,
});
});
it("strips basePath and locale prefixes before matching App routes", async () => {
const fakeWindow = installFakeBrowserGlobals([
{ canPrefetchLoadingShell: false, patternParts: ["about"], isDynamic: false },
]);
fakeWindow.__VINEXT_LOCALES__ = ["en", "fr"];
fakeWindow.__VINEXT_DEFAULT_LOCALE__ = "en";
const { markAppRouteDetectedOnPrefetch } =
await import("../packages/vinext/src/shims/internal/app-route-detection.js");
await markAppRouteDetectedOnPrefetch("/docs/fr/about?from=pages#details", "/docs");
const routerModule = await import("../packages/vinext/src/shims/router.js");
expect(routerModule.default.components["/about"]).toEqual({ __appRouter: true });
});
it("does not classify external URLs as App routes", async () => {
installFakeBrowserGlobals([
{ canPrefetchLoadingShell: false, patternParts: ["about"], isDynamic: false },
]);
const { getPagesRouterComponentsMap, markAppRouteDetectedOnPrefetch } =
await import("../packages/vinext/src/shims/internal/app-route-detection.js");
await markAppRouteDetectedOnPrefetch("https://example.com/about", "");
expect(getPagesRouterComponentsMap()).toEqual({});
});
it("does not mark or hard-navigate a Pages-owned overlap", async () => {
const fakeWindow = installFakeBrowserGlobals([
{ canPrefetchLoadingShell: false, patternParts: [":path+"], isDynamic: true },
]);
fakeWindow.__VINEXT_PAGES_LINK_PREFETCH_ROUTES__ = [
{
canPrefetchLoadingShell: false,
patternParts: ["pages-dir", ":dynamic"],
isDynamic: true,
},
];
const { markAppRouteDetectedOnPrefetch } =
await import("../packages/vinext/src/shims/internal/app-route-detection.js");
const routerModule = await import("../packages/vinext/src/shims/router.js");
await markAppRouteDetectedOnPrefetch("/pages-dir/foobar", "");
expect(routerModule.default.components["/pages-dir/foobar"]).toBeUndefined();
void routerModule.default.push("/pages-dir/foobar");
expect(fakeWindow.location.assign).not.toHaveBeenCalled();
});
});