Commit Graph

1925 Commits

Author SHA1 Message Date
James Anderson fe700996d9 ci: extract Playwright cache plumbing into a composite action (#1438)
Two workflows now share an identical playwright-version + cache-restore
pattern: ci.yml (which owns the cache, used by E2E jobs) and
nextjs-deploy-suite.yml (which restores it read-only, added in #1435).
Pull the duplication into .github/actions/playwright-cache so the cache
key format can't drift between the two callers — if it ever does, the
deploy-suite stops hitting ci.yml's cache and the upstream Playwright
extraction hang from 2026-05-22 (runs 26265986264 .. 26281758803)
reappears.

The composite action:
- Reads the playwright version from a configurable lockfile path
  (`lockfile` input, defaults to pnpm-lock.yaml).
- Restores ~/.cache/ms-playwright keyed
  `playwright-${browser}-${runner.os}-v${version}`.
- Supports two modes: `cache` (restore + save, default — for the workflow
  that owns the cache) and `restore` (read-only — for downstream
  consumers that don't want to fight over the save).
- Exposes `cache-hit`, `version`, and `key` outputs so callers can gate
  install steps or use the version for sibling cache keys (e.g. ci.yml's
  apt-archives cache for WebKit deps).

No behavioural change — same cache keys, same restore-keys, same
~/.cache/ms-playwright path.
2026-05-22 11:15:37 +00:00
Nathan Nguyen 8df248863d fix: match Next metadata image route behavior (#1436) 2026-05-22 11:05:58 +00:00
James Anderson 68ae68a57c ci(deploy-suite): restore Playwright browsers from ci.yml's cache (#1435)
Follow-up to #1428 and #1430. The previous two PRs added log markers
and skipped the wedged transitive postinstall, but the underlying
problem remained: the explicit `pnpm playwright install chromium
chromium-headless-shell` in the prepare script hits the same upstream
extraction hang (download to 100%, then no progress until step
timeout).

What's actually different between the runs that worked (May 19-21)
and the runs that hang (May 22): nothing on our side. Same Chrome
145.0.7632.6, same Playwright 1.58.2, same Node 24.16.0, same runner
image fleet. On the May 21 success the extraction took 2.7 seconds
after 100% download; on May 22 it never completes (>9 min, killed by
the 15-min step timeout). Some upstream change — bad bytes from
cdn.playwright.dev, an Azure storage hiccup affecting fsync, a
Playwright installer interaction we can't see from here — is wedging
extraction. We can't fix it from this repo.

What we *can* do: stop downloading. vinext's own ci.yml already keeps
a `playwright-chromium-Linux-v1.58.2` cache hot (261 MB, last accessed
within minutes on every CI run today). The deploy-suite has never used
this cache — its only cache (the post-build `Save build artifacts`
step) is keyed per-run, so every nightly starts with an empty
~/.cache/ms-playwright and re-runs the install that's now hanging.

Add a restore step before the prepare that uses ci.yml's exact key
format. On hit, `pnpm playwright install` sees the browsers already
present and is a fast no-op. On miss (e.g. Next.js bumps Playwright
before vinext catches up), we fall through to the existing behavior.

Restore-only: ci.yml owns the save side.
2026-05-22 10:38:04 +00:00
Nathan Nguyen b7decf63cf feat(cache): add skip cache proof cross-checks (#1434)
* feat(cache): add skip cache proof cross-checks

Skip manifest entries remain untrusted hints, but future skip transport needs a cheap server-side proof boundary before it can omit any payload. Cache presence, client-declared artifact metadata, and stored artifact metadata are not individually enough to prove correctness.

Add a pure skip/cache cross-check helper that requires an accepted static layout cache proof, compatible artifact metadata, matching cache variant, matching payload hash, and valid invalidation proof. The helper always returns the disabled render-and-send skip disposition in this slice.

Cover graph, deployment, render epoch, variant, payload hash, invalidation, missing proof, and rejected proof cases at the helper boundary.

* fix(cache): bind skip cache artifact proof metadata

Accepted static layout artifact reuse decisions proved candidate artifact compatibility but did not carry that compatibility envelope forward. That let skip/cache cross-checks accept independently supplied artifact metadata as long as the client entry matched it, even when the cache proof had authorized a different artifact.

Store a snapshot of the candidate artifact compatibility on accepted artifact reuse proofs and require skip/cache cross-checks to match it exactly before checking client compatibility, variants, payload hashes, or invalidation state.

The skip/cache test matrix now covers the rejected proof-binding case, root-boundary compatibility mismatches, and layout entry id mismatches.
2026-05-22 11:28:54 +01:00
dependabot[bot] 0a8c3819ab chore(deps): bump cloudflare/wrangler-action from 3 to 4 (#1433)
Bumps [cloudflare/wrangler-action](https://github.com/cloudflare/wrangler-action) from 3 to 4.
- [Release notes](https://github.com/cloudflare/wrangler-action/releases)
- [Changelog](https://github.com/cloudflare/wrangler-action/blob/main/CHANGELOG.md)
- [Commits](https://github.com/cloudflare/wrangler-action/compare/v3...v4)

---
updated-dependencies:
- dependency-name: cloudflare/wrangler-action
  dependency-version: '4'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: James Anderson <james@eli.cx>
2026-05-22 10:15:48 +00:00
dependabot[bot] aa7730ca6d chore(deps): bump actions/download-artifact from 7 to 8 (#1432)
Bumps [actions/download-artifact](https://github.com/actions/download-artifact) from 7 to 8.
- [Release notes](https://github.com/actions/download-artifact/releases)
- [Commits](https://github.com/actions/download-artifact/compare/v7...v8)

---
updated-dependencies:
- dependency-name: actions/download-artifact
  dependency-version: '8'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: James Anderson <james@eli.cx>
2026-05-22 10:15:45 +00:00
dependabot[bot] 47f697c98e chore(deps): bump actions/github-script from 7 to 9 (#1431)
Bumps [actions/github-script](https://github.com/actions/github-script) from 7 to 9.
- [Release notes](https://github.com/actions/github-script/releases)
- [Commits](https://github.com/actions/github-script/compare/v7...v9)

---
updated-dependencies:
- dependency-name: actions/github-script
  dependency-version: '9'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: James Anderson <james@eli.cx>
2026-05-22 10:15:21 +00:00
James Anderson 4fba647a2c ci(deploy-suite): skip wedged playwright-chromium postinstall (#1430)
Follow-up to #1428. The previous PR added log markers, which revealed
the actual hang: `playwright-chromium`'s postinstall (`node install.js`,
triggered transitively by the Next.js workspace install) reaches 100%
on the 167 MiB Chrome zip download from cdn.playwright.dev, then never
prints "Done" — extraction/verification silently wedges. Every other
postinstall in the same install (cpu-features, sharp, esbuild, etc.)
finishes cleanly. Confirmed on runs 26265986264, 26279790373, and the
post-#1428 retry 26281061094 — same fingerprint each time.

Set `PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD=1` on the Prepare step so the
transitive `playwright-chromium` postinstall becomes a no-op. The
explicit `pnpm playwright install chromium chromium-headless-shell`
that runs later in `scripts/run-nextjs-deploy-suite.sh` provisions the
browsers we actually use at test time, populating ~/.cache/ms-playwright
which is cached and restored by the test shards.
2026-05-22 10:07:46 +00:00
James Anderson 37cfeb11ce ci(deps): add dependabot config with 1-day cooldown (#1429)
* ci(deps): add dependabot config with 1-day cooldown

Mirrors pnpm-workspace.yaml's `minimumReleaseAge: 1440` (24h) so
Dependabot won't open PRs for versions younger than the cooldown
window we already apply to local installs.

* ci(deps): run dependabot daily instead of weekly
2026-05-22 10:06:09 +00:00
James Anderson b203fa7fd6 ci(deploy-suite): unstick Next.js prepare step (#1428)
The nightly deploy suite hung for 29m+ in "Prepare Next.js checkout"
on two consecutive runs (26265986264, 26279790373), eating the 30m job
timeout. Same v16.2.6 SHA (ee6e79b1) built fine the three previous
nights, so it's environmental, not source drift.

The first `pnpm install` completes around the 30s mark and then logs go
silent until cancellation. With no per-command markers, we couldn't tell
which of the remaining commands actually hangs.

This patch:

- Drops `--with-deps` from the build-job playwright install. It runs
  `apt-get install` for browser system libraries, which is by far the
  most plausible silent-hang culprit (stuck dpkg lock / slow apt repo).
  The 16 test-shard runners each spin up a fresh ubuntu-latest and
  already run `playwright install` without `--with-deps` — so the apt
  state never transferred anyway. Doing it on the build runner is wasted
  work even when it doesn't hang.
- Removes the redundant second `pnpm install`. The workspace symlinks
  survive the build step; nothing between the build and re-install adds
  new packages.
- Adds timestamped `>>> ...` markers around each prepare command so a
  future hang is diagnosable from the logs.
- Adds `timeout-minutes: 15` on the Prepare step so a future hang fails
  fast (well under the 30m job cap) instead of starving the whole job.
2026-05-22 10:54:45 +01:00
Nathan Nguyen 3e0af061b8 feat: add disabled ClientReuseManifest protocol (#1418)
* add disabled client reuse manifest protocol

* fix: reject unsafe client reuse commit versions

* refactor: reuse shared record guard

* fix: address client reuse manifest review

* fix: canonicalize client reuse manifest entries

* docs: clarify manifest ordering invariant

* fix: make client reuse protocol invariants explicit
2026-05-22 10:46:22 +01:00
Divanshu Chauhan (divkix) cd1013ea33 feat(config): add experimental.appShells plumbing (#1415)
* feat(config): add experimental.appShells plumbing

Accepts experimental.appShells in next.config and defines
process.env.__NEXT_APP_SHELLS=false at build time, matching
Next.js PR #93997 plumbing scope. Behavorial implementation
is gated on upstream co-flags that vinext does not yet support.

- Adds experimental.appShells to CONFIG_SUPPORT as unsupported
- Sets __NEXT_APP_SHELLS define to false regardless of config value
- Tests for vinext check detection and Vite define injection

Fixes #1405

* review: address PR nits

- Expand experimental.appShells detail to explain why it's unsupported
- Move build-time define tests into their own describe block, so the
  __NEXT_APP_SHELLS define test isn't nested under basePath

* review: drop orphaned server lifecycle from basePath describe

The two remaining resolveNextConfig tests import from source directly
and don't use the dev server.

---------

Co-authored-by: James <james@eli.cx>
2026-05-22 10:26:40 +01:00
Nathan Nguyen 3ebc28f0e1 fix: preserve Next.js cache headers for prerendered app pages (#1424)
* fix: preserve Next.js cache headers for prerendered app pages

* fix: address cache header review feedback
2026-05-22 10:08:08 +01:00
Nathan Nguyen 82b6388ece fix(app-router): render optimistic loading shells for dynamic navigation (#1421)
* fix(app-router): render optimistic loading shells for dynamic navigation

Dynamic App Router links currently skip automatic prefetch for route patterns with params, so a navigation to a sibling dynamic URL waits for fresh RSC data before committing anything. That diverges from Next.js, where automatic prefetch learns the loading shell for dynamic routes and the router can commit the shell immediately while the full payload is fetched.

The client prefetch path now requests a loading-shell render for dynamic app routes, keeps that entry out of the navigation cache, learns reusable optimistic route templates from settled shell prefetches, and commits a detached optimistic payload for matching dynamic navigations. The server emits shell-only RSC payloads under a separate cache variant so normal navigation data is not polluted.

Tests cover matching semantics, shell-only page wiring, cache key isolation, prefetch cache consumption, Link prefetch behavior, and the upstream Next.js optimistic-routing deploy-suite case.

* chore(app-router): address optimistic routing review
2026-05-22 10:07:26 +01:00
Divanshu Chauhan (divkix) c1c51fdbed fix(config): warn on deprecated experimental.rootParams (#1417)
has been stable since Next.js stabilized the feature upstream.
- Emit a deprecation warning when users still set
- Matches Next.js upstream wording for parity

Closes #1325
2026-05-22 09:12:09 +01:00
Nathan Nguyen 3c77b2bb32 ci: avoid killing deploy-suite test runners during cleanup (#1423) 2026-05-22 07:51:24 +01:00
James Anderson 81bdd6acd3 fix(pages-router): render loading shell for unlisted fallback: true paths (#1414)
* fix(pages-router): render loading shell for unlisted `fallback: true` paths

PR #1394 fixed the post-rewrite static-vs-dynamic priority sub-problem
from #1331 and explicitly tracked `fallback: true` rewrite behaviour
separately. When `getStaticPaths` returns `fallback: true` and a request
hits a path not in the pre-rendered list, vinext was SSRing the page
fully — running `getStaticProps` and emitting `isFallback: false` in
`__NEXT_DATA__`. Next.js instead ships a loading shell with empty props
and `useRouter().isFallback === true`, then the client hydrates by
fetching the data URL. The upstream regression test
`should rewrite to fallback: true page successfully` in
`test/e2e/middleware-rewrites/test/index.test.ts` asserts "Loading..."
in the SSR HTML, which vinext could not produce.

Thread an `isFallback` signal through `resolvePagesPageData`,
`buildPagesNextDataScript`, the Pages Router SSR context, and both the
prod (`pages-server-entry.ts`) and dev (`dev-server.ts`) renderers:

- `getStaticProps` and `getServerSideProps` are skipped on the fallback
  render (matches Next.js `render.tsx`'s `if (isSSG && !isFallback)`
  gate around `getStaticProps`).
- Data requests (`/_next/data/<buildId>/<page>.json`) still resolve real
  props so the client can swap in after the shell ships.
- `useRouter().isFallback` reads from the SSR context server-side and
  from `__NEXT_DATA__.isFallback` client-side — both flip to true on
  the shell render.

The dev-server comment that claimed Next.js dev "also renders fully"
was incorrect; Next.js's `pages-handler.ts` invokes `doRender` with
`isFallback: true` for dev fallback responses, so dev now serves the
shell too.

Tests:
- Updated `tests/pages-router.test.ts` "SSR renders unlisted path with
  getStaticPaths fallback: true" — previously documented the deviation;
  now asserts the loading shell, empty props, and `isFallback: true`.
- Added a counterpart test that the data URL for the same unlisted
  path still returns real props from `getStaticProps`.
- Added a production-side regression test mirroring the upstream
  "should rewrite to fallback: true page successfully" expectation.

Refs #1331

* test(pages-page-data): include isFallback: false in normalized render assertion

The render branch of `resolvePagesPageData` now returns `isFallback`
alongside the other fields (added in the parent commit for the
`fallback: true` loading shell). Update the strict-shape `toEqual` in
the cache-miss unit test so it matches the new return shape.
2026-05-21 22:17:05 +00:00
James Anderson 512be7f25c fix(middleware): preserve route priority on rewrite target resolution (#1394)
* fix(middleware): preserve route priority on rewrite target resolution

When `trailingSlash: true` is set in next.config.js, a request like
`/rewrite-1/` was matched against the next.config.js rewrite source
`/rewrite-1` literally, including the trailing slash. The match failed,
so the request fell through to the dynamic `[id]` route and rendered
the dynamic page with `id="rewrite-1"` instead of being rewritten to
the static target.

Strip a single trailing slash from the incoming pathname before
matching against config rewrite/redirect/header sources, matching
Next.js's `source + '(/)?'` behavior under `trailingSlash: true`. The
source pattern itself is preserved so catch-all (`:param*`, `:param+`)
and root (`/`) semantics are unaffected.

Refs #1331

* address bonk feedback: locale-static path, comment wording, tighter tests

- Extract `stripTrailingSlashForConfigMatch` and call it from
  `matchRedirect` so the locale-static fast path (Map.get on the
  pathname) also honors trailing slashes — previously a pure
  locale-static redirect rule could miss when `trailingSlash: true`.
- Clarify the comment to reflect that the strip is unconditional
  defense-in-depth rather than a faithful port of Next.js's
  conditional `(/)?` suffix.
- Disambiguate the pages-router integration tests by asserting on
  `id="home"` / `id="ssr"` so the assertion can't pass on the wrong
  page (both pages render "Hello World").
- Add direct unit tests for `matchConfigPattern` trailing-slash
  behavior covering exact, :param, regex-group, root, and catch-all
  cases.

* extend trailing-slash normalization to matchHeaders

Bonk flagged that matchHeaders runs its own compiled regex path that
doesn't go through matchConfigPattern, so a header source like
`/api/:path*` would miss `/api/foo/` under `trailingSlash: true`.

Normalize once at the top of matchHeaders the same way matchRedirect
already does, and add a regression test.
2026-05-21 22:15:23 +01:00
James Anderson ddfdd18a1a fix(pages-router): honour shallow prop on next/link (#1413)
Closes the last sub-problem of #1332.

The Pages Router `Router.push` / `Router.replace` already short-circuit the
`_next/data` fetch when `options.shallow === true`, but `<Link shallow>` never
made it to the router because `shallow` was missing from `LinkProps`. The prop
silently fell into `...rest`, was spread onto the underlying `<a>`, and the
click handler called `Router.push(href, undefined, { scroll, locale })` with
no shallow flag — so every `<Link shallow>` did a full server roundtrip and
GSSP re-ran.

This matches Next.js's deploy-suite fixture
`test/e2e/middleware-trailing-slash/app/pages/shallow.js`, which clicks a
`<Link href="/sha?hello=world" shallow>` and asserts the GSSP-generated
random message stays unchanged afterwards.

The fix is mechanical: add `shallow?: boolean` to `LinkProps`, destructure
it in the Link component, forward it through `navigatePagesRouterLink` into
the router transition options. `shallow` is only included in the router
options when explicitly set so existing callers that omit it keep the same
options shape.
2026-05-21 22:13:21 +01:00
James Anderson f0f3e35947 fix(trailing-slash): enforce config in routing and middleware redirects (#1395)
Plumb `trailingSlash` from the resolved next.config into NextURL via
NextRequest, so `NextResponse.redirect(request.nextUrl)` (and any other
stringification of a NextURL) emits a Location header that matches the
user's slash policy. Mirrors Next.js's `formatNextPathnameInfo`.

Pre-route trailing-slash normalization (e.g. `/foo` -> `/foo/` when
trailingSlash:true) already runs at every entry point via
`normalizeTrailingSlash` in request-pipeline. This commit closes the
remaining gap by making redirect Locations produced from inside
middleware (the common one-hop `request.nextUrl` redirect pattern in
the Next.js middleware-trailing-slash fixture) carry the slash too.

Refs #1332. Sub-problem 3 (shallow navigation triggering a server
roundtrip) is a separate Pages Router client-side concern and deferred
as follow-up.
2026-05-21 20:47:33 +00:00
James Anderson d504f022b0 fix(assets): default assetsDir to _next/static (Next.js parity) (#1411)
* fix(assets): default assetsDir to _next/static (Next.js parity)

Closes #1337. Supersedes #1383.

vinext's default `assetsDir` was Vite's historical `assets/`, so URL emission
(`/_next/static/...` via `resolveAssetUrlPrefix("")`) and on-disk layout
(`dist/client/assets/...`) disagreed in the empty-`assetPrefix` case. The
`build.assetsDir` and `experimental.renderBuiltUrl` Vite overrides only
applied when `assetPrefix` was configured, leaving the no-prefix branch
serving from a different path than the URL contract Next.js's client
runtime and test harness assert against.

This commit flips the default to Next.js's canonical layout:

- `resolveAssetsDir("")` returns `_next/static` (was `assets`)
- `build.assetsDir` is now set unconditionally from `resolveAssetsDir`
- Vite's default `base + assetsDir` composition produces correct URLs
  in the no-prefix case; `renderBuiltUrl` stays gated on `assetPrefix`
  because it's only needed for the configured cases
- Drops the legacy `/assets/` branches in prod-server (3 sites) and
  static-file-cache (hard cutover — see PR description for rationale)
- Updates `_headers` generation, precompress default, fonts plugin
  `DEFAULT_ASSETS_DIR` to match the new default

With the layout aligned, invalid `_next/static/*` requests naturally
return plain-text `404 + "Not Found"` from the static-file layer instead
of falling through to the page renderer (which would produce an HTML
404 with bootstrap scripts and CSS). This replaces #1383's parity
short-circuit with the natural code path:

- Node prod-server (App + Pages branches): missing asset under
  `resolveAppRouterAssetPath` returns `text/plain; charset=utf-8` 404
- Cloudflare worker entry: `isNextStaticPath` recognises asset-shape
  after ASSETS-binding misses; returns `notFoundStaticAssetResponse`

Mirrors Next.js: packages/next/src/server/lib/router-server.ts.

Adds `tests/invalid-static-asset-404.test.ts` (3 App Router + 3 Pages
Router cases — no prefix / basePath / assetPrefix) ported from Next.js
e2e suites. Updates 11 source files + 12 test files; 914+ tests pass
in the touched suites (asset-prefix, app-router, pages-router, deploy,
font-google, static-file-cache, serve-static, precompress, features,
routing, isr-cache, build-optimization, app-rsc/ssr, standalone,
middleware, shims).

Ported from Next.js:
- test/e2e/invalid-static-asset-404-app/*.test.ts
- test/e2e/invalid-static-asset-404-pages/*.test.ts

* fix(e2e): update cloudflare-pages-router hydration spec for _next/static

* PR #1411 approved. Clean fix, 2 nits.

Co-authored-by: james-elicx <james-elicx@users.noreply.github.com>

* refactor(assets): derive isHashed checks from ASSET_PREFIX_URL_DIR

Replace hardcoded '_next/static' string literals in runtime code paths
with the ASSET_PREFIX_URL_DIR constant so the asset URL convention has
a single source of truth.

- server/prod-server.ts isHashed: pathname.includes(`/${ASSET_PREFIX_URL_DIR}/`)
- server/prod-server.ts public-dir guard: startsWith(`/${ASSET_PREFIX_URL_DIR}/`)
- server/static-file-cache.ts isHashed: both startsWith + includes derived
- plugins/fonts.ts DEFAULT_ASSETS_DIR = ASSET_PREFIX_URL_DIR

Behavioural no-op — the constant is '_next/static' so the resolved
strings are identical. Centralises the URL contract so future changes
to ASSET_PREFIX_URL_DIR (or a refactor that derives it from build
config) flow through to every consumer.

Doc-comment references to '_next/static' left in place — they
describe the canonical value for human readers and are not code paths.

---------

Co-authored-by: ask-bonk[bot] <ask-bonk[bot]@users.noreply.github.com>
Co-authored-by: james-elicx <james-elicx@users.noreply.github.com>
2026-05-21 20:36:04 +00:00
James Anderson 6e1669d76d fix: warn on blocked javascript: URL navigation (#1372)
Match Next.js's observable behaviour when `<Link>` clicks, `router.push`,
`router.replace`, and `router.prefetch` block a `javascript:` (or `data:`/
`vbscript:`) URL. Vinext already prevented navigation, but the
`console.error` that Next.js's E2E suite asserts on
(`test/e2e/app-dir/javascript-urls`) was missing because:

- App Router `router.push`/`replace`/`prefetch` threw synchronously
  inside `React.startTransition`, so the throw never reached React's
  event-handler error reporter.
- `<Link>` rendered an inert anchor with no `href`, so clicking did
  nothing — no throw, no log.
- Pages Router `next/router` had no `javascript:` guard at all and
  would fall through to `window.location.assign(...)`.

Centralise the matching message in `url-safety.ts` via a new
`reportBlockedDangerousNavigation()` helper. `assertSafeNavigationUrl`
now logs before throwing, and Link's dangerous-click branch and the
Pages Router `performNavigation` both invoke the guard so the throw +
console.error pair mirrors Next.js exactly.

Closes #1349

References:
- packages/next/src/client/components/app-router-instance.ts:345,402,442,460
- packages/next/src/client/components/segment-cache/navigation.ts:537
- packages/next/src/shared/lib/router/router.ts:1025,1057
2026-05-21 16:34:55 +01:00
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