mirror of
https://github.com/cloudflare/vinext.git
synced 2026-09-14 19:04:59 +08:00
f1efcc1e60
* fix(cloudflare): validate reusable warm responses * fix(cloudflare): retry staged warmup failures safely * fix(cloudflare): extend staged propagation retries * fix(build): filter Pages-owned RSC warm paths * fix(cloudflare): keep CDN warmup correct with custom adapters and i18n * fix(cloudflare): reject unusable CDN warm responses * fix(cloudflare): keep CDN warmup bound to the deployed build * fix(router): reuse warmed RSC on locale-prefixed hybrid routes * fix(cloudflare): verify exact build and variant during CDN warmup * fix(router): keep warmed RSC ownership rewrite-safe * test(cloudflare): prove browser reuse of warmed RSC variants * test(cloudflare): bind RSC warm proof to each deploy attempt * fix(rsc): keep deferred loading shells contextual * fix(build): keep CDN warm discovery route-correct * fix(cache): preserve valid CDN policy during RSC warmup * fix(cloudflare): warm the deployed Worker route host * fix(cloudflare): preserve staged warmup guarantees * fix(build): discover localized Pages warmup paths * fix(cloudflare): wait for staged Worker before CDN warmup * fix(cloudflare): preserve strict warmup deploy invariants * fix(build): fail closed during CDN warm discovery
449 lines
15 KiB
TypeScript
449 lines
15 KiB
TypeScript
/**
|
|
* Direct unit tests for the client-side `resolveHybridClientRouteOwner`.
|
|
*
|
|
* Mirrors the server-side `pagesRouteHasPriorityOverAppRoute` tests and
|
|
* adds the manifest-mocking plumbing needed to drive the client resolver
|
|
* with synthetic `__VINEXT_LINK_PREFETCH_ROUTES__` and
|
|
* `__VINEXT_PAGES_LINK_PREFETCH_ROUTES__` arrays.
|
|
*
|
|
* The hard guarantee these tests assert: the client and the server reach
|
|
* the same owner for the same (pages pattern, app pattern) pair. If a
|
|
* test here ever diverges from `tests/hybrid-route-priority.test.ts`,
|
|
* the hybrid invariant is broken and the next browser hard-navigation
|
|
* will go to the wrong router.
|
|
*
|
|
* The trie matcher expects Next.js-segment-encoded patternParts: static
|
|
* segments are plain strings, dynamic segments start with `:`, catch-alls
|
|
* end with `+`, and optional catch-alls end with `*`. See
|
|
* `routing/route-trie.ts`.
|
|
*/
|
|
import { afterEach, beforeEach, describe, expect, it } from "vite-plus/test";
|
|
import type {
|
|
VinextLinkPrefetchRoute,
|
|
VinextPagesLinkPrefetchRoute,
|
|
} from "../packages/vinext/src/client/vinext-next-data.js";
|
|
import {
|
|
toClientRewrites,
|
|
type ClientRewrites,
|
|
} from "../packages/vinext/src/client/client-rewrites.js";
|
|
import {
|
|
resolveHybridClientRewriteHref,
|
|
resolveHybridClientRouteOwner,
|
|
} from "../packages/vinext/src/shims/internal/hybrid-client-route-owner.js";
|
|
|
|
const APP_BASE = "http://localhost/";
|
|
|
|
type WindowState = {
|
|
app: VinextLinkPrefetchRoute[];
|
|
locales?: string[];
|
|
pages: VinextPagesLinkPrefetchRoute[];
|
|
rewrites?: ClientRewrites;
|
|
};
|
|
|
|
function installWindow({ app, locales, pages, rewrites }: WindowState): void {
|
|
(globalThis as any).window = {
|
|
location: { href: APP_BASE, origin: "http://localhost" },
|
|
__VINEXT_LINK_PREFETCH_ROUTES__: app,
|
|
__VINEXT_LOCALES__: locales,
|
|
__VINEXT_PAGES_LINK_PREFETCH_ROUTES__: pages,
|
|
__VINEXT_CLIENT_REWRITES__: rewrites,
|
|
};
|
|
}
|
|
|
|
function uninstallWindow(): void {
|
|
delete (globalThis as any).window;
|
|
}
|
|
|
|
let savedWindow: unknown;
|
|
beforeEach(() => {
|
|
savedWindow = (globalThis as any).window;
|
|
});
|
|
afterEach(() => {
|
|
if (savedWindow === undefined) {
|
|
uninstallWindow();
|
|
} else {
|
|
(globalThis as any).window = savedWindow;
|
|
}
|
|
});
|
|
|
|
const appRoute = (patternParts: string[], isDynamic = true): VinextLinkPrefetchRoute => ({
|
|
canPrefetchLoadingShell: !isDynamic,
|
|
isDynamic,
|
|
patternParts,
|
|
});
|
|
|
|
const pagesRoute = (patternParts: string[], isDynamic = true): VinextPagesLinkPrefetchRoute => ({
|
|
canPrefetchLoadingShell: false,
|
|
isDynamic,
|
|
patternParts,
|
|
});
|
|
|
|
const documentRoute = (patternParts: string[], isDynamic = true): VinextLinkPrefetchRoute => ({
|
|
canPrefetchLoadingShell: false,
|
|
documentOnly: true,
|
|
isDynamic,
|
|
patternParts,
|
|
});
|
|
|
|
describe("resolveHybridClientRouteOwner", () => {
|
|
it("returns null when neither router has a matching manifest", () => {
|
|
installWindow({ app: [], pages: [] });
|
|
expect(resolveHybridClientRouteOwner("/missing", "")).toBeNull();
|
|
});
|
|
|
|
it("returns null when neither router matched the URL", () => {
|
|
installWindow({
|
|
app: [appRoute(["a"])],
|
|
pages: [pagesRoute(["b"])],
|
|
});
|
|
expect(resolveHybridClientRouteOwner("/c", "")).toBeNull();
|
|
});
|
|
|
|
it("returns 'app' when only the App manifest matched", () => {
|
|
installWindow({
|
|
app: [appRoute(["a", ":slug"])],
|
|
pages: [pagesRoute(["b"])],
|
|
});
|
|
expect(resolveHybridClientRouteOwner("/a/foobar", "")).toBe("app");
|
|
});
|
|
|
|
it("returns 'pages' when only the Pages manifest matched", () => {
|
|
installWindow({
|
|
app: [appRoute(["a"])],
|
|
pages: [pagesRoute(["b", ":slug"])],
|
|
});
|
|
expect(resolveHybridClientRouteOwner("/b/foobar", "")).toBe("pages");
|
|
});
|
|
|
|
it("returns document ownership for App route handlers and Pages API routes", () => {
|
|
installWindow({
|
|
app: [documentRoute(["app-api"], false)],
|
|
pages: [{ ...pagesRoute(["api", ":slug"]), documentOnly: true }],
|
|
});
|
|
|
|
expect(resolveHybridClientRouteOwner("/app-api", "")).toBe("document");
|
|
expect(resolveHybridClientRouteOwner("/api/test", "")).toBe("document");
|
|
});
|
|
|
|
it("keeps locale-prefixed App pages distinct from unprefixed Pages API routes", () => {
|
|
// Next.js treats /fr/api/* as a localized page path, not a Pages API path:
|
|
// test/e2e/i18n-api-support/index.test.ts
|
|
// https://github.com/vercel/next.js/blob/canary/test/e2e/i18n-api-support/index.test.ts
|
|
installWindow({
|
|
app: [appRoute([":locale", "api", "status"])],
|
|
locales: ["en", "fr"],
|
|
pages: [{ ...pagesRoute(["api", "status"], false), documentOnly: true }],
|
|
});
|
|
|
|
expect(resolveHybridClientRouteOwner("/fr/api/status", "")).toBe("app");
|
|
expect(resolveHybridClientRouteOwner("/api/status", "")).toBe("document");
|
|
});
|
|
|
|
it("still locale-normalizes Pages page matching", () => {
|
|
installWindow({
|
|
app: [appRoute([":locale", "dashboard"])],
|
|
locales: ["en", "fr"],
|
|
pages: [pagesRoute(["about"], false)],
|
|
});
|
|
|
|
expect(resolveHybridClientRouteOwner("/fr/about", "")).toBe("pages");
|
|
});
|
|
|
|
it("applies document ownership only after choosing the most specific route", () => {
|
|
installWindow({
|
|
app: [appRoute(["api", "settings"], false)],
|
|
pages: [{ ...pagesRoute(["api", ":slug"]), documentOnly: true }],
|
|
});
|
|
expect(resolveHybridClientRouteOwner("/api/settings", "")).toBe("app");
|
|
|
|
installWindow({
|
|
app: [documentRoute(["docs", ":slug"])],
|
|
pages: [pagesRoute(["docs", "settings"], false)],
|
|
});
|
|
expect(resolveHybridClientRouteOwner("/docs/settings", "")).toBe("pages");
|
|
});
|
|
|
|
it.each(["beforeFiles", "afterFiles", "fallback"] as const)(
|
|
"resolves %s rewrites before choosing the route owner",
|
|
(rewritePhase) => {
|
|
installWindow({
|
|
app: [appRoute(["app-destination"], false)],
|
|
pages: [pagesRoute(["pages-destination"], false)],
|
|
rewrites: {
|
|
beforeFiles:
|
|
rewritePhase === "beforeFiles"
|
|
? [{ source: "/source", destination: "/app-destination" }]
|
|
: [],
|
|
afterFiles:
|
|
rewritePhase === "afterFiles"
|
|
? [{ source: "/source", destination: "/app-destination" }]
|
|
: [],
|
|
fallback:
|
|
rewritePhase === "fallback"
|
|
? [{ source: "/source", destination: "/app-destination" }]
|
|
: [],
|
|
},
|
|
});
|
|
|
|
expect(resolveHybridClientRouteOwner("/source", "")).toBe("app");
|
|
},
|
|
);
|
|
|
|
it("matches locale-aware rewrites against the raw App pathname", () => {
|
|
installWindow({
|
|
app: [appRoute([":locale", "app-destination"])],
|
|
locales: ["en", "fr"],
|
|
pages: [],
|
|
rewrites: {
|
|
afterFiles: [],
|
|
beforeFiles: [
|
|
{
|
|
source: "/:nextInternalLocale(en|fr)/source",
|
|
destination: "/:nextInternalLocale/app-destination",
|
|
},
|
|
],
|
|
fallback: [],
|
|
},
|
|
});
|
|
|
|
expect(resolveHybridClientRouteOwner("/fr/source", "")).toBe("app");
|
|
});
|
|
|
|
it.each(["beforeFiles", "afterFiles", "fallback"] as const)(
|
|
"returns the %s rewrite destination href",
|
|
(rewritePhase) => {
|
|
installWindow({
|
|
app: [appRoute(["app-destination"], false)],
|
|
pages: [],
|
|
rewrites: {
|
|
beforeFiles:
|
|
rewritePhase === "beforeFiles"
|
|
? [{ source: "/source", destination: "/app-destination" }]
|
|
: [],
|
|
afterFiles:
|
|
rewritePhase === "afterFiles"
|
|
? [{ source: "/source", destination: "/app-destination" }]
|
|
: [],
|
|
fallback:
|
|
rewritePhase === "fallback"
|
|
? [{ source: "/source", destination: "/app-destination" }]
|
|
: [],
|
|
},
|
|
});
|
|
|
|
expect(resolveHybridClientRewriteHref("/source", "")).toBe("/app-destination");
|
|
},
|
|
);
|
|
|
|
it("evaluates conditional client rewrites against browser-visible context", () => {
|
|
installWindow({
|
|
app: [appRoute(["app-destination"], false)],
|
|
pages: [],
|
|
rewrites: {
|
|
afterFiles: [],
|
|
beforeFiles: [
|
|
{
|
|
source: "/source",
|
|
destination: "/app-destination",
|
|
has: [{ type: "query", key: "preview", value: "1" }],
|
|
},
|
|
],
|
|
fallback: [],
|
|
},
|
|
});
|
|
|
|
expect(resolveHybridClientRouteOwner("/source", "")).toBeNull();
|
|
expect(resolveHybridClientRouteOwner("/source?preview=1", "")).toBe("app");
|
|
});
|
|
|
|
it("hands rewrites with browser-invisible conditions back to the document request", () => {
|
|
installWindow({
|
|
app: [appRoute(["app-destination"], false)],
|
|
pages: [],
|
|
rewrites: toClientRewrites({
|
|
afterFiles: [],
|
|
beforeFiles: [
|
|
{
|
|
source: "/cookie-source",
|
|
destination: "/app-destination",
|
|
has: [{ type: "cookie", key: "internal-access", value: "cookie-secret" }],
|
|
},
|
|
{
|
|
source: "/header-source",
|
|
destination: "/app-destination",
|
|
has: [{ type: "header", key: "x-origin-auth", value: "header-secret" }],
|
|
},
|
|
],
|
|
fallback: [],
|
|
}),
|
|
});
|
|
|
|
expect(resolveHybridClientRewriteHref("/cookie-source", "")).toBeNull();
|
|
expect(resolveHybridClientRouteOwner("/cookie-source", "")).toBe("document");
|
|
expect(resolveHybridClientRewriteHref("/header-source", "")).toBeNull();
|
|
expect(resolveHybridClientRouteOwner("/header-source", "")).toBe("document");
|
|
});
|
|
|
|
it("uses browser-authoritative conditions as a server handoff prefilter", () => {
|
|
installWindow({
|
|
app: [appRoute(["app-destination"], false)],
|
|
pages: [],
|
|
rewrites: toClientRewrites({
|
|
afterFiles: [],
|
|
beforeFiles: [
|
|
{
|
|
source: "/source",
|
|
destination: "/app-destination",
|
|
has: [
|
|
{ type: "query", key: "preview", value: "1" },
|
|
{ type: "cookie", key: "internal-access", value: "cookie-secret" },
|
|
],
|
|
},
|
|
],
|
|
fallback: [],
|
|
}),
|
|
});
|
|
|
|
expect(resolveHybridClientRouteOwner("/source", "")).toBeNull();
|
|
expect(resolveHybridClientRouteOwner("/source?preview=1", "")).toBe("document");
|
|
});
|
|
|
|
it("applies every beforeFiles rewrite before choosing ownership", () => {
|
|
installWindow({
|
|
app: [],
|
|
pages: [pagesRoute(["pages-destination"], false)],
|
|
rewrites: {
|
|
afterFiles: [],
|
|
beforeFiles: [
|
|
{ source: "/source", destination: "/intermediate?first=1" },
|
|
{ source: "/intermediate", destination: "/pages-destination?second=2" },
|
|
],
|
|
fallback: [],
|
|
},
|
|
});
|
|
|
|
expect(resolveHybridClientRouteOwner("/source?original=1", "")).toBe("pages");
|
|
});
|
|
|
|
it.each(["afterFiles", "fallback"] as const)(
|
|
"continues through unmatched %s rewrite destinations",
|
|
(rewritePhase) => {
|
|
installWindow({
|
|
app: [appRoute(["app-destination"], false)],
|
|
pages: [],
|
|
rewrites: {
|
|
afterFiles:
|
|
rewritePhase === "afterFiles"
|
|
? [
|
|
{ source: "/source", destination: "/intermediate" },
|
|
{ source: "/intermediate", destination: "/app-destination" },
|
|
]
|
|
: [],
|
|
beforeFiles: [],
|
|
fallback:
|
|
rewritePhase === "fallback"
|
|
? [
|
|
{ source: "/source", destination: "/intermediate" },
|
|
{ source: "/intermediate", destination: "/app-destination" },
|
|
]
|
|
: [],
|
|
},
|
|
});
|
|
|
|
expect(resolveHybridClientRouteOwner("/source", "")).toBe("app");
|
|
},
|
|
);
|
|
|
|
it("lets a more specific Pages dynamic route beat an App root catch-all", () => {
|
|
// Mirrors the server test of the same name. /pages-dir/:dynamic
|
|
// (score 51) beats /:path+ (score 1000).
|
|
installWindow({
|
|
app: [appRoute([":path+"])],
|
|
pages: [pagesRoute(["pages-dir", ":dynamic"])],
|
|
});
|
|
expect(resolveHybridClientRouteOwner("/pages-dir/foobar", "")).toBe("pages");
|
|
});
|
|
|
|
it("lets an App static route own the request when Pages only has a catch-all", () => {
|
|
installWindow({
|
|
app: [appRoute(["dashboard"], false)],
|
|
pages: [pagesRoute([":path+"])],
|
|
});
|
|
expect(resolveHybridClientRouteOwner("/dashboard", "")).toBe("app");
|
|
});
|
|
|
|
it("lets a static Pages route win over a dynamic App catch-all", () => {
|
|
// E.g. Pages has a literal `/about` page, App only has a catch-all.
|
|
// The literal Pages hit must own the request even though the App
|
|
// catch-all matches the same URL.
|
|
installWindow({
|
|
app: [appRoute([":path+"])],
|
|
pages: [pagesRoute(["about"], false)],
|
|
});
|
|
expect(resolveHybridClientRouteOwner("/about", "")).toBe("pages");
|
|
});
|
|
|
|
it("rejects an identical static App and Pages route", () => {
|
|
installWindow({
|
|
app: [appRoute([], false)],
|
|
pages: [pagesRoute([], false)],
|
|
});
|
|
expect(() => resolveHybridClientRouteOwner("/", "")).toThrow("Conflicting app and page routes");
|
|
});
|
|
|
|
it("retains Pages provider order after merged route validation", () => {
|
|
installWindow({
|
|
app: [appRoute([":slug"])],
|
|
pages: [pagesRoute([":id"])],
|
|
});
|
|
expect(resolveHybridClientRouteOwner("/anything", "")).toBe("pages");
|
|
});
|
|
|
|
it("lets a static-prefix Pages catch-all beat a bare App catch-all", () => {
|
|
// `/_sites/:slug*` (score 1951 with the static-prefix reduction)
|
|
// must beat `/:slug*` (score 2000). The previous hand-copied
|
|
// comparator missed the static-prefix reduction and returned 'app'
|
|
// for this case, splitting client / server ownership.
|
|
installWindow({
|
|
app: [appRoute([":slug*"])],
|
|
pages: [pagesRoute(["_sites", ":slug*"])],
|
|
});
|
|
expect(resolveHybridClientRouteOwner("/_sites/anything/here", "")).toBe("pages");
|
|
});
|
|
|
|
it("lets a static-prefix Pages dynamic beat a bare App dynamic", () => {
|
|
installWindow({
|
|
app: [appRoute([":subdomain"])],
|
|
pages: [pagesRoute(["_sites", ":subdomain"])],
|
|
});
|
|
expect(resolveHybridClientRouteOwner("/_sites/foo", "")).toBe("pages");
|
|
});
|
|
|
|
it("prioritizes an earlier static App segment over a later static Pages segment", () => {
|
|
installWindow({
|
|
app: [appRoute(["account", ":tab"])],
|
|
pages: [pagesRoute([":section", "details"])],
|
|
});
|
|
expect(resolveHybridClientRouteOwner("/account/details", "")).toBe("app");
|
|
});
|
|
|
|
it("keeps an exact App dynamic route ahead of a Pages optional catch-all", () => {
|
|
installWindow({
|
|
app: [appRoute([":section"])],
|
|
pages: [pagesRoute([":section", ":rest*"])],
|
|
});
|
|
expect(resolveHybridClientRouteOwner("/foo", "")).toBe("app");
|
|
});
|
|
|
|
it("ignores the basePath prefix when matching", () => {
|
|
installWindow({
|
|
app: [appRoute(["a"])],
|
|
pages: [pagesRoute(["pages-dir", ":dynamic"])],
|
|
});
|
|
// The client strips the basePath before consulting the manifest,
|
|
// matching the server's normalisation.
|
|
expect(resolveHybridClientRouteOwner("/base/pages-dir/foobar", "/base")).toBe("pages");
|
|
expect(resolveHybridClientRouteOwner("/base/a", "/base")).toBe("app");
|
|
});
|
|
});
|