Commit Graph

1503 Commits

Author SHA1 Message Date
James Anderson 576c55d7db feat(pages-router): implement _next/data JSON endpoint (#1384)
* feat(pages-router): implement _next/data JSON endpoint

Recognize `/_next/data/<buildId>/<page>.json` in dev and prod request
pipelines. The URL is normalized to the page path BEFORE middleware
runs (matching Next.js' `handleNextDataRequest`), and the server emits
`{ pageProps }` as JSON with `Content-Type: application/json` instead
of the rendered HTML.

Closes #1330

* fix(pages-router): address bigbonk findings on _next/data endpoint

- Delegate JSON envelope construction from the generated entry to the
  typed `buildNextDataJsonResponse` helper so the envelope shape lives
  in one place (entries stay thin per AGENTS.md).
- Return a JSON 404 from `resolvePagesPageData` when `getStaticPaths`
  with `fallback: false` rejects the path for a data request. Without
  this, client-side navigations to unlisted slugs got an HTML 404 body
  which fails to parse as JSON in the Next.js client router.
- Apply the same JSON-404 gating in the dev server for
  `getStaticPaths fallback:false`, `getServerSideProps.notFound`, and
  `getStaticProps.notFound` paths.
- Read the dev buildId from the plugin's resolved `nextConfig.buildId`
  first so a user-supplied `generateBuildId` is honored, matching the
  value embedded into the prod server entry.
- Add coverage for `getStaticProps` data requests and the
  `fallback:false` JSON-404 path.

* chore: drop unused NextDataMatch export (knip)

* fix(pages-router): JSON 404 for dev stale buildId; HTML 404 for gSSP/gSP notFound on HTML nav

Follow-ups from bigbonk round 2:

- Dev request pipeline now returns a JSON 404 when `_next/data/<wrong>` is
  hit, mirroring the prod-server path. Previously the dev fall-through
  served Vite's HTML 404, breaking client routers that try to parse the
  body as JSON.
- `resolvePagesPageData` now gates the gSSP/gSP `notFound: true` 404 body
  on `isDataReq` so HTML navigations get the HTML 404 page and data
  requests get the JSON envelope. Previously both shapes always returned
  the JSON body, which surfaced text-content-type mismatches on HTML
  navigations through gSSP/gSP `notFound`.
2026-05-21 15:55:33 +01:00
James Anderson fb7a2f795b fix(rsc): redirect returns flight payload (200) for RSC navigations (#1390)
* fix(rsc): redirect returns flight payload (200) for RSC navigations

When a server component calls `redirect()` and the client makes an RSC
navigation request (`.rsc` URL), encode the redirect as a React error
chunk inside the flight stream and return HTTP 200 instead of a raw 307.
The client's `RedirectErrorBoundary` decodes the `NEXT_REDIRECT;...`
digest and performs the navigation — matching Next.js's behavior in
`app-render.tsx generateDynamicFlightRenderResult`.

`generateMetadata()` redirects now always return 200 with a flight
payload (both RSC and SSR document requests), because metadata is
suspended/streamed in Next.js — its redirects ride inside the flight
body, never the HTTP status line. Page-component redirects on SSR
document requests still emit a 307, unchanged.

Closes #1347

* fix(rsc): preserve soft-nav and useTransition pending across flight redirect

The previous commit returned a 200 + flight payload for RSC `redirect()`
responses but left vinext's client-side navigation loop without a way to
detect the redirect — the 200 response fell through to React's render,
threw at decode time, and hit the catch path's `window.location.href` hard
navigation. That tore down the page mid-transition and clobbered
`useTransition`'s isPending state.

Add an out-of-band header (`X-Vinext-Rsc-Redirect`) that carries the
redirect target alongside the flight body. The browser-entry loop now
detects the header right after the existing `resolveRscRedirectLifecycleHop`
(307-following) check, drains the body, and `continue`s the loop with the
new href. The same `pendingRouterState` spans both hops, so the outer
transition stays pending continuously — restoring pre-1347 behavior.

Also stamp the build-time `X-Vinext-Rsc-Compatibility-Id` header on the
flight-redirect response. Without it, `resolveRscCompatibilityNavigationDecision`
treats the response as cross-build and hard-navigates before the redirect
header is read.

Export `MAX_RSC_REDIRECT_DEPTH` from `app-browser-rsc-redirect` so the new
header path can reuse the same hop-limit ceiling.
2026-05-21 15:30:23 +01:00
James Anderson b06a1a1d04 fix(i18n): strip locale prefix for API routes (#1408)
* fix(i18n): strip locale prefix for API routes

Mirror Next.js's behaviour where a locale-prefixed request path like
`/fr/api/ok` is matched against `pages/api/ok` after stripping the
locale segment. Previously the locale prefix was kept through the
`/api/` startsWith check, so any locale-prefixed API URL 404'd.

Add a small helper `stripI18nLocaleForApiRoute(url, i18nConfig)` in
`server/pages-i18n.ts` that mirrors Next.js's
`normalizeLocalePath().pathname` (see
packages/next/src/shared/lib/i18n/normalize-locale-path.ts) and wire it
into all three Pages Router request pipelines so dev/prod parity holds:

- dev plugin: `packages/vinext/src/index.ts`
- prod (Node): `packages/vinext/src/server/prod-server.ts`
- prod (Cloudflare worker entry): `packages/vinext/src/deploy.ts`

App Router is intentionally unchanged: Next.js does not support the
`i18n` config field with App Router, so App Router route handlers
under `app/*/route.ts` never see a locale prefix to strip.

Refs #1336 (item 3). Items 2 (sticky locale on client navigations) and
4 (default-locale path normalisation) are being handled in parallel
PRs.

Tests:
- Unit tests for `stripI18nLocaleForApiRoute` covering basic, regional
  (`nl-NL`), query-preserving, unconfigured-prefix, and null-config
  cases (`tests/pages-i18n.test.ts`).
- Dev integration tests (`tests/features.test.ts`) and prod integration
  tests (`tests/pages-i18n-prod.test.ts`) that GET `/fr/api/ok` and
  assert a 200 "ok" response against the existing
  `pages-i18n-domains` fixture, ported from Next.js's
  `test/e2e/middleware-redirects/test/index.test.ts` ("should redirect
  to api route with locale").
- Updates to `tests/deploy.test.ts` assertions for the generated
  worker-entry template strings.

* test(after-deploy): update worker-entry assertion after locale strip

The Pages Router worker entry now calls handleApiRoute(request,
apiLookupUrl, ctx) instead of using resolvedUrl directly, so this
generated-code assertion needs the new variable name.

Refs #1336 (item 3).
2026-05-21 15:02:18 +01:00
James Anderson b7f7ad649d fix(basepath): enforce scoping on rewrites/redirects/routes (#1397)
* fix(basepath): enforce scoping on rewrites/redirects/routes

Sub-issues addressed under #1333:

1. Rewrites/redirects/headers were firing on requests outside the
   configured `basePath`. Threaded a `BasePathMatchState` through the
   matchers and prod-server / deploy worker / dev plugin so default
   rules (no `basePath: false` opt-out) only evaluate when the request
   was under basePath, and opt-out rules only evaluate outside it.

5. Pages Router requests that landed outside basePath fell through to
   internal route matching. After redirects and beforeFiles rewrites
   run, the request now 404s when it was outside basePath and no
   `basePath: false` rule rewrote it — matching Next.js's
   `resolve-routes.ts:304-309`.

4. With rewrites no longer firing on out-of-basepath requests, the
   downstream code that prepends basePath to destinations no longer
   sees a stripped-but-already-internal pathname, eliminating the
   `/docs/docs/...` doubling for the rewrite-driven cases.

App Router uses the same gating; `basePath: false` rules don't yet
fire there because `normalizeRscRequest` 404s out-of-basepath requests
before the matchers see them — flagged as follow-up in the rule
gating site. External rewrite proxying (sub-issue #2) and static
asset doubling (sub-issue #3, partly addressed by #1337/#1383) are
deferred.

Refs #1333

* test: update deploy.test signature assertions and format basepath tests

* review: address bonk nits — add inverse basePath:false tests and TODO comment
2026-05-21 13:49:25 +00:00
yyh 371817a07b chore: upgrade vite plus (#1410) 2026-05-21 14:37:09 +01:00
James Anderson f5e2105b94 fix(error): match Next.js default error page UI (#1402)
When no `app/global-error.tsx` (or route-level `error.tsx`) is defined,
vinext's runtime returned null from the error boundary fallback, which
caused the request to bubble up as a generic 500 instead of rendering
the styled default error page Next.js ships out of the box.

Port Next.js's built-in default global error component
(`packages/next/src/client/components/builtin/global-error.tsx`) to
`vinext/shims/default-global-error.tsx`, including the 32x32 warning
icon, the "This page couldn't load" heading, theme CSS, the Reload form
button, the Back button (client errors only), and the
`ERROR <digest>` footer (server errors only).

Wire this default in via a small module wrapper
(`server/default-global-error-module.ts`) so both the runtime error
boundary fallback (`app-fallback-renderer.ts`) and the route-level
client `<ErrorBoundary>` wrapper (via `app-page-element-builder.ts`)
fall back to it whenever `globalErrorModule` is null. User-defined
global error modules continue to take precedence.

Closes #1362
2026-05-21 13:27:27 +01:00
James Anderson 818093b7d9 fix(i18n): honor locale: false on rewrites/redirects + default-locale redirect (#1382)
* fix(i18n): honor locale: false on rewrites/redirects

When a rewrite or redirect carries `locale: false`, Next.js leaves the
source pattern untouched so user-supplied `:locale` segments can capture
the locale prefix themselves. Without `locale: false`, Next.js prepends
an internal locale alternation so the rule matches every locale-prefixed
URL automatically.

Vinext previously ignored the flag entirely and never injected the
locale alternation, so:
  - rewrites with `locale: false` on a source like
    `/:locale/rewrite-files/:path*` failed because the underlying
    matcher could not handle a non-trailing named param combined with a
    trailing catch-all;
  - plain rewrites/redirects with i18n configured silently failed to
    match locale-prefixed paths.

This change:
  1. Adds `locale?: false` (and `basePath?: false` for forward
     compatibility) to NextRedirect/NextRewrite.
  2. Fixes matchConfigPattern to use the regex branch whenever a pattern
     has multiple named params and a catch-all.
  3. Adds applyLocaleToRoutes mirroring Next.js's processRoutes — emits
     `/:nextInternalLocale(en|sv|nl)/...` and (for redirects) per
     default-locale literal variants while skipping rules flagged with
     `locale: false`. Retains the original unprefixed source as a
     fallback so default-locale requests that arrive without a prefix
     still match (Next.js handles this upstream via request path
     normalisation; doing it here avoids a server-wide change).

Ported tests from
  test/e2e/i18n-ignore-rewrite-source-locale/rewrites.test.ts

Refs #1336 — items 2, 3, 4 remain as follow-ups.

* fix(i18n): address Bonk review feedback

- Keep the locale-capture variants emitted by applyLocaleToRoutes on the
  redirect index fast path. `_LOCALE_STATIC_RE` now matches both the
  optional `/:locale(en|fr)?/foo` user-written form and the mandatory
  `/:nextInternalLocale(en|fr)/foo` form. Each entry records whether the
  locale segment is optional; the no-locale-prefix branch only fires for
  optional entries so mandatory variants still require a prefix.
- Thread `trailingSlash` through `applyLocaleToRoutes` so the root source
  is preserved (`/:nextInternalLocale(en|fr)/`) when trailingSlash is
  configured, matching Next.js processRoutes.
- Promote the dynamic import in next-config.ts to a static import — the
  module is already loaded for isExternalUrl.
- Drop the unnecessary `new Set([...])` dedup and document that the
  defaultLocales list will grow once domain-locale support lands.
2026-05-21 11:14:22 +00:00
James Anderson 2d035d47bd fix: await top-level await module imports (#1381)
* fix: make next/document default export a class

Next.js's `next/document` default export is `class Document extends
React.Component`. vinext's shim exported a function, so user
`_document.tsx` files that use the class-based form (the common Next.js
idiom: `class MyDocument extends Document`) ended up extending a
function. React refuses to call class constructors without `new`, which
caused SSR to throw "Class constructor MyDocument cannot be invoked
without 'new'" — surfacing in e2e as 500 / empty responses.

This is the actual root cause of the "async modules render empty pages"
report (issue #1361): the Next.js `test/e2e/async-modules` fixture's
`pages/_document.jsx` uses `class extends Document`, and once it crashes
the whole page renders empty. Top-level await itself propagates
correctly through Rolldown's static-import graph already.

Adds a class-based Document shim that mirrors Next.js's signature
(static `getInitialProps`, instance `render()`), plus production-build
e2e coverage for pages, `_app.tsx`, `getStaticProps`,
`getServerSideProps`, API routes, and class-based `_document.tsx`, all
using top-level `await`.

Closes #1361

Ports from Next.js: test/e2e/async-modules/index.test.ts
https://github.com/vercel/next.js/blob/canary/test/e2e/async-modules/index.test.ts

* fix(next/document): align Document base class with Next.js's typed contract

Addresses Bonk review feedback on PR #1381:

- Export DocumentContext / DocumentInitialProps types matching Next.js,
  so subclasses that delegate via `await Document.getInitialProps(ctx)`
  receive typed objects. The runtime path is still a stub (the Pages
  Router renderPage/defaultGetInitialProps chain is not wired up), but
  the signatures pin the contract.
- Declare the default Document class export in next-shims.d.ts so
  strict-mode consumers don't see `any` for `import Document from
  "next/document"`.
- Add a class-extends regression test in tests/document.test.ts — this
  is the contract that broke in issue #1361 (the original Next.js
  `pages/_document.jsx` uses `class extends Document`).
- Switch the class generic default from `unknown` to `{}` to match
  Next.js's `class Document<P = {}>`.
- Clarify App Router fixture comment: the upstream Next.js async-modules
  suite is Pages-Router-only, so this is adapted, not ported verbatim.
2026-05-21 12:03:33 +01:00
James Anderson d92d4ff2ab fix(css): support data URL composes in CSS modules (#1392)
Turbopack supports importing inline CSS via `data:text/css,...` and
`data:text/css+module,...` data URLs. Vite/Rolldown treats `data:`
specifiers as external, so the literal URL was reaching the bundled
output and Node/workerd refused to load it with
`ERR_UNKNOWN_MODULE_FORMAT: Unknown module format: text/css+module`,
breaking the entire build for any app that adopted the Turbopack-only
syntax (e.g. test/e2e/app-dir/css-modules-data-urls).

Add a pre-transform that rewrites these imports into synthetic
`\0vinext-data-css/<hash>.module.css` (or `.css`) specifiers. Vite's
existing CSS pipeline (LightningCSS, CSS modules) then handles
class-name hashing, JS export map generation, and stylesheet asset
emission. The decoded payload is cached by synthetic id so the same
data URL imported from RSC and client environments deduplicates the
way real CSS imports do.

Closes #1363
2026-05-21 10:50:28 +00:00
James Anderson ab37cc661e fix(middleware): allow server-only imports (#1376)
Middleware runs server-side, so importing `server-only` is semantically
correct. However, vinext bundles middleware into the SSR environment
via `virtual:vinext-server-entry`, and @vitejs/plugin-rsc's
`rsc:validate-imports` treats any non-RSC environment as a "client"
build, rejecting the bare specifier with:

  'server-only' cannot be imported in client build ('ssr' environment):
   imported by middleware.js
     imported by virtual:vinext-server-entry
       imported by virtual:vinext-app-ssr-entry

Next.js solves this with webpack `issuerLayer` rules: middleware sits
in the `neutralTarget` layer where `server-only` aliases to a no-op
while `client-only` still errors
(packages/next/src/build/webpack-config.ts).

Vite has no per-layer aliasing within an environment, so we mirror the
behavior with import-chain taint tracking: seed a Set with the
middleware entry path, propagate the taint along the resolveId graph,
and short-circuit `server-only` to the existing no-op shim when the
importer is tainted. The plugin uses `enforce: "pre"` so its resolveId
beats plugin-rsc's `order: "pre"` validate-imports filter on the bare
specifier.

Closes #1344
2026-05-21 11:41:53 +01:00
James Anderson bc586788a2 fix(middleware): preserve query params on rewrite (#1375)
* fix(middleware): preserve query params on rewrite

When middleware (or a `next.config.js` rewrite) sends a request to a new
target, vinext was throwing away the original request's query string,
breaking `getServerSideProps`/route-handler `query` for any URL of the
form `/rewrite-me?key=value`.

Match Next.js's behaviour:
`Object.assign(parsedUrl.query, rewrittenParsedUrl.query)` — keep the
original query first, let the rewrite-target's query win on key
conflicts. Applied to:

- middleware rewrites (middleware-runtime.ts)
- beforeFiles / afterFiles / fallback config rewrites (prod-server.ts,
  index.ts, deploy.ts)

Closes #1342

References:
- https://github.com/vercel/next.js/blob/canary/packages/next/src/server/route-modules/route-module.ts (Object.assign merge)
- https://github.com/vercel/next.js/blob/canary/test/e2e/edge-pages-support/index.test.ts (rewrite query expectations)

* test(query): document URL-encoded space normalization on rewrite merge

Addresses Bonk review observations on #1375:
- Add test confirming `%20` → `+` normalization through URLSearchParams
  is intentional and lossless (downstream consumers re-parse).
- Drop unreachable `?? ""` fallback in mergeRewriteQuery; `String#split`
  always yields a string.
2026-05-21 11:38:52 +01:00
James Anderson 24a1bd4edc fix(middleware): redirect protocol — relative Location and x-nextjs-redirect (#1377)
* fix(middleware): redirect protocol — relative Location and x-nextjs-redirect

Match Next.js's edge adapter behaviour for middleware redirects:

1. Relativize the `Location` header for same-host redirects. Next.js only
   emits absolute URLs when the redirect target is cross-origin; vinext
   was always emitting `http://host/path` for same-host targets, which
   broke deploy-suite parity tests.

2. Translate redirects to the `x-nextjs-redirect` soft-redirect protocol
   when the request carries `x-nextjs-data: 1`. The client router uses
   this header instead of a raw HTTP 3xx to avoid CORS issues on
   cross-origin data fetches.

Both fixes live in `executeMiddleware`, so all four call sites
(prod-server, dev plugin, deploy worker, app-middleware) inherit them.

Refs #1334

* fix(middleware): thread isDataRequest through all callers

Address Bonk review. The previous commit checked `x-nextjs-data` on the
middleware request, but that header is in INTERNAL_HEADERS and is
stripped by `filterInternalHeaders` before any caller constructs the
middleware request -- so the soft-redirect branch was unreachable.

Make `isDataRequest` an explicit option on `ExecuteMiddlewareOptions`
and `ApplyAppMiddlewareOptions`, and capture it from the raw incoming
headers in each of the four entry points (dev plugin, prod server,
deploy worker, App Router RSC handler) before filtering. The generated
Pages Router runMiddleware now accepts a third `options` argument
carrying the flag through to the runtime helper.

Also: drop redundant lowercase `headers.delete("location")` -- the
Fetch spec makes Headers.delete case-insensitive.

Add a regression test ensuring a forged `x-nextjs-data: 1` request
header cannot trigger the soft-redirect path without the caller
explicitly opting in.

Refs #1334
2026-05-21 11:34:52 +01:00
James Anderson 7e060b98f7 feat(link): support onNavigate prop (#1371)
vinext already exposes `<Link onNavigate>` and the matching `NavigateEvent`
contract (`{ url, preventDefault(), defaultPrevented }`), but the Next.js
deploy suite still fails the `replace history state for external links
with replace prop` case: when `<Link>` points at a truly external URL and
has `replace` set, vinext falls through to the browser's default click
navigation, which pushes a new entry instead of replacing the current one.

This mirrors `linkClicked` in
`.nextjs-ref/packages/next/src/client/link.tsx` for the external + replace
branch: call `e.preventDefault()` and `window.location.replace(href)` so
the entry is replaced rather than pushed.

Adds focused unit tests around the onNavigate contract: onClick + onNavigate
fire for internal clicks, `preventDefault()` cancels the navigation, and
onNavigate is skipped for modifier-key clicks, target=_blank, download
links, and external URLs (with and without `replace`).

Closes #1357
2026-05-21 11:28:14 +01:00
James Anderson 0fbbe33a2e fix: pass params: null (not {}) for non-dynamic routes (#1374)
Next.js exposes `params` as `null` (effectively — via `params || null`
or `params ? await params : null` user-side) to route handlers and
`getServerSideProps`/`getStaticProps` on routes with no dynamic
segments. vinext was passing an empty object, which is truthy and
breaks user code that branches on `params`.

The fix computes a user-facing params value at each dispatch boundary
(`null` when `route.isDynamic === false`, otherwise the matched
object) while leaving internal bookkeeping — navigation context, query
merging, `useParams()` — on the empty-object shape that those
consumers expect.

References the Next.js test suite assertions:
- test/e2e/app-dir/app-routes/app-custom-routes.test.ts
  "does not provide params to routes without dynamic parameters"
- test/e2e/edge-pages-support/index.test.ts
  "should have correct query/params on index"

Closes #1350
2026-05-21 11:27:07 +01:00
James Anderson 517c2bde19 feat(pages-router): expose window.next.router on hydration (#1400)
* feat(pages-router): expose window.next.router on hydration

Promote next/router from a lazy dynamic import inside hydrate() to a
static side-effect import at the top of the generated client entry, so
window.next = { version, router, ... } is published before any test
harness or third-party script reads it.

Without this, the Next.js deploy test suite — which drives client-side
navigations via `browser.eval('next.router.push(...)')` — crashes with
`TypeError: Cannot read properties of undefined (reading 'router')` (~80
test failures across middleware-general, middleware-rewrites,
trailing-slashes, basepath, with-router, and ignore-invalid-popstateevent).

Mirrors Next.js's bootstrap order in
.nextjs-ref/packages/next/src/client/next.ts (line 5–13), which
statically imports the router from './' and sets window.next as a
top-level side effect before initialize/hydrate.

Closes #1329

* feat(router): expose state fields as live getters on Router singleton

window.next.router must expose `pathname`, `route`, `query`, `asPath`,
`basePath`, `locale`, `locales`, `defaultLocale`, `domainLocales`,
`isReady`, `isPreview`, and `isFallback` as live properties — not just
the navigation methods. The Next.js deploy test suite drives navigations
via `browser.eval('next.router.push(...)')` and then asserts on
`browser.eval('next.router.pathname')` to verify the navigation
completed; without the field getters the assertion reads undefined.

Mirrors Next.js's `singletonRouter` shape in
.nextjs-ref/packages/next/src/client/router.ts (lines 32-47), which
uses Object.defineProperty for urlPropertyFields so reads forward to
the active router state.

Strengthens the existing shims.test.ts coverage so future regressions
of the field surface fail at unit-test time.
2026-05-21 10:26:23 +00:00
James Anderson c66b86e8f5 fix(after): wire next/after to Workers ctx.waitUntil in deploy mode (#1403)
Pages Router API routes did not receive the per-request Workers
`ExecutionContext`, so any `after()` (or other shim) call inside a
`pages/api/*` handler had no `ctx.waitUntil()` to keep the isolate
alive past the response. Thread `ctx` through `handleApiRoute`
→ `handlePagesApiRoute`, and wrap the user handler in
`runWithExecutionContext(ctx, ...)` so the ALS surfaces the ctx
for downstream `after()` calls.

Also pass a Node-shaped execution context from `vinext start` so
behavior is consistent across the dev / start / deploy server
paths.

Closes #1365
2026-05-21 10:04:38 +00:00
James Anderson fb927fdb00 feat(form): apply basePath and route via Pages Router for soft nav (#1387)
The `next/form` shim was rendering raw `action` strings without applying
basePath, and its Pages Router fallback bypassed the Router singleton —
meaning route events, HTML fetch, and scroll handling didn't fire on
form submission. Bring it in line with `next/link`:

- Prefix the form-level `action` prop with `__NEXT_ROUTER_BASEPATH`
  (matches Next.js `addBasePath(actionProp)`). A submitter's `formAction`
  is intentionally left alone, matching upstream.
- In Pages Router, delegate to `next/router` Router.push/replace so the
  navigation flows through `performNavigation` like Link does. Keep the
  pushState+popstate path as a recovery fallback only.

Closes #1355
2026-05-21 10:34:13 +01:00
James Anderson b01fe032fa fix(pages-router): avoid getServerSideProps identifier collision in build (#1370)
When a page declared `getServerSideProps` (or `getStaticProps` /
`getStaticPaths`) as a local binding and re-exported it via
`export { getServerSideProps }`, the client-bundle transform overwrote
the `export { ... }` statement with `export const getServerSideProps =
undefined;`. The original local `const getServerSideProps = ...` was
left in place, producing two declarations of the same identifier in
module scope. Rolldown/OXC rejects this with `Identifier
'getServerSideProps' has already been declared`, breaking the entire
production build (and cascading to 48 test failures in Next.js's
`test/e2e/getserversideprops` suite).

Drop the export specifier without emitting a stub, matching Next.js's
`next-ssg-transform` behaviour. The now-unused local declaration becomes
dead code and is tree-shaken later.

Closes #1354
2026-05-21 10:32:44 +01:00
James Anderson b8617a4c40 fix(pages-router): wrap edge API request in NextRequest, support bare runtime export (#1391)
Two parity gaps with Next.js were causing edge runtime API routes to fail
with 500 errors:

1. Edge API handlers received a plain `Request` object, so accessing
   `req.nextUrl.searchParams` (the idiomatic pattern in Next.js edge API
   routes) threw at runtime. Next.js wraps the request in `NextRequest`
   before invoking the handler (NextRequestHint in
   next/src/server/web/adapter.ts).

2. Only `export const config = { runtime: 'edge' }` was recognised as an
   edge runtime declaration. Next.js also accepts a bare
   `export const runtime = 'edge'` and resolves the effective runtime as
   `config.runtime ?? config.config?.runtime`
   (next/src/build/analysis/get-page-static-info.ts).

Both fixes are applied in dev (`server/api-handler.ts`) and prod
(`server/pages-api-route.ts`) so the two paths stay in parity.

Refs #1338
2026-05-21 10:32:07 +01:00
James Anderson 7771099d1d fix(pages-router): guard navigation shim against SSR (#1378)
`performNavigation()` and the `Router` singleton's `back`/`reload`/
`prefetch`/`beforePopState` methods referenced browser globals
(`window`, `document`) unconditionally. Invoking `router.push()` from a
Pages Router component during SSR or prerender (`VINEXT_PRERENDER=1`)
therefore crashed with `ReferenceError: window is not defined` and took
down the request pipeline.

Mirror Next.js's `ServerRouter` (packages/next/src/server/render.tsx),
where every navigation method synchronously throws "No router instance
found". The throw happens before any browser global is accessed, so the
failure surfaces as a normal render error instead of a ReferenceError —
and the documented message + docs link keeps userland error handling
intact.

Closes #1353
2026-05-21 10:31:18 +01:00
James Anderson f61de5d39a fix(parallel-routes): resolve nested slot pages over default.tsx (#1389)
* fix(parallel-routes): resolve nested slot pages over default.tsx

When two sibling route groups occupy the same URL pattern (e.g.
`(group-a)/layout.tsx` next to `(group-b)/page.tsx`), the layout-only
sibling was dropped entirely because its pattern collided with the page
route, taking its parallel slots with it. Nested pages inside its
`@slot` directories were never materialized, so URLs that only the
catch-all slot could handle returned 404 / not-found instead of
rendering the matched slot page.

This change keeps the dropped layout-only routes as "ghost parents"
that participate in `discoverSlotSubRoutes`. Their slot sub-pages still
mint synthetic sub-routes (carrying the ghost's layout chain and slot
metadata), but the ghost itself is not added to the route table to
preserve the existing single-route-per-pattern invariant.

Closes #1339

Ported from Next.js tests:
- test/e2e/app-dir/parallel-routes-catchall-groups

* test(parallel-routes): cover shared-root URL of sibling route groups

Adds an e2e assertion that /parallel-group-catchall (the URL shared by
both (group-a) and (group-b)) renders (group-b)/page.tsx, not the
(group-a) ghost or its catch-all slot. Closes the coverage gap called
out in code review for the existing /foo and /bar tests.
2026-05-21 10:30:34 +01:00
James Anderson 086c6e8b52 fix(app-router): parse interception route markers in route scanner (#1398)
* fix(app-router): parse interception route markers in route scanner

Refs #1364

* test(app-router): cover (.) and (...) markers, fix JSDoc typo

Addresses Bonk review feedback on #1398: add explicit coverage for the
remaining two interception marker shapes when used outside `@slot`, and
remove the stray quote in `isInterceptionMarkerDir`'s JSDoc.
2026-05-21 10:29:02 +01:00
James Anderson 5d397d5f4b fix(metadata): emit metadata from parallel route slots (#1399)
When the route has no children page providing a title (parallel layout
that doesn't render `{children}`, or a parent that only has
`default.tsx`), allow the active parallel slot page to contribute the
primary <title>. Previously every slot was forced with
`contributesTitle: false`, which suppressed the slot title even when no
primary page title existed — producing an empty title or stale layout
title for these routes.

Slots keep the existing suppression when the matched page module already
provides a title, preserving the modal/sidebar case where the slot
metadata must not clobber the page title.

Refs #1341
2026-05-21 10:28:22 +01:00
James Anderson a4ec9afeeb fix(actions): return 404 + x-nextjs-action-not-found for missing actions (#1386)
Treat both build-time error shapes raised by `@vitejs/plugin-rsc` when a
server-action reference is missing from the manifest — dev's
`[vite-rsc] invalid server reference '<id>'` and prod's
`server reference not found '<id>'` — as Next.js action-not-found.

Previously only the dev-mode message was recognised, so production
builds (and apps with no server actions defined at all) returned 500
instead of Next.js' 404 + `x-nextjs-action-not-found: 1` response.
Both the fetch-action and progressive (no-JS) code paths benefit, since
the latter calls `isServerActionNotFoundError(error, null)` before it
has resolved an action id.

Refs #1340
2026-05-21 09:57:21 +01:00
James Anderson 912369840c fix: usePathname returns canonical URL after middleware rewrite (#1379)
When middleware or a next.config.js rewrite redirects an external URL
to an internal route target, `usePathname()` (and the navigation
context that backs it) was returning the internal rewrite destination
instead of the canonical/external path the user requested.

Preserve the pre-rewrite pathname as `canonicalPathname` and use it
when populating the App Router navigation context, while leaving
`cleanPathname` to drive internal route matching. This matches Next.js
behavior: test/e2e/app-dir/hooks/hooks.test.ts —
"should have the canonical url pathname on rewrite".

Closes #1348
2026-05-21 09:43:02 +01:00
Nathan Nguyen 5748be3ba5 feat(cache): gate planner commits on explicit reuse proof (#1327)
* feat(cache): gate planner commits on explicit reuse proof

Runtime cache entries could enter browser commit planning as ordinary payloads even when the entry had no planner-visible reuse proof. That violated the #726 invariant that a cache hit is not semantic authority by itself.

The planner now receives a compact cache-entry proof decision through AppElements metadata and rejects missing or incompatible cache entries before route-topology commit approval. Cache-restored browser payloads synthesize the missing-proof rejection when metadata is absent, while fresh payloads keep the existing path.

Targeted tests cover proof projection, metadata transport, planner decisions, and the cached-payload lifecycle path.

* fix(cache): skip unproofed visited RSC cache restores

Visited App Router RSC snapshots were still eligible for replay even though production payloads do not yet carry cache-entry reuse proof. That let the new planner gate reject the cache path and turn soft restores into hard navigations.

Require decoded proof metadata before a payload is stored as a visited-response restore candidate, and update the real app-router navigation test to prove unproofed responses refetch without reloading. Also make cache-proof rejection-code parsing exhaustive at the type boundary.

* fix(cache): require explicit App Router payload origins

App Router navigation commits still carried cache-restore semantics as an optional boolean. A fresh default could hide a future restore path that forgot to request reuse proof, reopening the cache authority bug for visited responses.

The violated invariant is that cache origin is part of the payload contract, not a caller memory check.

Replace the boolean with a discriminated AppNavigationPayloadOrigin and thread it through browser entry, navigation controller, visible commit classification, and tests. Fresh payloads remain proof-optional; visited-cache payloads synthesize a missing-proof rejection and fail closed.

* docs(cache): document static layout proof parsing boundary

Runtime cache proof parsing currently accepts one proven reuse class: static layout. Future proof classes must update the parser before visited-cache responses can safely restore as commit-capable payloads.

The review also noted that the cache-restorable metadata predicate behaves as a narrowing boundary. Make that contract explicit as a TypeScript type guard without changing runtime behavior.

* fix(cache): keep accepted proof in rejection traces

Cache-proofed payloads that failed a later interception or root-boundary gate produced correct hard-navigation decisions, but the rejection trace lost the accepted proof fields. That made cache-proof diagnostics incomplete for payloads that passed proof validation and failed a later topology gate.

The root cause was that later rejection branches still used the pre-cache-proof trace fields after commitTraceFields had been computed.

Thread the enriched trace fields into those rejection decisions, document cache proof parser three-way semantics, narrow the cache-restorable metadata type guard precisely, and read the pending commit proof from the canonical top-level field.
2026-05-20 14:15:28 +01:00
James Anderson f27c40a77f chore: split e2e compatibility by App Router vs Pages Router (#1321)
* feat(compat): split e2e compatibility by App Router vs Pages Router

The /compatibility page previously showed one undifferentiated grid of
~1000 Next.js test files. This change classifies each test by which
router(s) its fixture exercises (App, Pages, both, or unknown) and
surfaces the breakdown in the UI.

How:

- A new `router` column on `compat_file_results` (enum: app | pages |
  both | unknown). Defaults to 'unknown' so pre-classifier rows still
  render — they just show up under 'Other'.
- A new `scripts/classify-nextjs-suites.mjs` walks each test's fixture
  directory and looks for app/page.tsx, app/route.ts, app/layout.tsx,
  pages/*.tsx (excluding _app / _document / _error specials). A fixture
  with real routes in both folders is classified as 'both' — these are
  the genuine parity tests and counting them only once would hide a
  router-specific failure.
- Edge cases handled in the classifier:
    * pageExtensions naming (layout.page.tsx → layout)
    * Suites whose .test.ts lives in a test/ subdir alongside the fixture
    * Inline-fixture suites under test/e2e/app-dir/ (no on-disk routes,
      but path convention says App Router)
    * APP_ROUTER_NON_APP_DIR_SUITES curated override
    * Skips node_modules and .next when scanning fixtures
- The nightly workflow now runs the classifier in the build job once
  per run (the cheap part — happens with Next.js still on disk),
  uploads the suite → router map as an artifact, and the report job
  joins it into the ingest payload before POSTing.
- The compatibility page gains a 'By router' card row showing per-router
  pass rates and file counts (parity tests are counted toward both
  router buckets — adding them exceeds the total, see the explainer
  card). The contribution grid grows filter chips above it for
  interactive narrowing.

Verified:

- vp check (format + type + lint) clean
- 14 new classifier unit tests pass (vp test run tests/classify-nextjs-suites.test.ts)
- apps/web build:vinext succeeds end-to-end
- Local classifier run against .nextjs-ref reports
  app=576 / pages=325 / both=113 / unknown=22 out of 1036 suites, with
  spot-checked classifications matching expectations.

Backward compatibility: the ingest endpoint treats `router` as optional;
existing workflow runs and historical rows continue to function and
render as 'Other' until the next nightly classifies them.

* refactor(compat): store router classification in its own table

Replaces the inline `router` column on `compat_file_results` (introduced
in the previous commit) with a dedicated `compat_suite_meta` table,
classification keyed by `suite`. The /compatibility UI LEFT JOINs the
two tables at query time.

Rationale (per design discussion in PR #1321):

- Classifications conceptually describe test files, not test runs. Storing
  them per-row coupled their cadence to results ingestion, which is wrong
  when (a) the Next.js ref bumps and re-classifies everything, (b) an
  override fix lands without re-running tests, or (c) a partial test run
  still wants fresh classifications.
- One row per suite (PK on `suite`) means re-classifying is an upsert,
  not a backfill loop. Provenance (`next_ref`, `classified_at`) is
  stored on the row for debugging.
- Decoupling lets the workflow POST classifications from the build job
  immediately after running the classifier, without round-tripping through
  the report job. Results ingestion stays focused on results.

Endpoints:
- `POST /api/compatibility` no longer accepts `router` per file
  (reverted to its pre-PR shape).
- `POST /api/compatibility/classify` (new): accepts
  `{ nextRef, classifiedAt?, suites: [{ suite, router }] }`,
  upserts in chunks (25 rows per INSERT to fit SQLite's variable cap),
  shares auth with the results endpoint via a new `_auth.ts` helper.

Workflow:
- The build job now POSTs the classification map directly to the new
  endpoint after running the classifier. Same guardrails as the results
  POST (only full-suite, only against main).
- The report job no longer needs to download / merge the classification
  artifact — it's already in D1 by the time results land.
- The classification JSON is still uploaded as a workflow artifact for
  manual re-submission / debugging.

Schema:
- New table `compat_suite_meta(suite PK, router, next_ref, classified_at)`
  with an index on `router` for the per-router count queries.
- `compat_file_results` reverts to its pre-PR columns. No data migration
  needed because the column was only ever populated on this branch.

Trade-off: classification changes are now retroactive — re-classifying a
suite updates how it appears in every historical run. This is usually
what you want (corrections heal the whole history) but means the trend
chart isn't a strict point-in-time record. The provenance fields on the
meta row let you tie a reclassification back to a specific Next.js ref.

Verified:
- vp check clean
- 14 classifier unit tests still pass
- apps/web build:vinext succeeds; /api/compatibility/classify shows up
  in the route list

* refactor(compat): drop redundant next_ref from compat_suite_meta

The Next.js ref a classification was produced against is already
recoverable from the run history — compat_runs records next_ref per
run, and the most recent classification's ref is implicit (it's the
ref of the most recent classify POST, which the workflow always pairs
with a run).

The future-proofing case for per-ref classifications would want a
composite (suite, next_ref) PK rather than a single global row anyway,
so this column doesn't help with that scenario either. classified_at
is enough for the debug case ('when was this last computed?').

Changes:
- Drop next_ref column from compat_suite_meta (3 columns now:
  suite PK, router, classified_at)
- /api/compatibility/classify no longer requires nextRef in the body
- Workflow no longer passes NEXT_REF when building the classify payload
- Migration regenerated as 0001_romantic_skullbuster.sql (one fewer
  column on the CREATE TABLE)
- Chunk size bumped to 33 rows/INSERT (100-var cap / 3 columns)

Verified: vp check clean, classifier tests pass, apps/web build succeeds.

* feat(compat-ui): share router filter between grid and trend chart

Lifts the router-filter state out of ContributionGrid into a new
CompatibilityViews client wrapper that owns the Kumo segmented Tabs
control. The grid and the line chart both consume the active filter
as a prop, so changing the tab updates both visualisations in lockstep.

The line chart now plots per-router series. The trend query was
rewritten to aggregate via JOIN against compat_suite_meta — one row
per run with app/pages/both/unknown rollups in a single round-trip,
~90 runs × ~1000 file rows over an indexed join. The TrendPoint
carries all five series; the chart picks one based on the filter
without re-fetching.

Other UI tweaks in this commit:
- Replaced the hand-rolled pill row with Kumo Tabs (variant='segmented',
  size='sm'). Fixes a weird active state (bg-kumo-default + text-kumo-base
  was using the text color for backgrounds, producing a saturated
  inversion).
- 'Parity' → 'Mixed' in user-visible labels. Internal identifiers
  (DB enum 'both', bucket variable 'parity') unchanged.
- Removed the standalone 'Other' stat card; the segmented Tabs still
  expose the 'Other' filter so unclassified suites are reachable.
- Reorganised the page into one 'Test files and trend' section
  containing both visualisations, gated by the shared Tabs.
- .gitignore: ignore .dev.vars (Wrangler's local-secrets convention)
  so the COMPAT_INGEST_SECRET we use for local testing never gets
  committed by accident.

Verified:
- vp check clean (format + type + lint)
- 14 classifier unit tests pass
- apps/web build:vinext succeeds; both API endpoints + page render

* fix(compat): address PR review comments

Addresses actionable items from the two /bigbonk review passes:

1. scanFixture: skip recursion into already-checked app/ and pages/
   (with a wrinkle around fixture wrappers).

   The previous code unconditionally pushed every child onto the walk
   stack, which (a) wasted work re-walking subtrees the route checker
   already covered and (b) risked a false positive on App Router route
   groups literally named 'pages' (e.g. app/pages/index.tsx would have
   tripped the Pages Router detector).

   Naive fix (skip descent into any app/ or pages/) regressed ~10 real
   Next.js fixtures that use a wrapping directory literally named 'app'
   as the test app's project root, with the real app/ and pages/ nested
   inside (test/e2e/og-api, test/e2e/middleware-static-files, etc.).

   Final fix: a directory named app or pages is treated as a fixture
   wrapper (and recursed into) if it contains a top-level next.config.*
   OR an inner app/ alongside an inner pages/. Otherwise it's handed to
   the route checker, which either finds real routes or returns nothing
   and the walk skips it. The wrapper detector intentionally does NOT
   treat middleware.{js,ts} as a wrapper signal, because Next.js tests
   put noop middleware.js files inside real App Router app/ to assert
   the file is ignored at that level (test/e2e/app-dir/app-middleware).

   Verified against the full test/e2e/ tree of the local Next.js
   checkout: 1036 suites classify identically to the pre-fix output
   (576 app / 325 pages / 113 both / 22 unknown), with the
   app/pages/index.tsx false-positive now correctly handled.

2. list-nextjs-e2e-suites.mjs: add the same import.meta.url guard the
   classifier already has, so importing the module programmatically
   doesn't immediately parse process.argv and write a file.

3. _auth.ts: early-exit when the X-Compat-Secret header is missing
   or empty, before paying two SHA-256 digests. The constant-time
   guarantee we care about (don't leak the contents of the expected
   secret via length or prefix matching) is preserved — we only
   short-circuit on values we already know can't match a non-empty
   secret.

4. Migration 0001_romantic_skullbuster.sql: add trailing newline.

Tests:
- 3 new regression tests added (18 total, all pass):
  * fixture wrapper with next.config.js + inner app/ + pages/
  * fixture wrapper with next.config.js + inner pages/ only
  * App Router app/ containing a route group named 'pages'
  * App Router app/ containing a noop middleware.js
- vp check clean
- Classifier output diff against current Next.js HEAD: empty

* fix(compat-ui): unexport cellMatchesFilter (knip)

CI's knip step flagged `cellMatchesFilter` as an unused export. It's only
used inside contribution-grid.tsx itself — the shared wrapper has its
own bucketing logic in compatibility-views.tsx. Drop the `export`.

* fix(compat): address third-pass review comments

1. sqlExcluded: narrow parameter type to a closed union
   ("router" | "classified_at") so the no-user-input invariant for
   sql.raw is compiler-enforced rather than relying on a code comment.

2. ContributionGrid: gate the SVG render on visibleCells.length > 0,
   and also clamp svgWidth/svgHeight to >= 0. Previously, when a
   filter emptied the grid, the SVG rendered with width=-3/height=-3
   alongside the placeholder div (browsers clamped silently but it
   was invalid SVG).

3. compatibility/page.tsx 'How this works' card: add a short note
   that per-router trend lines use the latest classification, so
   reclassifying a suite updates how it appears in historical runs.
   The aggregate "All" line is unaffected. Avoids a confusing
   support question if users notice old per-router numbers shift
   after a classifier improvement.

4. scanFixture readability: simplify the early-return checks from
   'if (hasApp && hasPages)' to 'if (hasPages)' / 'if (hasApp)',
   since the other flag was just set on the line above. No
   behavioural change; classifier output against the full Next.js
   test/e2e tree is byte-identical (1036 suites, 576/325/113/22).

5. /api/compatibility/classify body validator: bounds-check
   classifiedAt. Reject NaN/Infinity and any timestamp earlier
   than 2023-01-01 UTC. Catches the common seconds-vs-milliseconds
   mistake and accidental 0/-1 values from buggy callers. The
   only legitimate caller (the GH workflow) sends Date.now() so
   this is purely defensive.

Verified:
- vp check clean (format + type + lint + knip)
- 18 classifier unit tests pass
- apps/web build:vinext succeeds
- Classifier output against current Next.js HEAD is unchanged

* refactor(compat-ui): extract shared router bucketing module

Addresses items 1, 3, and 4 from the fourth review pass.

1. Trend GROUP BY note (item 1): added an inline comment explaining
   that selecting r.created_at while grouping by r.id only is a SQLite
   functional-dependency affordance. Standard SQL (postgres etc.)
   would reject it; the comment heads off a future 'fix' that breaks
   the query if it's ever ported.

2. Unified router bucketing (item 3): the three places that sliced
   cells by router filter all used slightly different naming
   conventions ('parity' vs 'both', 'other' vs 'unknown'). Extracted
   the canonical logic to a new ./router-buckets module with one
   shared vocabulary (the same as RouterKind), and updated all three
   call sites:

     - contribution-grid.tsx: cellMatchesFilter from shared module
     - compatibility-views.tsx: countByFilter for tab labels
     - page.tsx: bucketByRouter + bucketPassRate for stat cards,
       byRouter.parity/other renamed to byRouter.both/unknown

   Doc comment at the top of router-buckets.ts pins the
   'Mixed counts in both app and pages' rule in one place. No
   behavioural change.

3. passRate function rename (item 4): renamed the line chart's local
   ratio helper from passRate -> computePassRateRatio to avoid
   shadowing the passRate variable in page.tsx (and to distinguish
   it from bucketPassRate in router-buckets which returns a
   percentage, not a ratio).

Not addressed (intentionally):
- 'Mixed (both)' vs 'Parity'/'Interop' label (item 2): the user
  explicitly chose 'Mixed' over 'Parity' in an earlier message.
- trendRowsDesc rename (item 5): reviewer self-tagged 'very minor';
  renaming would muddle the diff.
- deriveSuiteGroup inconsistent levels (item 5b): pre-existing
  tooltip-only behaviour, not introduced by this PR.

Verified:
- vp check clean (format + type + lint + knip)
- 18 classifier unit tests pass
- apps/web build:vinext succeeds
2026-05-20 13:52:11 +01:00
Nathan Nguyen 76c5d448fc fix: enforce strict parameter isolation for root-params in prerendering and SSR (#1324)
* fix: support root-params dynamic getters during static prerendering and SSR

* fix: enforce strict parameter isolation for root params in prerendering and SSR

* fix: correct parameter mapping in multi-source resolver and clean up handleSsr fallback

* fix: use dedicated standalone ALS for root-params outside of unified request context

* fix: thread root params through generated app page dispatch

Normal App Router page requests computed root params in the typed handler, but the generated RSC entry did not forward them into page dispatch. SSR then received an empty root params scope for requests that should expose root params.

Destructure and forward rootParams in the generated entry, assert that template wiring, reuse the shared root-param picker, and align the scope helper API with the existing runWith overload pattern.
2026-05-20 10:53:25 +01:00
JamesbbBriz 7075d210e7 feat(cli): native App Router route type generation (#1144)
* Add native App Router route typegen

* Address route typegen review feedback

* Respect typegen target directory

* Require explicit LayoutProps route

* Serialize route type regeneration

* Derive layout params from route graph

* Scope layout slot typegen by route group

* Map typegen slots to owner layouts

* Preserve slot-local layout type keys

* Generate next-env.d.ts during typegen

* Address review feedback for route typegen

* Defer route sort and tighten typegen helpers

* Stabilize Windows create-next-app smoke check

* Address route typegen review fixes

* Match Next route type import

* Stabilize Windows create-next-app smoke

* Skip redundant route param shape writes

* Defer dev route type generation
2026-05-20 10:45:51 +01:00
Nathan Nguyen fa1392616d fix(pages-router): preserve i18n client root navigation (#1323)
* fix(pages-router): preserve i18n client root navigation

Pages Router client navigation fetched the unprefixed root URL even when the transition resolved to the default locale. That let root Accept-Language detection redirect a link click from /new to the preferred locale path, diverging from Next.js.

Use the active locale for implicit Link and Router transitions, fetch default-locale root HTML through a locale-qualified internal URL while preserving the visible unprefixed URL, and parse i18n __NEXT_DATA__ scripts that append locale globals.

* fix(pages-router): preserve i18n client root navigation

Pages Router client navigation fetched the unprefixed root URL even when the transition resolved to the default locale. That let root Accept-Language detection redirect a link click from /new to the preferred locale path, diverging from Next.js.

Use the active locale for implicit Link and Router transitions, fetch default-locale root HTML through a locale-qualified internal URL while preserving the visible unprefixed URL, and parse i18n __NEXT_DATA__ scripts that append locale globals.

* fix(pages-router): validate next data parsing

* fix(pages-router): preserve link locale transitions

* fix(pages-router): preserve popstate locale fetch

* chore(pages-router): address locale review nits
2026-05-20 10:40:58 +01:00
Nathan Nguyen 705da9a52f feat(cache): introduce static layout artifact reuse proof primitive (#1307)
* Enable static layout artifact reuse proof

* fix(cache): reject unproven static layout variants

Static layout reuse could previously accept public or internal candidate variant dimensions without proving that the current request had the same canonical dimensions. Public visibility only means the values are safe to expose, not that the layout artifact is invariant across routes, params, search state, or other dimensions.

Reject dimensioned static layout candidates until the API can compare candidate and current variants by canonical dimension source, name, privacy, and value hashes. Make cache outcome recording best-effort so telemetry failures cannot turn a cache hit into a miss or escape the read path.

* fix(cache): require route-budgeted layout reuse candidates

Static layout artifact reuse could still accept a raw buildCacheVariant result at the authority boundary. That allowed callers to bypass route-budget admission before receiving a canReuse true decision.

Require BuildCacheVariantWithRouteBudgetResult for createStaticLayoutArtifactReuseDecision and add a type-level regression that raw variants do not satisfy the authority input.

* chore(cache): clarify static layout proof traces

The approved review called out non-blocking ambiguity in trace fields and telemetry documentation. The proof behavior is unchanged, but the trace output and type regression are easier to reason about.

Sort unproven dimension sources defensively, rename artifact compatibility trace fallback fields, document raw app-page cache keys as internal-only, and make the raw-variant type regression explicit without a dead branch.

* chore(cache): dedupe static layout dimension sources

The unproven-dimension trace already sorted sources, but duplicate source labels could still make traces noisier than necessary. Use the existing sortedUnique helper so the source summary is deterministic and compact while dimensionCount remains exact.
2026-05-20 10:36:20 +01:00
Nathan Nguyen 0d8d3e3c60 refactor(router): quarantine navigation runtime topology (#1322)
* refactor(router): quarantine navigation runtime topology

* fix(router): restore manifest-backed interception runtime

App Router browser navigations lost first-hop intercepted route context after snapshot topology stopped acting as fallback proof. That made /feed to /photos/:id request the direct page payload instead of the declared modal payload.

The violated invariant was that route topology must come from the manifest, while snapshots only carry runtime proof. Resolve first-hop interception from the manifest, move RSC bootstrap streams onto the symbol-backed runtime, teach prerender extraction that script shape, and harden the runtime contract around route manifest and RSC data.

Coverage includes runtime seam tests, prerender extraction tests, planner/browser navigation tests, package rebuild, and the app-router intercepted routes E2E slice.

* fix(router): narrow prerender RSC script parsing

The prerender extractor accepted any script starting with the symbol-runtime bootstrap expression, then parsed it as though it had the exact RSC chunk-push prefix. That was harmless at the current head, but it made future runtime scripts a foot-gun.

Only the exact runtime chunk prefix and exact done marker are now accepted. A focused extractor test covers non-chunk runtime scripts that share the bootstrap expression prefix.

* fix(router): address navigation runtime review

* fix(router): resolve navigation runtime review threads
2026-05-20 10:29:13 +01:00
Nathan Nguyen 3262443f0b fix(pages-api): execute edge API routes with Fetch Request (#1320)
* fix(pages-api): execute edge API routes with Fetch Request

Pages API routes with config.runtime set to edge were still executed through the Node-style req/res adapter. That gives user handlers a plain headers object and turns valid Edge API code that returns a Response into a 500.

Detect edge and experimental-edge API route modules before Node body parsing, pass the Fetch Request through, and forward the returned Response. The dev Node bridge mirrors the generated Web Request path and preserves Set-Cookie response headers.

Regression coverage ports the relevant Next.js edge-async-local-storage contract for Fetch Request execution, concurrent AsyncLocalStorage isolation, and the dev bridge.

* fix(pages-api): stream edge API responses in dev

* fix(pages-api): handle closed dev edge streams

* fix(pages-api): stream dev edge request bodies

* fix(pages-api): validate dev edge forwarded protocol
2026-05-19 18:12:42 +01:00
Nathan Nguyen afc549d624 fix(router): normalize trailing slash parity (#1316)
Trailing slash handling treated every non-api path the same on server redirects, so trailingSlash:true added slashes to file-looking catch-all routes and Pages Router imperative navigation skipped the client canonicalization path used by Link.

Next.js splits the invariant between route-manifest redirects and client resolveHref normalization: file-looking paths lose a trailing slash, non-file paths gain one, and queries stay attached to the canonical pathname. Share the server redirect decision across Pages Router runtimes and apply the same client helper in Router.push and Router.replace.

Adds focused regressions for catch-all dot segments, query preservation, .well-known exclusion, and Pages Router history writes.
2026-05-19 18:06:36 +01:00
Nathan Nguyen 5b6b764bbd fix(app-router): prerender layout static params (#1318)
* fix(app-router): prerender layout static params

* address review: uniform resolver contract, sentinel comment, drop defensive clamp

---------

Co-authored-by: James <james@eli.cx>
2026-05-19 18:03:16 +01:00
Nathan Nguyen 1551f91cbd fix(metadata): match Next dynamic route semantics (#1317)
* fix(metadata): match Next dynamic route semantics

Metadata routes diverged from Next.js in several observable cases: robots.txt emitted Sitemap before Host, dynamic metadata Response results could miss framework cache headers, TSX sitemap files were not discovered, and rendered metadata URLs treated canonical, manifest, and file-based social image routes as the same URL class.

The implementation now follows Next's metadata route extension, cache, robots, and URL resolution rules while keeping file-based social image route markers non-enumerable so resolved metadata shape does not leak internal state.

* fix(metadata): gate social image preview fallback

File-based social image routes used the preview deployment URL whenever VERCEL_URL was present outside development. That let deployment URLs override an explicit metadataBase in non-preview production environments.

Gate preview URL fallback on VERCEL_ENV=preview, add coverage for metadataBase precedence and production fallback URLs, and keep static metadata image cache headers stable in development.
v0.0.51
2026-05-19 14:11:47 +01:00
Nathan Nguyen e0eabc122b feat(router): add manifest-backed interception topology (#1314)
* feat(router): add manifest-backed interception topology

* test(router): cover concrete dynamic interception proof ids

* feat(router): index interception facts by slot
2026-05-19 13:15:31 +01:00
James Anderson 9415a238f4 feat(config): implement assetPrefix (#1311)
* feat(config): parse and apply next.config assetPrefix at build time

Wire `assetPrefix` from next.config through to the Vite build so every
emitted JS/CSS/image/static asset URL is prefixed accordingly. Mirrors
Next.js semantics: `assetPrefix` controls asset URLs only, independent
of `basePath` (which still controls route URLs).

* Add `assetPrefix` to NextConfig and ResolvedNextConfig, normalised via
  a new `normalizeAssetPrefix` helper that trims trailing slashes,
  accepts absolute URLs (`https://cdn.example.com[/sub]`) and path
  prefixes (`/custom-asset-prefix`), and rejects non-string / unparseable
  values.
* New `packages/vinext/src/utils/asset-prefix.ts` module collects the
  shared helpers: `resolveAssetsDir` (on-disk layout), `resolveAssetUrlPrefix`
  (emitted URL prefix), `assetPrefixPathname` (path portion for runtime
  matching), and the canonical `_next/static` directory constant.
* In the Vite plugin, set `build.assetsDir` so on-disk layout matches the
  URL (`<prefix>/_next/static/` for path prefixes, `_next/static/` for
  absolute URLs). Use `experimental.renderBuiltUrl` to override Vite's
  default `base + url` URL emission so `assetPrefix` and `basePath` stay
  independent — routes continue to anchor at `base = basePath + '/'`.
* Persist `assetPrefix` in the embedded `vinextConfig` for the Pages
  Router server and in a new `vinext-runtime-config.json` sibling for
  the App Router prod server (the RSC entry only embeds `__basePath`).

@see https://nextjs.org/docs/app/api-reference/config/next-config-js/assetPrefix

* feat(server): serve assetPrefix-prefixed assets at runtime

Update the production server to resolve incoming asset requests under
`<assetPrefix>/_next/static/<filename>` (path-prefix form) and
`/_next/static/<filename>` (absolute-URL form) in addition to the
historical `/assets/` URLs. Both routers (Pages and App) share a new
`resolveAppRouterAssetPath` helper that returns the lookup path on disk
or `null` to fall through.

* App Router prod server loads `assetPrefix` from the new
  `vinext-runtime-config.json` sibling written by the Vite plugin.
* Pages Router prod server reads `assetPrefix` from the embedded
  `vinextConfig` and threads it through the existing
  `stripBasePath` lookup.
* StaticFileCache marks any path under `_next/static/` as content-hashed
  (alongside the existing `assets/` rule) so emitted bundles get the
  long-lived `immutable` cache headers. Same change in the slow-path
  branch of `tryServeStatic`.

Cloudflare Workers needs no code change: the ASSETS binding serves
`<prefix>/_next/static/...` directly from `dist/client/<prefix>/_next/static/...`
because the on-disk layout already mirrors the URL. Absolute-URL
prefixes are expected to be served by the CDN, so they don't reach the
worker.

* test(asset-prefix): add unit + e2e build tests for next.config assetPrefix

Covers the surface area added in the previous two commits:

* Unit tests on the new helpers: `normalizeAssetPrefix` (path vs URL,
  trailing-slash trimming, validation), `resolveAssetsDir` /
  `resolveAssetUrlPrefix` (on-disk vs emitted-URL layout),
  `assetPrefixPathname` (runtime matching), and
  `resolveAppRouterAssetPath` (request → on-disk lookup translation).
* End-to-end build tests that exercise `vinext build` against the
  `tests/fixtures/app-basic` app patched with each `assetPrefix` shape:
    - Path prefix (`/custom-asset-prefix`): SSR HTML references
      `/custom-asset-prefix/_next/static/` URLs, on-disk layout matches,
      and fetching the bootstrap URL from the prod server returns 200.
    - Absolute URL (`https://cdn.example.com`): emitted asset URLs are
      fully qualified; no `/assets/` leaks.
    - Combined `basePath: '/app'` + `assetPrefix: '/cdn-prefix'`: routes
      stay under `/app/` while asset URLs live under `/cdn-prefix/` —
      proving the two configs stay independent.
    - Unset (default): historical `/assets/` layout is preserved.

Ported from Next.js: test/e2e/app-dir/asset-prefix/asset-prefix.test.ts
https://github.com/vercel/next.js/blob/canary/test/e2e/app-dir/asset-prefix/asset-prefix.test.ts

* test(next-config): add assetPrefix field to makeResolved helper

ResolvedNextConfig requires assetPrefix; the test helper was missing
it which broke the type check.

* fix(config): replace polynomial regex with loop (CodeQL)

CodeQL flagged trim().replace(/\/+$/, "") and replace(/^\/+/, "")
as polynomial-time backtracking on uncontrolled input. Swap both for
explicit while loops with linear-time semantics. Same fix applied to
the URL pathname trim in assetPrefixPathname().

* refactor(prod-server): drop dead null fallback; tighten hashed-asset detection

- Remove the `?? null` after resolveAppRouterAssetPath; the helper
  already returns null on miss, so the fallback (and its obsolete
  comment) were no-ops.
- Add explicit startsWith("_next/static/") / startsWith("/_next/static/")
  prefix checks before the substring fallback in both static-file-cache
  and prod-server. The substring check stays so assetPrefix-prefixed
  layouts (e.g. "cdn/_next/static/") are still recognised.

* fix(build/precompress): use resolved assetsDir so assetPrefix builds get compressed

precompressAssets() hardcoded `<clientDir>/assets/`. When `assetPrefix`
is configured the build writes hashed assets to a different subdirectory
(`<prefix>/_next/static/` for path prefixes, `_next/static/` for
absolute URLs) and the precompression step walked an empty directory,
silently emitting zero .br/.gz/.zst files. Cloudflare Workers builds
with assetPrefix would then serve uncompressed assets.

Thread the resolved subdirectory through from resolveAssetsDir(assetPrefix)
at the plugin call site, default to `"assets"` for backward compat,
and add focused tests for both assetPrefix layouts.

* refactor: drop redundant isHashed startsWith; harden test cleanup against build failures

- prod-server.ts: drop the redundant `pathname.startsWith("/_next/static/")`
  check; `pathname` always has a leading slash so the existing
  `includes("/_next/static/")` already covers it.
- static-file-cache.ts: keep both `startsWith("_next/static/")` and
  `includes("/_next/static/")` because `relativePath` has no leading
  slash — the prefix and substring checks cover different layouts
  (absolute-URL prefix vs path-prefix). Clarify the comment.
- tests/asset-prefix.test.ts: register the tmp-dir cleanup with the
  shared `cleanups` array synchronously inside `buildFixtureWithConfig`,
  right after `mkdtempSync`. Previously the cleanup was only pushed
  after the build returned, so a thrown `createBuilder`/`buildApp`
  would leak the tmp dir.

* refactor(build): embed assetPrefix in App Router entry instead of sidecar JSON

Mirrors the existing __basePath embed pattern (see entries/app-rsc-entry.ts)
and how Pages Router exposes `vinextConfig.assetPrefix` from its own entry.
The runtime-config.json sidecar was unnecessary — the App Router entry
template already inlines config values as compile-time constants, so
assetPrefix should be inlined the same way.

Changes:
- Add `assetPrefix` to `AppRouterConfig` in entries/app-rsc-entry.ts.
- Emit `export const __assetPrefix = <json>` from the generated RSC
  entry alongside the existing `__basePath` / `__configHeaders` /
  `__expireTime` constants. Single-line declaration to stay within the
  "thin generated entry" guideline.
- Thread `nextConfig.assetPrefix` through the generateRscEntry call site
  in index.ts.
- App Router prod-server reads `rscModule.__assetPrefix` (with a string
  fallback for older builds) instead of `vinext-runtime-config.json`.
- Delete the vinext-runtime-config.json write in index.ts. Nothing else
  in the repo reads this file (verified via rg on packages/ tests/
  examples/).

Pages Router behaviour is unchanged: it already gets assetPrefix from
the embedded `vinextConfig` exported by pages-server-entry.ts.

Addresses james-elicx review feedback on #1311.

* fix(ssr): emit bootstrap as <script src> via bootstrapModules

The App Router SSR pipeline previously passed only `bootstrapScriptContent`
to React's renderToReadableStream, producing inline
`<script>import("/path/to/entry.js")</script>` tags. The URL was therefore
inaccessible to anything that reads `script.attribs.src` — including the
Next.js asset-prefix fixture test "bundles should return 200 on served
assetPrefix" which iterates script tags looking for a `src` attribute
prefixed by the configured assetPrefix.

Extract the bootstrap URL from the plugin-rsc-generated content and pass
it as `bootstrapModules: [url]` so React emits a real
`<script type="module" src="url">` tag. The existing
`extractModulePreloadHtml` helper already did the same extraction for
the modulepreload <link>, so the URL was already exposed (just renamed
`extractBootstrapModuleUrl` and lifted out of the head-injection helper
to share between the bootstrap and the preload tags).

The inline `bootstrapScriptContent` is no longer needed — it was only
carrying the dynamic-import call that bootstrapModules now performs
natively. Passing both would load the bootstrap module twice.

`bootstrapModules` works in both dev and prod: in dev, plugin-rsc emits
a Vite dev-server URL (e.g. `/@id/__x00__virtual:vinext-app-browser-entry`)
that the browser fetches as a module from the dev server; in prod it's the
hashed bundle URL, optionally prefixed by `assetPrefix`.

Mirrors Next.js's app-render path which combines both
`bootstrapScriptContent` (for session state) and `bootstrapScripts`
(for the actual chunk URL). See
.nextjs-ref/packages/next/src/server/app-render/required-scripts.tsx.
We use `bootstrapModules` instead of `bootstrapScripts` because vinext's
chunks are native ES modules (Vite output) — a `type="module"` tag is the
correct loader.

Tests updated where they asserted on the old `import("URL")` shape:
tests/asset-prefix.test.ts, tests/app-router.test.ts,
tests/e2e/cloudflare-workers/navigation.spec.ts.

Verified all related suites pass locally (asset-prefix, app-router,
script-nonce, script-preload, use-client-page-pathname, document,
prerender, ISR, fetch-cache, entry-templates, precompress).

* fix(build): use resolveAssetsDir for Pages Router client entry fallback scan

The Pages Router Cloudflare Workers post-build step scans the on-disk
assets directory for the client entry chunk when the SSR manifest
lookup doesn't surface one. The directory path was hardcoded to
`<clientDir>/assets/`, which is the Vite default — but when
`assetPrefix` is configured, the chunk lives under
`<prefix>/_next/static/` (path-prefix) or `_next/static/` (absolute-URL
prefix). The fallback would silently miss the entry and the worker
would ship without its client bootstrap reference.

Use the shared `resolveAssetsDir(assetPrefix)` helper that already
drives `build.assetsDir` and the prod-server lookup, so this scan
works for every supported asset layout.

Addresses round-4 reviewer feedback on #1311 (low-priority follow-up
item — only fires when the SSR manifest lookup fails).

* feat(config): mirror Next.js basePath → assetPrefix fallback

Next.js auto-sets `assetPrefix = basePath` when `basePath` is configured
and `assetPrefix` is left unset (packages/next/src/server/config.ts:528-531).
Without this, an app deployed under a basePath serves its routes
correctly but emits its assets from `<basePath>/assets/...` (Vite's
default `base + assetsDir` composition) rather than from the Next.js-
canonical `<basePath>/_next/static/...`.

Add the same fallback in resolveNextConfig, gated by Next.js's own
conditions:
  - basePath !== ""   (skip when basePath is unset)
  - basePath !== "/"  (Next.js rejects this earlier; we mirror the
                       guard so we never produce assetPrefix === "/")
  - assetPrefix === "" (user did not opt out)

The fallback feeds the rest of the asset-prefix pipeline (Vite's
`build.assetsDir`, `experimental.renderBuiltUrl`, and the prod-server
URL→file resolver) so no other code paths need to change.

Tests:
 - tests/next-config.test.ts: 5 unit tests for the four edge cases of
   the fallback condition.
 - tests/asset-prefix.test.ts: new end-to-end test that builds with
   `basePath: "/app"` (no assetPrefix), starts the prod-server, and
   asserts the bootstrap <script src> lands at /app/_next/static/...
   (and that the legacy /app/assets/... layout is gone).
 - tests/pages-router.test.ts: updated the "preserves basePath on
   backfilled SSR manifest entries" test to expect the new
   /docs/_next/static/ layout (was /docs/assets/). The manifest
   assertion is relaxed to startsWith("docs/") because Vite's raw
   SSR manifest now emits some duplicate-prefixed entries
   ("docs/docs/_next/static/...") alongside the backfilled ones; both
   forms anchor under basePath so the prod-server's URL→file lookup
   remains correct — and the user-visible HTML assertions in the same
   test are the source of truth.

Addresses round-4 reviewer feedback on #1311.

* fix(server): Pages Router must not strip basePath before asset lookup

The Pages Router prod-server stripped `basePath` from the request
pathname BEFORE handing it to `resolveAppRouterAssetPath`, which broke
the asset lookup whenever the assetPrefix path-prefix branch was active
with a path that happened to equal basePath.

Concrete failure: with the round-4 commit `bac8425` mirroring Next.js's
`basePath → assetPrefix` fallback, an app configured with
`basePath: "/docs"` (and no explicit `assetPrefix`) now resolves
`assetPrefix = "/docs"`. Emitted asset URLs are
`/docs/_next/static/…` (correct). But the Pages Router branch did:

  const staticLookupPath = stripBasePath(pathname, basePath);
  // "/docs/_next/static/x.js" → "/_next/static/x.js"
  resolveAppRouterAssetPath(staticLookupPath, "/docs", "/docs");
  // path-prefix branch needs pathname to start with "/docs/" → null → 404

The App Router branch already passes the un-stripped `pathname` to the
same helper (see line ~1140). Match that behaviour. `staticLookupPath`
is still computed and used downstream by non-asset paths
(image-optimization endpoint, page routing), where the basePath-stripped
form is correct.

Test coverage:
 - tests/pages-router.test.ts: extended the existing "preserves basePath
   on backfilled SSR manifest entries" test to FETCH every emitted asset
   URL and assert HTTP 200 (previously it only inspected HTML for the
   URL string). This is the missing assertion that would have caught
   the bug the first time.
 - tests/asset-prefix.test.ts: added a parallel Pages Router e2e test
   that builds an inline pages/ fixture with `basePath: "/docs"`,
   starts the prod-server, collects every script/link URL from the SSR
   HTML, and asserts every URL serves a 200 + correct content-type.
   Confirmed to fail on `bac8425` before this fix and pass after.

Addresses round-5 reviewer feedback on #1311.

* refactor(ssr): accept either quote style in bootstrap URL regex

plugin-rsc currently emits the bootstrap as `import("<url>")` with
double quotes, but the contract isn't documented and a future version
could switch to single quotes. Tighten the URL extraction regex to
`import(["'])([^"']+)\1` so either form works.

Addresses non-blocking round-5 review feedback on #1311.

* docs(ssr): note CSP implications of bootstrapModules switch

The round-3 commit `3a2c886` switched the App Router SSR bootstrap from
inline `bootstrapScriptContent` (`<script>import(…)</script>`) to
React's `bootstrapModules` option (`<script type="module" src=…>`).
That has user-visible CSP consequences worth documenting at the call
site so app authors hit them in code search rather than at runtime:

  - Apps no longer need `script-src 'unsafe-inline'` for the bootstrap
    (improvement — inline imports previously required it).
  - Apps that restrict script sources need `'self'` for same-origin,
    or the CDN origin when assetPrefix is an absolute URL.
  - React still threads nonce through to the emitted <script> tag, so
    nonce-based CSP (`script-src 'nonce-…' 'strict-dynamic'`) keeps
    working unchanged.

Addresses non-blocking round-5 review feedback on #1311.
2026-05-19 11:53:49 +01:00
Nathan Nguyen 1dbcbfbcc8 feat(app-router): plan navigation from RouteManifest topology (#1312)
* feat(app-router): plan navigation from RouteManifest topology

Navigation commit approval still trusted flight payload snapshots for root-boundary and target-slot topology even when the static RouteManifest existed. That made stale or missing snapshot metadata authoritative during App Router commits, which could preserve layouts across the wrong root or fall back to soft commits when the graph already proved a same-root transition.

The planner now resolves current and target topology through RouteManifest when available, uses snapshot slot bindings only as visible content proof, and keeps explicit interception proof as the authority for intercepted slot commits. The generated browser entry embeds the manifest and approval paths pass it through to the planner.

Targeted coverage exercises stale snapshot roots, missing snapshot roots, dynamic URL pattern resolution, manifest target slot bindings, visible-slot proof, intercepted source topology, and browser-entry manifest serialization.

* fix(app-router): read route manifest lazily during navigation approval

The browser entry assigned the generated RouteManifest global before a static import, but ESM evaluates imported modules before the importer body. That let the browser navigation controller capture null at module evaluation, leaving manifest-aware planning disabled in real browser bootstraps.

Make the controller accept a lazy RouteManifest getter and read it at commit-planning time. Also require direct manifest route-id hits to match the snapshot matchedUrl before trusting them, so stale-but-valid route ids fall back to concrete URL pattern resolution.

Covered by app browser controller and navigation planner regressions, plus targeted entry template checks.

* fix(app-router): clarify manifest route topology resolution

Manifest route lookup now shares one matched-url normalization helper across direct route checks and pattern fallback. That keeps concrete dynamic URL fallback from drifting across call sites.

Document that manifest pattern iteration follows buildAppRouteGraph route precedence, and record why intercepted targets cannot use direct-target manifest slot bindings.
2026-05-18 18:24:45 +01:00
Divanshu Chauhan (divkix) a85c2c1dee fix: add x-action-forwarded guard and E2E tests for server action forwarding loop (#1292)
* feat(server-action): add x-action-forwarded guard to prevent forwarding loops

Defensively implements the Next.js server action forwarding loop fix.
- Adds ACTION_FORWARDED_HEADER constant to headers.ts
- Adds x-action-forwarded to INTERNAL_HEADERS blocklist
- Adds guard to handleServerActionRscRequest and handleProgressiveServerActionRequest
- Returns 404 + x-nextjs-action-not-found when header is present

Ported from Next.js: vercel/next.js@20892dd

* test(e2e): add action-forward-loop fixtures and tests

- Adds /nextjs-compat/action-forward-loop fixture with middleware rewrite
- Adds /nextjs-compat/action-forward-loop-rewrite as rewrite target
- Adds error boundary component using unstable_isUnrecognizedActionError
- Adds E2E tests: middleware rewrite + action POST, not-found header contract

* test(e2e): fix action-forward-loop tests per review feedback

- page.tsx: use useActionState so action result renders into #action-result
- actions.ts: update runAction signature for useActionState compat
- Remove dead error-boundary.tsx (never exercised in single-worker model)
- Rename second test to match actual behavior (stale action ID -> 404)

* test(request-pipeline): add x-action-forwarded to INTERNAL_HEADERS snapshot
2026-05-18 18:07:05 +01:00
James Anderson 4aaae3d23e fix(build): pin cssTarget so esbuild preserves max-width media-query syntax (#1310)
* fix(build): pin cssTarget so esbuild preserves `max-width` media-query syntax

Vite's default `build.cssTarget` follows `build.target` (modern evergreens),
which lets esbuild's CSS minifier rewrite `@media (max-width: 768px)` to
the Media Queries Level 4 range syntax `@media (width <= 768px)`. Both
forms are semantically equivalent in modern browsers, but the rewrite is
observable to user code that inspects `cssText` on `CSSMediaRule`s and
breaks tools that pattern-match the raw query string. Next.js's CSS
pipeline (webpack + cssnano, or lightningcss only when opted in via
`experimental.useLightningcss`) preserves the original syntax by default,
so user code carried over from Next.js can break when migrating to vinext.

Pin `build.cssTarget` to ["chrome87", "edge88", "firefox78", "safari15"],
which predate Safari 16.4 — the first browser to support MQ Level 4 range
syntax. esbuild checks the configured target and skips lowering when any
target is too old. Only literal media-query syntax is affected; matching
behavior at runtime is unchanged.

Mirrors the Next.js fixture
test/e2e/app-dir/css-media-query/css-media-query.test.ts which asserts
`cssText` preserves `max-width: 768px`.

Tests:
- New tests/css-media-query-target.test.ts builds a minimal fixture
  and asserts the produced CSS asset contains `max-width: 768px` and
  does not contain `width<=768px`. Fails without this change; passes
  with it.
- All existing CSS / SCSS / build-optimization / nextjs-compat app-css
  tests continue to pass.

* refactor(build): narrow cssTarget to only pin Safari low

Address review feedback on #1310. Only Safari needs to be pinned to a
version that predates MQ Level 4 range syntax (Safari 16.4) so esbuild
skips the @media (max-width: …) → @media (width <= …) rewrite. The other
targets were pinned far older than necessary, which caused esbuild to
downlevel unrelated modern CSS features (`:is()`, CSS Nesting, `lab()`,
`oklch()`, gradient interpolation, etc.).

Bumps chrome/edge/firefox targets to ~2023 baselines while keeping
safari15 to retain the MQ rewrite fix.
2026-05-18 16:59:56 +01:00
Divanshu Chauhan (divkix) 54e271ddc9 fix(cache-runtime): attach inner "use cache" call site as cause of nested-dynamic error (#1289)
* fix(cache-runtime): attach inner "use cache" call site as cause of nested-dynamic error

Ports Next.js PR #93707 to vinext. When a nested "use cache" propagates a
dynamic cache life (revalidate: 0 or expire < 5 minutes) to a parent without an
explicit cacheLife(), the error now attaches the inner invocation as .

Changes:
- Add NestedDynamicUseCacheError class (captured eagerly at inner call site)
- Track hasExplicitRevalidate / hasExplicitExpire on CacheContext
- Propagate dynamicNestedCacheError via ??= (first child wins)
- Throw with  when outer lacks explicit cacheLife for the relevant field
- Update error messages to consistently backtick "use cache"
- Add unit tests covering revalidate:0, short expire, explicit outer, first-child-wins

Refs: #1194
Ported from: https://github.com/vercel/next.js/pull/93707

* docs(cache-runtime): document nested-dynamic error propagation invariants

Addresses bigbonk review feedback on #1289:

- Guard `Error.captureStackTrace` with a typeof check for non-V8 runtime
  robustness. The constructor already captures a stack; this call only
  trims the constructor frame.
- Document that propagating the inner's resolved life into the parent's
  `lifeConfigs` is load-bearing: minimum-wins must reflect the inner's
  dynamic values for the outer's nested-dynamic detection to fire, and
  the outer's explicit `cacheLife()` correctly suppresses the throw by
  pushing the resolved values above the dynamic thresholds. Matches
  Next.js `propagateCacheLifeAndTagsToRevalidateStore`.
- Clarify that `hasExplicitRevalidate` / `hasExplicitExpire` flags
  really mean "cacheLife() was called and the resolved config includes
  this field", not "the user explicitly passed this field" — matches
  upstream which tracks the flag at the work unit store level.
- Strengthen the first-child-wins test to assert the outer error
  message contains "revalidate" (not "expire"), rigorously verifying
  that `dynamicNestedCacheError ??=` preserves the first dynamic
  child's eager error as the cause.

* fix(cache-runtime): gate nested-dynamic throw on prerender/dev to match Next.js

Addresses bigbonk review feedback on #1289 (round 2):

- Previously the nested-dynamic-cache error threw unconditionally, even
  during normal production dynamic SSR. The error messages say
  "not allowed during prerendering", which is misleading outside the
  prerender phase. Next.js gates the equivalent check on the work unit
  type being `prerender` (or `request` in development) — see
  use-cache-wrapper.ts cases 'prerender'/'request' at the read site.
- Gate the throw on `process.env.VINEXT_PRERENDER === "1"` (set by
  build/prerender.ts during the prerender phase) or NODE_ENV ===
  "development". Production runtime SSR no longer throws; the outer
  function still executes and its result simply isn't cached
  (minimum-wins resolves its effective revalidate to 0).
- Add a regression test verifying no-throw behavior under production
  runtime conditions (no VINEXT_PRERENDER, NODE_ENV=production).
- Document caller paths (shared MISS, private, dev) on
  `runCachedFunctionWithContext` — all three reach this helper and
  propagate lifeConfigs / dynamicNestedCacheError up to the parent,
  matching Next.js's `propagateCacheEntryMetadata`. Request-scoped
  recording uses minimum-wins so inner-vs-outer order doesn't matter.
- Add a test documenting the potentially surprising minimum-wins
  semantics: with `cacheLife({ revalidate: 60 })` on the outer and
  `cacheLife({ revalidate: 0 })` on the inner, the throw is suppressed
  but the outer's effective revalidate resolves to 0, so the outer's
  result isn't cached and the function executes on every call. Matches
  Next.js `propagateCacheLifeAndTagsToRevalidateStore`.
- Wrap prerender-gated tests in a `withPrerenderFlag` helper that
  sets/restores VINEXT_PRERENDER around each test.

* fix(cache-runtime): make nested-dynamic error message context-aware; pin dev/prod gate

Addresses bigbonk review feedback on #1289 (round 3):

- Make the nested-dynamic error messages context-aware: emit "in
  development" when NODE_ENV=development, "during prerendering"
  otherwise. The previous unconditional "during prerendering" wording
  was misleading when the gate fired in dev SSR. Done via two small
  message-builder functions instead of constants.
- Add a dev-mode test ("does throw in development mode even without
  VINEXT_PRERENDER set") that pins down the other side of the gate.
  Without this, Vitest's default NODE_ENV=test would mask a regression
  that broke the gate to e.g. `!== "production"` instead of
  `=== "development"`. Together with the existing production test,
  both branches of the gate are now covered.
- Document the sequencing invariant between the inner's
  `parentCtx.lifeConfigs.push(effectiveLife)` and the outer's
  `resolveCacheLife(ctx.lifeConfigs)`. The correctness depends on the
  inner's await completing (including its post-execution propagation)
  before the outer's resolveCacheLife runs. A future refactor away from
  `await` (e.g. Promise.all with short-circuit) would silently break
  this and produce stale minimum-wins results.
- Document the eager-Error allocation overhead trade-off: matches
  Next.js (captured eagerly so `cause` points at the original call
  site), with the lazy-capture optimization noted for future profiling.

Verified: tests/shims.test.ts (918 tests), cache + ISR + fetch-cache
suites, and full `vp check` all clean.

* docs(cache-runtime): clarify gate priority, effectiveLife semantics, and per-field flag note

Addresses bigbonk review feedback on #1289 (round 4):

- Prioritize VINEXT_PRERENDER over NODE_ENV=development in
  nestedCacheContextPhrase. If the prerender flag is set, the user is
  prerendering regardless of NODE_ENV (e.g. a dev-config prerender),
  so "during prerendering" is the accurate phrase.
- Document the semantic difference between checking the outer's merged
  effectiveLife.revalidate vs the inner's entry metadata (as Next.js
  does). Behavior is observably equivalent in all cases due to the
  hasExplicit* guards; we use effectiveLife because vinext doesn't
  model a CacheResultMetadata type.
- Extend the hasExplicit* comment in cache.ts to clarify that the
  `!== undefined` checks are effectively unconditional (default profile
  always provides both fields). They remain as defensive guards; per-
  field suppression would require inspecting the raw user input and
  would diverge from Next.js semantics, so it's noted as a deliberate
  design change rather than an incidental one.

Bonk's remaining comments are informational:
- Comment 2 ("lifeConfigs.push is a behavioral change"): the PR
  description already calls this out; no code change.
- Comment 4 ("NODE_ENV=test gate coverage gap"): explicitly flagged as
  not blocking; the dev + prod test pair already pins both branches.

Verified: tests/shims.test.ts (918 tests) and full `vp check` clean.

* docs(cache-runtime): correct hasExplicit guard explanation; note parallel-call invariant

Addresses bigbonk review feedback on #1289 (round 5):

- Fix incorrect minimum-wins explanation in the throw block comment.
  The previous wording claimed minimum-wins "drops" the inner's values
  when the outer has an explicit cacheLife() — that's wrong. Minimum-
  wins takes Math.min(60, 0) = 0, so the threshold check still
  evaluates true. What actually suppresses the throw is the
  `!ctx.hasExplicitRevalidate` / `!ctx.hasExplicitExpire` guard.
  Future maintainers reading the old comment might have concluded the
  hasExplicit* guards were redundant; the corrected wording makes
  clear they are load-bearing.
- Also corrected the same misconception in the comment above the
  `parentCtx.lifeConfigs.push` block ("the outer only suppresses the
  throw when its own explicit cacheLife() pushed the effective values
  above the dynamic thresholds"). Replaced with the accurate two-step
  description: lifeConfigs.push makes the threshold checks possible;
  hasExplicit* guards independently decide suppression.
- Add a sentence to the sequencing-invariant comment noting it holds
  for both sequential (`await innerA(); await innerB()`) and parallel
  (`await Promise.all([innerA(), innerB()])`) inner call patterns,
  since the outer's `await cacheContextStorage.run(...)` only resolves
  after fn's returned promise settles.
- Updated PR description to call out `parentCtx.lifeConfigs.push` as a
  standalone behavioral change in production (not just during
  prerender/dev), separate from the error-cause feature. Linked to
  the minimum-wins documenting test that pins this behavior.

Verified: tests/shims.test.ts (918 tests) and `vp check` clean.

---------

Co-authored-by: James <james@eli.cx>
2026-05-18 15:55:42 +01:00
James Anderson 558cec0328 fix(shims/script): preload <script> sources during SSR via ReactDOM.preload (#1309)
Mirror Next.js's App Router behavior at
.nextjs-ref/packages/next/src/client/script.tsx:298-376: when SSR-rendering
`<Script src={...} />` with `strategy="afterInteractive"` (the default)
or `strategy="beforeInteractive"`, call `ReactDOM.preload(src, { as: 'script', ... })`
so React Float hoists `<link rel="preload" as="script" href={src} />` into
`<head>`. The script is then fetched in parallel with the streamed HTML.

Before this change, the SSR branch of the Script shim only emitted a
`<script>` tag for `beforeInteractive` and returned `null` for every other
strategy, with no preload. The matching Next.js fixture test
test/e2e/app-dir/app-esm-js/index.test.ts ("should be able to render
nextjs api in app router") asserts:

  expect($('head link[href="/test-ext.js"]').length).toBe(1)
  expect($('head link[href="/test.js"]').length).toBe(1)

…which fails today because no preload links are emitted.

The existing Image shim already uses this exact React Float pattern
(packages/vinext/src/shims/image.tsx:285-286), so the SSR pipeline
already supports Float hoisting.

Tests:
- Updated existing Script SSR tests in tests/script.test.ts and
  tests/shims.test.ts that asserted afterInteractive/default rendered
  empty — they now assert the preload <link> is emitted (no <script>
  tag), matching Next.js.
- Added tests/nextjs-compat/script-preload.test.ts that ports the
  app-esm-js fixture's preload assertions against the app-basic
  /script-nonce page (which already exercises afterInteractive and
  beforeInteractive strategies via the existing fixture).
- crossOrigin handling matches React's type contract
  ("anonymous" | "use-credentials" | undefined). React normalises
  "anonymous" to `crossorigin=""` in HTML; tests accept both forms.

All shim / script / page / document / nonce tests pass (1151 tests).
2026-05-18 15:29:07 +01:00
James Anderson 14ec0a1e62 fix(shims): treat draftMode() as non-dynamic; mark dynamic only on enable/disable (#1308)
Calling `draftMode()` and reading `isEnabled` are plain reads and must not
opt the route out of static prerendering. Only `enable()` / `disable()`
mutate state and need to be tracked as dynamic, matching Next.js's
`trackDynamicDraftMode` (see
.nextjs-ref/packages/next/src/server/request/draft-mode.ts:152-165).

Before this change, any App Router page that called `draftMode()` — even
just to check `isEnabled` — set `dynamicUsageDetected = true`, which:
- caused build-time speculative prerender to skip the route
- caused the request-time ISR HTML write to bail out

Tests for the matching Next.js fixture
(test/e2e/app-dir/draft-mode/draft-mode.test.ts, "should not generate
rand when draft mode disabled during next start") fail today because
vinext returns a fresh random value on every request instead of the
cached prerender. After this change, the page is prerendered/cached
correctly, and `enable()`/`disable()` still bail out of caching as
expected.

The existing test that enforced the old (wrong) behavior is updated to
match Next.js, and dedicated assertions are added that `enable()` and
`disable()` each mark dynamic usage.
2026-05-18 15:27:55 +01:00
James Anderson c483253b58 fix(rsc): allow JSX in .js client modules inside node_modules (#1295)
Many third-party Next.js client libraries ship plain `.js` files containing
`'use client'` (or `'use server'`) plus JSX. Next.js's SWC pipeline compiles
JSX in `.js` transparently, but vinext's `vinext:jsx-in-js` plugin
previously skipped node_modules entirely, so `@vitejs/plugin-rsc`'s
`rsc:use-client` analysis pass would feed the raw source to rolldown/oxc
with `lang: 'js'` and fail:

    [plugin rsc:use-client] node_modules/.../client/index.js
    RolldownError: Parse failed with 1 error:
    Unexpected JSX expression
    4:   return <p>hello world</p>

This affected the Next.js deploy-suite test fixtures:
  - test/e2e/app-dir/next-dist-client-esm-import
  - test/e2e/app-dir/client-module-with-package-type

Fix: extend `vinext:jsx-in-js` to also handle `.js`/`.mjs` files inside
`node_modules` *when* they begin with a React directive. We mirror
`@vitejs/plugin-rsc`'s detection by parsing the directive prologue
(skipping BOM, hashbang, comments, and earlier directives like
`'use strict'`), which keeps the transform cost essentially zero for the
vast majority of node_modules files.

Validation:
  - vp check (format / lint / type) — clean
  - vp test run tests/jsx-in-js.test.ts tests/jsx-in-js-node-modules.test.ts
    tests/intercepting-routes-build.test.ts tests/build-optimization.test.ts
  - vp test run tests/nextjs-compat/ tests/font-google-build.test.ts
    tests/build-time-classification-integration.test.ts
    tests/tsconfig-path-alias-build.test.ts (281 passing, 0 regressions)
  - vp run vinext#build (clean)
  - New tests/jsx-in-js-node-modules.test.ts reproduces the reported error
    on main and passes with the fix.
2026-05-18 14:41:38 +01:00
Nathan Nguyen 3e0ae047fc fix(build-report): parse route exports with OXC AST (#1260)
* fix(build-report): parse route exports with OXC AST

Route report classification relied on regexes and a hand-written return-object scanner. That made comments look like exports and missed valid static syntax such as no-substitution template literals and numeric separators.

The report now parses route modules with oxc-parser, reads top-level static exports from the AST, and keeps getStaticProps revalidate extraction scoped to the exported function body. Tests cover the prior false positives, supported literal forms, TypeScript generic syntax fallback, and Next.js-style export specifier handling.

* fix(cli): lazy-load build report generation

The CLI imported the route report module at startup, which also loaded the native parser dependency added for build report classification. That widened the parser and native binding path to non-build commands.

Load the build report module inside buildApp() immediately before printing the report so dev, start, check, lint, deploy, and help paths do not touch the parser-backed report code.

* perf(build-report): reuse parsed route modules

Route classification reparsed the same source for each static export query. With the TSX-to-TS fallback, a single route could invoke the parser several times before producing one report row.

Thread the parsed OXC Program through internal route-report helpers so classifier paths parse each source once while preserving the public string-based helper signatures. Also clarifies the synthetic getStaticProps wrapper path and the unsupported unary-operator branch.

* fix(build-report): use Vite parser export

Route report parsing now uses Vite's OXC-backed parser export instead of depending on oxc-parser directly. Adding the parser as a package dependency duplicated the toolchain parser and exposed native optional bindings through vinext itself.

Import parseSync and ESTree types from Vite, keep the existing parser options and AST analysis unchanged, and remove the direct package/catalog entries so the lockfile only retains transitive parser usage from dev tooling.
2026-05-18 14:39:17 +01:00
James Anderson 69c72b02e2 fix(build): emit SSR CSS assets referenced by SSR entry (#1304)
Set `build.ssrEmitAssets: true` so the SSR environment writes asset
files (CSS, etc.) that JS chunks reference. Vite defaults
`emitAssets` to `false` for environments with `consumer: "server"`,
so when the CSS plugin rewrites a server-component CSS import into
`import "<hash>.css"` and emits the CSS asset, the asset file is
subsequently stripped from the bundle by Vite's asset-cleanup hook.
The dangling import then crashes `vinext start` with
ERR_MODULE_NOT_FOUND on `dist/server/ssr/style.css`.

We set the top-level `build.ssrEmitAssets` rather than
`environments.ssr.build.emitAssets` because Vite re-applies the
top-level value during `resolveConfig` and would otherwise overwrite
any per-environment value. Mirrors `@vitejs/plugin-rsc`'s existing
`emitAssets: true` on the `rsc` environment.

Adds tests asserting both the App Router and Pages Router SSR
environments resolve to `emitAssets: true` and that every CSS import
in the SSR build output resolves to a real file on disk.
2026-05-18 12:42:18 +01:00
James Anderson 1d57274ba4 test(config): regression coverage for next.config runtime value shapes (#1297)
Adds 12 ported upstream fixtures as unit tests for loadNextConfig,
covering shapes not exercised by the four extension/import tests added
in #1296 (which validates the runner's resolve.extensions + commonjs()
configuration). These complement that coverage by asserting the
returned config value is actually surfaced for shapes that historically
build successfully but resolve to empty/undefined when normalisation
has gaps:

  - async default-function export (next-config-ts/async-function)
  - plain default object export (next-config-ts/export-default)
  - aliased default export, 'export { x as default }' (export-as-default)
  - mixed sibling extensions under both type:commonjs and type:module
    (next-config-ts/import-js-extensions-{cjs,esm})
  - nested-import propagation under both module-type flavours
    (next-config-ts/nested-imports-{cjs,esm})
  - Node API at module scope under type:module (next-config-ts/node-api-cjs)
  - bare-specifier CJS packages (next-config-ts/import-from-node-modules)

Originally this PR also introduced a hand-rolled cjsImportsPlugin that
used createRequire + globalThis stashing to interop CJS siblings. After
#1296 (cf20845) was refactored to reuse the existing vite-plugin-commonjs
dependency, that hand-rolled plugin became fully redundant — all 12
tests pass against main's commonjs()-based loader unchanged. Dropped
the implementation and kept the tests as additional regression
coverage.
2026-05-18 12:25:49 +01:00
James Anderson 8de3afdf25 fix(config): recognize next.config.mts and interop CJS imports in config loader (#1296)
* fix(config): recognize next.config.mts and interop CJS imports in config

The next.config.ts loader did not recognize next.config.mts and did not
interop CJS imports (.cjs/.cts/.js/.ts files that assign to module.exports)
brought in by the config file. As a result, fixtures from the Next.js
test/e2e/app-dir/next-config-ts-native-mts/ family failed with either
"Cannot find next.config.*" (silent null return) or "module is not
defined" inside Vite's ESM-only module runner.

Two changes:

1) The loader's local CONFIG_FILES list is extended to include
   next.config.mts (matching the canonical list in shims/constants.ts,
   which already mirrors Next.js packages/next/src/shared/lib/constants.ts)
   while keeping .cjs for backwards-compat with existing vinext users.
   deploy.ts and check.ts pick up the same extended list so their config
   scanners stay in sync.

2) A new Vite plugin (cjsImportsInteropPlugin) is installed on the inline
   runnerImport instance. It wraps .cjs/.cts files (and .js/.ts files that
   statically assign to module.exports) so that module.exports becomes the
   ESM default export, mirroring how Next.js's SWC pipeline auto-converts
   CJS imports from inside next.config.ts. The transform is scoped to the
   config-load instance only, so it never affects the user's application
   module graph.

Tests ported from .nextjs-ref/test/e2e/app-dir/next-config-ts-native-mts/
{nested-imports,import-js-extensions-cjs,import-js-extensions-esm,
import-from-node-modules}/.

* docs(config): clarify CJS exports/module.exports semantics in comment

The previous comment said "Named exports off module.exports are also
reflected", which glosses over the asymmetric behaviour that Node CJS
modules exhibit when user code does `module.exports = X` followed by
`exports.foo = Y`: the latter writes to the discarded original object
and does not show up on the final default export. Rewrite the comment to
spell out the aliasing, the reassignment break, and the
`module.exports.foo` workaround.

* refactor(config): reuse vite-plugin-commonjs for next.config sibling imports

The hand-rolled cjsImportsInteropPlugin duplicated logic that vinext
already pulls in via vite-plugin-commonjs (used globally for application
code in packages/vinext/src/index.ts:795). Drop the bespoke plugin and
install commonjs() on the inline runnerImport instance instead.

Two pieces of configuration are needed for parity with the previous
behaviour:

1. resolve.extensions explicitly includes .cjs and .cts. vite-plugin-commonjs
   keys its 'should I transform this id?' check off config.resolve.extensions,
   and Vite's default list (.mjs/.js/.mts/.ts/.jsx/.tsx/.json) omits both
   CJS-only extensions. Without this, .cjs/.cts imports flowed through
   the runner untransformed and still crashed with 'module is not defined'.

2. A filter that:
   - Returns false for the config file itself, so cjsGlobalsInjectorPlugin
     remains the sole producer of the file's module wrapper (otherwise
     both plugins inject 'module = …' and the runner trips on
     'Identifier module has already been declared').
   - Returns true for ids under node_modules, so bare-import packages
     imported by next.config.* (CJS plugin wrappers, etc.) get transformed.
     vite-plugin-commonjs defaults to skipping node_modules.

Net diff: -59 lines, all four next-config-extensions tests still pass,
no regression on tests/next-config.test.ts (108 passing) or the broader
deploy/features/shims/check suites (1637 passing in aggregate).
2026-05-18 12:07:44 +01:00
Divanshu Chauhan (divkix) aed79d46b0 test: regression tests for issue #1196 (catch-all route params with basePath + rewrites + middleware) (#1291)
* test: add regression tests for issue #1196 (catch-all route params with basePath + rewrites + middleware)

Adds integration and unit tests to verify that catch-all route params
are preserved when basePath, rewrites, and middleware are combined.

- Integration test in pages-router.test.ts: checks server-rendered
  __NEXT_DATA__ contains correct page and query for a catch-all route.
- Unit test in shims.test.ts: verifies client-side router.query stays
  intact for a catch-all page under basePath.

Both tests are ported from the Next.js test suite and target the
regression described in GitHub issue #1196.

* test: address review feedback on #1196 regression tests

- Use {.*?} regex for consistency with other __NEXT_DATA__ extractions
- Drop unused { appRouter: false } option (vinext auto-detects)
- Rename shims test to describe what it actually verifies (initial state)
- Move vi.resetModules() after window restore for clean teardown ordering

* test: add real client-nav test for #1196 catch-all router.query

Adds a Router.push() test that mocks fetch and exercises the actual
client-side navigation code path: performNavigation → pushState →
navigateClient → fetch. The mocked fetch handler captures router state
mid-navigation via a Probe component, then asserts router.query.path
reflects the destination URL segments after basePath is stripped.

This addresses the bonk review feedback that the previous tests only
verified initial hydration state and didn't exercise client navigation.
The new test also uses the previously-unused buildNavHtml `query`
parameter, addressing the dead-signature concern.

vinext's navigateClient doesn't construct _next/data/... URLs the way
Next.js does, so it doesn't have the architectural pattern that causes
#1196 in Next.js. But the route-param extraction path that ultimately
produces router.query must still strip basePath from the resolved
pathname before matching the catch-all pattern — and that's what this
test verifies.

---------

Co-authored-by: opencode <noreply@opencode.ai>
2026-05-18 11:59:53 +01:00