Files
cloudflare__vinext/tests/app-router-next-config-codegen.test.ts
T
Nathan Nguyen 3a1e56748f fix(app-router): match Pages navigation params in hybrid builds (#1741)
* fix(app-router): emit pages client entry for hybrid builds

* fix pages router navigation params readiness

* fix(pages-router): track hashed fallback client entry

* fix(pages-router): honor navigation readiness in hooks

* fix(pages-router): derive server router.isReady from SSR readiness

Address review feedback on #1741:

- getRouterSnapshot() now reads navigationIsReady from the SSR context on
  the server instead of unconditionally returning true. For pre-ready Pages
  routes (auto-export dynamic / query-string / rewrite-capable builds) this
  keeps useRouter().isReady consistent between SSR and client hydration,
  avoiding a hydration mismatch for components that read it in JSX. Mirrors
  Next.js render.tsx's server readiness rule.
- Drop the redundant guard branch in getClientParamsSnapshot.
- Add a regression test asserting server useRouter().isReady reflects the
  SSR navigationIsReady context.

* fix(pages-router): align param resolution and dev/prod readiness parity

Address review feedback (ask-bonk + self-review) on #1741:

- Align client Pages navigation params to query-first, matching the server
  snapshot and Next.js's adaptForPathParams (derives path params from
  router.query). __NEXT_DATA__.query is kept current on client navigation, so
  the two snapshots are now provably identical and avoid a latent post-
  hydration mismatch.
- Populate window.__VINEXT_PAGE_PATTERNS__ in the dev server (own script tag)
  so the next/navigation compat hooks can resolve a dynamic pattern from a
  resolved path in dev exactly as in production. Closes a dev/prod parity gap
  (prod set this via the client entry; dev never did).
- Drop the redundant pre-readiness setSSRContext in dev; the single post-
  module-load call now publishes navigationIsReady before rendering, matching
  the prod handler.
- Document that installPagesClientAssetGlobals is intentionally inert for
  App-only builds, and that getReadonlyPagesSearchParams' string-keyed cache
  is safe to share across concurrent SSR requests (immutable wrappers).
- Add a dev regression test asserting __VINEXT_PAGE_PATTERNS__ exposure.

* fix(pages-router): prefer URL params for client snapshots

* fix(app-router): gate pages asset globals

* test(app-router): add production test for hybrid basePath + assetPrefix

* fix(navigation): widen usePathname return type to string | null

* refactor(pages-router): dedupe navigation readiness modeling and cache pattern scan

Addresses code-review cleanup findings on the Pages Router navigation
readiness work:

- Extract buildPagesReadinessNextData() so the dev SSR handler and the
  production Pages page handler derive the gssp/gsp/gip/appGip/autoExport
  __NEXT_DATA__ readiness flags from one place, instead of two hand-kept
  copies that could drift and produce a dev/prod hydration mismatch.
- Extract resolvePagesNavigationPathname() shared by the client and SSR
  branches of getPagesNavigationContext, so the pathname null-ness rule
  (fallback / pre-ready auto-export dynamic) cannot diverge between them.
- Memoize resolvePagesRoutePatternForPath()'s pattern scan, which ran an
  uncached O(routes) loop on every useSyncExternalStore snapshot read.
- In getServerSearchParamsSnapshot, serialize the source only when the
  object-identity fast path misses, avoiding a toString() on every read.

* refactor(pages-router): consistent ISR readiness data + robustness cleanups

Addresses further code-review findings:

- Thread the readiness flags (gssp/gsp/gip/appGip/autoExport) through the ISR
  background-regeneration path: renderPagesIsrHtml now serializes the same
  __NEXT_DATA__ readiness state the initial render emits, so a regenerated,
  re-cached page hydrates with the identical initial router.isReady the server
  computed. Previously these flags were dropped on that path — benign today
  (ISR-eligible getStaticProps routes never trigger the flag-gated early-ready
  branches) but a latent dev/prod hydration-readiness drift. Adds a shared
  PagesNextDataExtras type so every render path stays in lockstep.
- readSsrManifest: degrade a malformed/unparseable SSR manifest to "no SSR
  manifest" with a warning instead of throwing and aborting server startup,
  restoring the previous tolerant behavior.
- pages-readiness: project PagesReadinessNextData from the canonical
  VinextNextData and accept PagesPageModule, instead of bespoke types that
  could drift from the __NEXT_DATA__ shape.
- getReadonlyPagesSearchParams: drop the redundant string-keyed cache slot; the
  per-object WeakMap is authoritative and concurrency-safe on its own.
- isPagesRouterReady: drop the redundant server-side window check.

* fix(pages-router): restore string-keyed readonly search-params cache

Reverts the cache simplification from the previous commit. The string-keyed
slot is load-bearing, not redundant: across the Pages Router pre-ready → ready
transition the navigation context swaps in a new URLSearchParams instance even
when the query string is unchanged, so the per-object WeakMap alone returns a
fresh ReadonlyURLSearchParams wrapper and useSearchParams() loses Object.is
stability — re-firing a [searchParams] effect. Caught by the rewrite-driven
pre-ready static page e2e (search-params-snapshots saw ['',''] not ['']).
Keeps the improved comment explaining why both cache levels are needed.

---------

Co-authored-by: James <james@eli.cx>
2026-06-09 11:30:38 +01:00

432 lines
17 KiB
TypeScript

import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { createServer } from "vite";
import { describe, expect, it } from "vitest";
import { generateRscEntry } from "../packages/vinext/src/entries/app-rsc-entry.js";
import { generateSsrEntry } from "../packages/vinext/src/entries/app-ssr-entry.js";
import vinext from "../packages/vinext/src/index.js";
describe("App Router next.config.js features (generateRscEntry)", () => {
// Use a minimal route list for testing — we only care about the generated config handling code
const minimalRoutes = [
{
pattern: "/",
pagePath: "/tmp/test/app/page.tsx",
routePath: null,
layouts: ["/tmp/test/app/layout.tsx"],
templates: [],
parallelSlots: [],
loadingPath: null,
errorPath: null,
layoutErrorPaths: [null],
notFoundPath: null,
forbiddenPaths: [],
forbiddenPath: null,
unauthorizedPaths: [],
unauthorizedPath: null,
routeSegments: [],
layoutTreePositions: [0],
isDynamic: false,
params: [],
},
{
pattern: "/about",
pagePath: "/tmp/test/app/about/page.tsx",
routePath: null,
layouts: ["/tmp/test/app/layout.tsx"],
templates: [],
parallelSlots: [],
loadingPath: null,
errorPath: null,
layoutErrorPaths: [null],
notFoundPath: null,
forbiddenPaths: [],
forbiddenPath: null,
unauthorizedPaths: [],
unauthorizedPath: null,
routeSegments: [],
layoutTreePositions: [0],
isDynamic: false,
params: [],
},
{
pattern: "/blog/:slug",
pagePath: "/tmp/test/app/blog/[slug]/page.tsx",
routePath: null,
layouts: ["/tmp/test/app/layout.tsx"],
templates: [],
parallelSlots: [],
loadingPath: null,
errorPath: null,
layoutErrorPaths: [null],
notFoundPath: null,
forbiddenPaths: [],
forbiddenPath: null,
unauthorizedPaths: [],
unauthorizedPath: null,
routeSegments: [],
layoutTreePositions: [0],
isDynamic: true,
params: ["slug"],
},
] as any[];
it("generates redirect handling code when redirects are provided", () => {
const code = generateRscEntry("/tmp/test/app", minimalRoutes, null, [], null, "", false, {
redirects: [
{ source: "/old-about", destination: "/about", permanent: true },
{ source: "/old-blog/:slug", destination: "/blog/:slug", permanent: false },
],
});
expect(code).toContain("createAppRscHandler");
expect(code).toContain("__configRedirects");
expect(code).toContain("configRedirects: __configRedirects");
expect(code).toContain("/old-about");
expect(code).toContain("/old-blog/:slug");
expect(code).toContain("permanent");
});
it("generates rewrite handling code when rewrites are provided", () => {
const code = generateRscEntry("/tmp/test/app", minimalRoutes, null, [], null, "", false, {
rewrites: {
beforeFiles: [{ source: "/before-rewrite", destination: "/about" }],
afterFiles: [{ source: "/after-rewrite", destination: "/about" }],
fallback: [{ source: "/fallback-rewrite", destination: "/about" }],
},
});
expect(code).toContain("__configRewrites");
expect(code).toContain("configRewrites: __configRewrites");
expect(code).toContain("beforeFiles");
expect(code).toContain("afterFiles");
expect(code).toContain("fallback");
expect(code).toContain("/before-rewrite");
expect(code).toContain("/after-rewrite");
expect(code).toContain("/fallback-rewrite");
});
it("generates custom header handling code when headers are provided", () => {
const code = generateRscEntry("/tmp/test/app", minimalRoutes, null, [], null, "", false, {
headers: [{ source: "/api/(.*)", headers: [{ key: "X-Custom-Header", value: "vinext" }] }],
});
expect(code).toContain("__configHeaders");
expect(code).toContain("configHeaders: __configHeaders");
expect(code).toContain("X-Custom-Header");
expect(code).toContain("vinext");
});
it("routes hybrid Pages API misses through the Pages server entry", () => {
// Ported from Next.js: test/e2e/og-api/index.test.ts
// https://github.com/vercel/next.js/blob/canary/test/e2e/og-api/index.test.ts
//
// In a mixed app/ + pages/ project, a Pages Router API route such as
// pages/api/og.js remains a real route even though the production server
// enters through the App Router handler.
const code = generateRscEntry("/tmp/test/app", minimalRoutes, null, [], null, "", false, {
hasPagesDir: true,
});
expect(code).toContain("renderPagesFallback as __renderPagesFallback");
expect(code).toContain("server/app-pages-bridge.js");
expect(code).toContain("return __renderPagesFallback(");
expect(code).toContain('return import.meta.viteRsc.loadModule("ssr", "index");');
expect(code).toContain("buildRequestHeaders: __buildRequestHeadersFromMiddlewareResponse");
expect(code).toContain(
"applyRouteHandlerMiddlewareContext: __applyRouteHandlerMiddlewareContext",
);
});
it("exports whether the App Router build includes Pages Router routes", () => {
const appOnlyCode = generateRscEntry("/tmp/test/app", minimalRoutes, null, [], null, "", false);
const hybridCode = generateRscEntry("/tmp/test/app", minimalRoutes, null, [], null, "", false, {
hasPagesDir: true,
});
expect(appOnlyCode).toContain("export const __hasPagesDir = false;");
expect(hybridCode).toContain("export const __hasPagesDir = true;");
});
it("re-exports Pages API handling from the hybrid SSR entry", () => {
// Ported from Next.js: test/e2e/og-api/index.test.ts
// https://github.com/vercel/next.js/blob/canary/test/e2e/og-api/index.test.ts
//
// The production App Router entry loads the SSR environment's "index"
// module for Pages fallbacks. That bridge must expose the Pages API
// dispatcher as well as page rendering.
const code = generateSsrEntry(true);
expect(code).toContain(
'export { handleApiRoute, pageRoutes, renderPage } from "virtual:vinext-server-entry";',
);
});
it("embeds basePath and trailingSlash alongside config", () => {
const code = generateRscEntry("/tmp/test/app", minimalRoutes, null, [], null, "/app", true, {
redirects: [{ source: "/old", destination: "/new", permanent: true }],
});
// User-provided basePath and trailingSlash must flow through into the
// generated entry alongside redirect config.
expect(code).toContain('"/app"');
expect(code).toContain("/old");
});
it("includes config pattern matching function for regex patterns", () => {
const code = generateRscEntry("/tmp/test/app", minimalRoutes, null, [], null, "", false, {
redirects: [{ source: "/docs/:path*", destination: "/wiki/:path*", permanent: false }],
});
expect(code).toContain("configRedirects: __configRedirects");
expect(code).toContain(":path*");
});
it("delegates request lifecycle to the typed App RSC handler", () => {
const code = generateRscEntry("/tmp/test/app", minimalRoutes, null, [], null, "", false, {
redirects: [{ source: "/old", destination: "/new", permanent: true }],
});
expect(code).toContain("export default __createAppRscHandler({");
expect(code).toContain("configRedirects: __configRedirects");
expect(code).toContain("dispatchMatchedPage({");
expect(code).toContain(" clientReuseManifest,");
expect(code).toContain(" rootParams,\n request,");
expect(code).toContain(" clientReuseManifest,");
expect(code).toContain(" rootParams,\n probeLayoutAt");
expect(code).toContain("dispatchMatchedRouteHandler({");
expect(code).toContain("matchRoute,");
});
it("describes beforeFiles rewrites in the generated app shape", () => {
const code = generateRscEntry("/tmp/test/app", minimalRoutes, null, [], null, "", false, {
rewrites: {
beforeFiles: [{ source: "/old", destination: "/new" }],
afterFiles: [],
fallback: [],
},
});
expect(code).toContain("configRewrites: __configRewrites");
expect(code).toContain('"beforeFiles":[{"source":"/old","destination":"/new"}]');
});
it("passes the typed handler the generated route matcher and config", () => {
const code = generateRscEntry("/tmp/test/app", minimalRoutes, null, [], null, "", false, {
rewrites: {
beforeFiles: [{ source: "/old", destination: "/new" }],
afterFiles: [],
fallback: [],
},
});
expect(code).toContain("const __routeMatcher = __createAppRscRouteMatcher(routes);");
expect(code).toContain("matchRoute,");
expect(code).toContain("configRewrites: __configRewrites");
});
it("describes afterFiles rewrites in the generated app shape", () => {
const code = generateRscEntry("/tmp/test/app", minimalRoutes, null, [], null, "", false, {
rewrites: {
beforeFiles: [],
afterFiles: [{ source: "/old", destination: "/new" }],
fallback: [],
},
});
expect(code).toContain('"afterFiles":[{"source":"/old","destination":"/new"}]');
expect(code).toContain("configRewrites: __configRewrites");
});
it("applies fallback rewrites when no route matches", () => {
const code = generateRscEntry("/tmp/test/app", minimalRoutes, null, [], null, "", false, {
rewrites: {
beforeFiles: [],
afterFiles: [],
fallback: [{ source: "/fallback", destination: "/about" }],
},
});
expect(code).toContain("configRewrites: __configRewrites");
expect(code).toContain('"fallback":[{"source":"/fallback","destination":"/about"}]');
});
it("describes external beforeFiles rewrites in the generated config", () => {
const code = generateRscEntry("/tmp/test/app", minimalRoutes, null, [], null, "", false, {
rewrites: {
beforeFiles: [{ source: "/ph/:path*", destination: "https://us.i.posthog.com/:path*" }],
afterFiles: [],
fallback: [],
},
});
expect(code).toContain("configRewrites: __configRewrites");
expect(code).toContain("https://us.i.posthog.com/:path*");
});
it("describes external afterFiles rewrites in the generated config", () => {
const code = generateRscEntry("/tmp/test/app", minimalRoutes, null, [], null, "", false, {
rewrites: {
beforeFiles: [],
afterFiles: [{ source: "/api/:path*", destination: "https://api.example.com/:path*" }],
fallback: [],
},
});
expect(code).toContain("configRewrites: __configRewrites");
expect(code).toContain("https://api.example.com/:path*");
});
it("describes external fallback rewrites in the generated config", () => {
const code = generateRscEntry("/tmp/test/app", minimalRoutes, null, [], null, "", false, {
rewrites: {
beforeFiles: [],
afterFiles: [],
fallback: [
{ source: "/fallback/:path*", destination: "https://fallback.example.com/:path*" },
],
},
});
expect(code).toContain("configRewrites: __configRewrites");
expect(code).toContain("https://fallback.example.com/:path*");
});
it("passes basePath and redirect config to the generated handler", () => {
const code = generateRscEntry("/tmp/test/app", minimalRoutes, null, [], null, "/app", false, {
redirects: [{ source: "/old", destination: "/new", permanent: true }],
});
expect(code).toContain('const __basePath = "/app"');
expect(code).toContain("basePath: __basePath");
expect(code).toContain("configRedirects: __configRedirects");
});
it("passes server action handlers and afterFiles rewrites to the typed handler", () => {
const code = generateRscEntry("/tmp/test/app", minimalRoutes, null, [], null, "", false, {
rewrites: {
beforeFiles: [],
afterFiles: [{ source: "/x", destination: "/y" }],
fallback: [],
},
});
expect(code).toContain("handleServerActionRequest({");
expect(code).toContain("loadServerAction");
expect(code).toContain('"afterFiles":[{"source":"/x","destination":"/y"}]');
expect(code).toContain("configRewrites: __configRewrites");
});
it("embeds allowedOrigins when provided", () => {
const code = generateRscEntry("/tmp/test/app", minimalRoutes, null, [], null, "", false, {
allowedOrigins: ["my-proxy.com", "*.my-domain.com"],
});
expect(code).toContain("__allowedOrigins");
expect(code).toContain("my-proxy.com");
expect(code).toContain("*.my-domain.com");
});
it("keeps allowedDevOrigins separate from allowedOrigins", () => {
const code = generateRscEntry("/tmp/test/app", minimalRoutes, null, [], null, "", false, {
allowedOrigins: ["actions.example.com"],
allowedDevOrigins: ["allowed.example.com"],
});
expect(code).toContain("actions.example.com");
expect(code).toContain("allowed.example.com");
});
it("origin validation does not use x-forwarded-host", () => {
const code = generateRscEntry("/tmp/test/app", minimalRoutes, null, [], null, "", false);
const actionStart = code.indexOf("handleServerActionRequest({");
const actionEnd = code.indexOf("i18nConfig: __i18nConfig", actionStart);
const actionOptions = code.slice(actionStart, actionEnd);
// CSRF behavior belongs to the shared action helper. The generated entry
// should only pass the original Request and configured origins through.
expect(actionOptions).toContain("request,");
expect(actionOptions).toContain("allowedOrigins: __allowedOrigins");
expect(actionOptions).not.toContain("x-forwarded-host");
expect(code).not.toContain("validateCsrfOrigin(request, __allowedOrigins)");
expect(code).not.toContain("function __validateCsrfOrigin");
});
// ── Dev origin check code generation ────────────────────────────────
it("generates dev origin validation code in RSC entry", () => {
const code = generateRscEntry("/tmp/test/app", minimalRoutes, null, [], null, "", false);
// Should include the dev origin validation function definition
expect(code).toContain("__validateDevRequestOrigin");
expect(code).toContain("__safeDevHosts");
expect(code).toContain("validateDevRequestOrigin: __validateDevRequestOrigin");
});
it("embeds allowedDevOrigins in dev origin check code", () => {
const code = generateRscEntry("/tmp/test/app", minimalRoutes, null, [], null, "", false, {
allowedDevOrigins: ["staging.example.com", "*.preview.dev"],
});
expect(code).toContain("staging.example.com");
expect(code).toContain("*.preview.dev");
expect(code).toContain("__allowedDevOrigins");
});
it("loads allowedDevOrigins from next.config into the virtual RSC entry", async () => {
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "vinext-app-rsc-allowed-dev-origins-"));
try {
fs.mkdirSync(path.join(tmpDir, "app"), { recursive: true });
fs.writeFileSync(
path.join(tmpDir, "app", "layout.tsx"),
"export default function Layout({ children }) { return <html><body>{children}</body></html>; }",
);
fs.writeFileSync(
path.join(tmpDir, "app", "page.tsx"),
"export default function Page() { return <div>allowed-dev-origins</div>; }",
);
fs.writeFileSync(
path.join(tmpDir, "next.config.mjs"),
`export default {
allowedDevOrigins: ["allowed.example.com"],
experimental: {
serverActions: {
allowedOrigins: ["actions.example.com"],
},
},
};`,
);
fs.symlinkSync(
path.resolve(__dirname, "..", "node_modules"),
path.join(tmpDir, "node_modules"),
"junction",
);
const testServer = await createServer({
root: tmpDir,
configFile: false,
plugins: [vinext({ appDir: tmpDir })],
server: { port: 0 },
logLevel: "silent",
});
try {
const resolved = await testServer.pluginContainer.resolveId("virtual:vinext-rsc-entry");
expect(resolved).toBeTruthy();
const loaded = await testServer.pluginContainer.load(resolved!.id);
const code = typeof loaded === "string" ? loaded : ((loaded as any)?.code ?? "");
expect(code).toContain('const __allowedDevOrigins = ["allowed.example.com"]');
expect(code).toContain('const __allowedOrigins = ["actions.example.com"]');
} finally {
await testServer.close();
}
} finally {
fs.rmSync(tmpDir, { recursive: true, force: true });
}
});
describe("RSC error runtime delegation", () => {
it("imports RSC error helpers from a normal server module", () => {
const code = generateRscEntry("/tmp/test/app", minimalRoutes, null, [], null, "", false);
expect(code).toContain("sanitizeErrorForClient as __sanitizeErrorForClient");
expect(code).toContain("server/app-rsc-errors.js");
expect(code).toContain("createAppRscOnErrorHandler");
expect(code).toContain("server/app-rsc-error-handler.js");
});
it("keeps request-specific onError wiring in the generated entry", () => {
const code = generateRscEntry("/tmp/test/app", minimalRoutes, null, [], null, "", false);
expect(code).toContain("createRscOnErrorHandler(pathname, routePath)");
expect(code).toContain(
"createAppRscOnErrorHandler(_reportRequestError, request, pathname, routePath)",
);
expect(code).not.toContain("function createRscOnErrorHandler(request, pathname, routePath)");
expect(code).not.toContain("return __createRscOnErrorHandler({");
});
});
});