Commit Graph

1503 Commits

Author SHA1 Message Date
一纸忘忧 5188768b58 fix: normalize static file cache paths on Windows (#1578) 2026-05-26 10:33:49 +00:00
James Anderson fd086c4475 fix(app-router): apply x-edge-runtime header on all App Router response paths (#1603)
Extends #1565: the merged change gated the marker on the resolved segment
config, but only covered the two `buildAppPage*Response` builders. Five
other response construction paths bypassed those helpers and skipped the
header for edge-runtime routes:

- `app-page-cache.ts` — ISR HIT/STALE responses
- `app-server-action-execution.ts` — server action RSC responses
  (redirect, no-revalidate, re-render)
- `app-page-boundary.ts` — boundary RSC responses
- `app-page-execution.ts` — 200 + flight-payload redirect responses
- `app-page-stream.ts` — HTML stream responses used by the boundary path

Centralizes the gating in a single `applyEdgeRuntimeHeader` helper so
every site shares one source of truth for header name + condition, and
threads `isEdgeRuntime` through the fallback renderer + dispatch options
so each construction site receives it.

Adds positive + negative coverage for each newly-covered path.
2026-05-26 11:28:33 +01:00
James Anderson 0e64147176 fix(pages-router): pass revalidateReason to getStaticProps; default gSSP Cache-Control (#1573)
* fix(pages-router): pass revalidateReason to getStaticProps context

Pages Router `getStaticProps` now receives `context.revalidateReason`
matching Next.js: `'build'` during build-time prerendering,
`'on-demand'` when the dispatch is signalled as an explicit revalidation,
and `'stale'` for stale-while-revalidate background regeneration and
runtime cache-miss requests.

Closes #1462

* fix(pages-router): default gSSP Cache-Control in prod + JSON data paths

Mirrors the dev-server branch added earlier in this PR. Production HTML
gSSP responses (renderPagesPageResponse) and the _next/data JSON envelope
(pages-server-entry isDataReq) now default to
"private, no-cache, no-store, max-age=0, must-revalidate" when the user
did not set Cache-Control via res.setHeader, matching Next.js's
pages-handler.ts (revalidate: 0 → getCacheControlHeader). Closes the
dev/prod parity gap flagged by bigbonk.

Also fixes the broken `gsspRes.headers` access in the isDataReq path
(should be `getHeaders()`) so user-set headers actually propagate, and
notes that isOnDemandRevalidate is forward-looking plumbing (no
caller sets it yet — res.revalidate() isn't implemented).

* test(pages-router): fix misaligned continuation comment

Drop the extra leading space on the JSDoc continuation lines flagged by
bigbonk so the block aligns with the surrounding tests.
2026-05-26 11:25:25 +01:00
James Anderson 7a6a91ef19 fix(404): align default not-found copy with Next.js (#1567)
* fix(404): align default not-found copy with Next.js

The Pages Router previously emitted `<h1>404 - Page not found</h1>` and the
App Router emitted plain-text `This page could not be found` (no period) when
no user-defined 404 boundary was present. Next.js renders
`This page could not be found.` (with trailing period) for both routers.

Closes #1454.

* fix(404): address bot review — charset attribute and h1 line-height

- Use HTML attribute name 'charset' (not the JSX 'charSet') in the raw HTML template.
- Drop stray 'line-height:49px' on the Pages Router h1 — Next.js's _error.tsx inherits 48px from the parent div.
2026-05-26 09:33:01 +00:00
James Anderson d0839903e8 fix(app-router): emit vary marker on edge RSC route response (#1565)
* fix(app-router): emit x-edge-runtime marker on edge RSC route response

Closes #1531

The Next.js edge-runtime app template (edge-ssr-app.ts) sets
`x-edge-runtime: 1` on every App Router response. vinext serves App
Router responses from a Cloudflare Worker (an edge-equivalent runtime)
but was not emitting this marker, so Next.js' upstream test suite
(`test/e2e/app-dir/app/index.test.ts`) saw `null` for the header.

Add the header to both the RSC and HTML response builders so the
assertion passes for any App Router page, regardless of which payload
shape the client requests.

* fix(app-router): gate x-edge-runtime marker on configured runtime

Only emit `x-edge-runtime: 1` when the route's resolved segment config
opts into the edge runtime (`export const runtime = "edge"` or
`"experimental-edge"`). Plumb the resolved runtime from the segment
config through dispatch and the render lifecycle so both RSC and HTML
response builders see it.

Without the gate, nodejs-runtime routes also advertise the marker, which
hides the configured runtime from downstream consumers.
2026-05-26 10:29:59 +01:00
James Anderson 61cf2e78f5 fix(image): emit /_next/image URLs (#1562)
* fix(image): emit /_next/image URLs to match Next.js

Closes #1513

The default image loader and optimization endpoint switched from the
vinext-specific /_vinext/image path to Next.js's canonical /_next/image.
This unblocks the deploy suite tests that import Next.js's expected
URL shape (/_next/image?url=...&w=...&q=...).

* refactor(image): use IMAGE_OPTIMIZATION_PATH constant at remaining call sites

Replace hardcoded "/_next/image" strings in index.ts, app-rsc-handler.ts,
and the generated worker entry templates in deploy.ts with the
IMAGE_OPTIMIZATION_PATH constant from server/image-optimization, matching
the pattern already used in prod-server.ts. Prevents future drift if the
path ever changes again.

* feat(image): accept both /_next/image and /_vinext/image at the optimizer

Add a VINEXT_IMAGE_OPTIMIZATION_PATH constant and an
isImageOptimizationPath() helper, then route through every match site
(prod-server, dev server passthrough, app RSC handler, generated worker
templates, and shipped example workers). Apps that wire image URLs to
either prefix now hit the same handler; new URLs are still emitted via
IMAGE_OPTIMIZATION_PATH.
2026-05-26 10:28:22 +01:00
James Anderson e5d9c4ca33 fix(pages-router): await Promise-shaped gssp props (#1568)
* fix(pages-router): await Promise-shaped gssp props

Next.js explicitly supports a Promise value for `getServerSideProps`
`props`. Pages returning `{ props: Promise.resolve(...) }` previously
rendered empty pageProps because vinext passed the unresolved Promise
straight through to the serializer. Match Next.js' `render.tsx` by
detecting a thenable `props` and awaiting it before continuing.

The fix is applied symmetrically to the dev server gssp dispatch
(`dev-server.ts`) and the prod page-data dispatch (`pages-page-data.ts`)
so the two stay in sync per AGENTS.md. Other return shapes (`notFound`,
`redirect`, `revalidate`, fallback) are untouched.

Closes #1459

* refactor: simplify Promise-shaped props handling

await Promise.resolve(x) already handles both thenables and plain
values, so the .then duck-typing check is unnecessary.
2026-05-26 09:21:38 +00:00
James Anderson dc77f8480d fix(pages-router): throw on dangerous URI schemes synchronously (#1575)
Hoist the `javascript:` / `data:` / `vbscript:` block from inside the
async `performNavigation` body up into `Router.push` / `Router.replace`
themselves so the throw is fully synchronous. Mirrors Next.js's Pages
Router `push`/`replace` at
packages/next/src/shared/lib/router/router.ts:1025-1033,1057-1065.

Why: `performNavigation` is an `async` function, so throwing from inside
it wraps the error in a rejected Promise. `<button onClick={() =>
router.push("javascript:...")}>` does not await that Promise, so React's
event-handler error reporter never observes the throw and the matching
`console.error` is not surfaced through page logs. That is the failure
shape asserted by Next.js's
`test/e2e/app-dir/javascript-urls/javascript-urls.test.ts:341,376`.

Behaviour preserved: navigation is still blocked, and the matching
`console.error("Next.js has blocked a javascript: URL as a security
precaution.")` is still emitted by `assertSafeNavigationUrl`. The inner
guard inside `performNavigation` is kept as defence-in-depth.

Refs #1349
2026-05-26 10:20:43 +01:00
James Anderson adb2e12827 fix(app-router): preserve 307 status on document loads in prerender (#1571)
The App Router prerender harness forwarded requests through `fetch` with
fetch's default `redirect: "follow"` behavior. When a page called
`redirect()` the prod server emitted a 307, fetch silently followed the
Location header to the destination page, and the harness wrote the
destination's HTML under the redirecting route's filename. The seeded
cache then served that body with status 200 for every document request,
breaking Next.js parity (test/e2e/app-dir/rsc-redirect/rsc-redirect.test.ts
expects 307).

Set `redirect: "manual"` on the App Router prerender `rscHandler` so the
original 3xx surfaces back. `htmlRender.ok` is then false, the route is
marked skipped, and at runtime the document request hits the live
render path that already builds a proper 307 + Location response via
`buildAppPageSpecialErrorResponse`. RSC flight-payload handling (#1347)
is untouched.

The pages-prerender `renderPage` helper already used `redirect: "manual"`,
so this brings the App Router phase in line.

Closes #1530
2026-05-26 10:20:11 +01:00
James Anderson 37e077dd06 fix(pages-router): preserve query string in Link and router.push (#1572)
`parseQueryString` and the parseQuery template embedded in the Pages
Router worker entry both used `url.split("?")[1]`, which silently drops
everything after the second `?`. URLs like `/linker?href=/about?hello=world`
have query `href=/about?hello=world` per RFC 3986, so the previous
implementation truncated `query.href` to `/about` and `<Link>` rendered
without the embedded query string. Switching to an `indexOf`-based slice
keeps the full query value intact.

Closes #1471
2026-05-26 10:19:14 +01:00
James Anderson 7ffa2b7f3c fix(pages-router): default Cache-Control on gssp responses (#1574)
getServerSideProps responses now ship the Next.js default
`private, no-cache, no-store, max-age=0, must-revalidate` Cache-Control
header in both dev and prod, and forward `res.setHeader('Cache-Control', ...)`
overrides set inside gssp through to the final response.

Mirrors next/src/server/route-modules/pages/pages-handler.ts where a
gssp page resolves to `revalidate: 0` and getCacheControlHeader emits
the same string, gated by `if (!res.getHeader('Cache-Control'))` to
preserve user overrides.

Closes #1461
2026-05-26 10:17:55 +01:00
James Anderson e3b1cde53d fix(pages-router): document push/replace Promise<boolean> contract (#1566)
Pages Router `window.next.router.push`/`replace` already resolved to
`true` on a successful navigation (and `false` on a hard-navigation
fallback) via `performNavigation`, but the public `window.next.router`
surface typed both as `(...args: unknown[]) => unknown`, leaving
consumers (and the Next.js deploy test suite that `awaits` the result)
without a typed boolean to assert on.

- Narrow `PagesRouterPublicInstance.push`/`replace` to
  `(...args: unknown[]) => Promise<boolean>` to match Next.js's
  `Router.change` contract (router.ts:1206 — `Promise<boolean>`).
- Add unit-test regression coverage asserting
  `window.next.router.push`/`replace` resolve to `true` on success.
- Void the `router.push(...)` calls in `tests/e2e/app-router/build-id-navigation.spec.ts`
  to satisfy the tightened union (App Router push is `void`).

Closes #1467
2026-05-26 10:10:41 +01:00
James Anderson 485046ed09 fix(link): warn on repeated forward slashes in href (#1563)
* fix(link): warn on repeated forward slashes in href

Closes #1554

When a `<Link href>` contains repeated forward-slashes (e.g. `/foo//bar`)
or a backslash, Next.js's `resolveHref` emits a `console.error` so the
developer notices the invalid URL during dev/CI. vinext's Link shim did
not surface that warning, which caused the Next.js Deploy Suite test
`test/e2e/repeated-forward-slashes-error` to fail waiting for the
message in the CLI output.

Add a small `warnOnRepeatedSlashesInHref` helper that mirrors the regex
and message used by Next.js, and call it from the Link body after the
href is resolved. The href continues to render and navigate unchanged —
Next.js does not block, it only warns.

Ported from Next.js: packages/next/src/client/resolve-href.ts

* fix(link): normalize repeated slashes and drop dedup for Next.js parity

Address review feedback on #1563:

- Remove the module-level `warnedRepeatedSlashHrefs` Set. Next.js fires
  this warning on every `resolveHref` call, so dedup was an unintended
  divergence and a latent test-ordering hazard.
- Add `normalizeRepeatedSlashes` (port of
  packages/next/src/shared/lib/utils/normalize-repeated-slashes.ts) and
  apply it after warning, so `<Link href="/a//b">` renders `href="/a/b"`
  exactly as Next.js does.
- Preserve protocol-relative URLs (`//example.com/...`) unchanged —
  vinext treats those as external via `isAbsoluteOrProtocolRelativeUrl`,
  so we skip both the warning and the normalization for them to avoid
  regressing the existing "locale does not mangle protocol-relative
  URLs" test.
- Document the `window.location.pathname` fallback (Next.js uses
  `router.pathname`; the difference is cosmetic and not asserted by the
  compat test).
- Add tests covering no-dedup behaviour, href normalization (including
  backslashes, query-string preservation, and protocol-prefixed URLs).
2026-05-26 10:10:08 +01:00
James Anderson 59c672a839 fix(app-router): router.prefetch throws on invalid URL (#1564)
* fix(app-router): router.prefetch throws on invalid URL

Match Next.js's createPrefetchURL behaviour: when the href cannot be
parsed via `new URL(href, window.location.href)`, throw the canonical
message `Cannot prefetch '<href>' because it cannot be converted to a
URL.` so the surrounding error boundary in the App Router can surface
the failure instead of silently swallowing it inside the async prefetch
IIFE.

Closes #1553

* fix(app-router): apply basePath before prefetch URL validation

Mirror Next.js's createPrefetchURL exactly by passing the href through
withBasePath before parsing. With a non-empty basePath, an otherwise
broken-looking href can be parseable (e.g. `new URL("/app///", origin)`
succeeds while `new URL("///", origin)` throws), so applying basePath
first matches upstream behaviour for basePath-configured apps.
2026-05-26 09:06:35 +00:00
James Anderson 601a394582 ci: address zizmor security audit findings (#1577)
* ci: address zizmor security audit findings

Run `zizmor .` and address all findings:

- Pin actions to commit SHAs with version comments (unpinned-uses).
  Dependabot picks up the version comment for future bumps.
- Move `contents: write` / `id-token: write` to the publish job in
  publish.yml; set `permissions: {}` at the workflow level
  (excessive-permissions).
- Add `permissions: {}` to deploy-examples.yml workflow + scope
  smoke-test job to `contents: read` (excessive-permissions).
- Move `${{ ... }}` expansions inside run blocks into `env:` vars
  in publish.yml / benchmarks.yml / nextjs-deploy-suite.yml
  (template-injection).
- Set `persist-credentials: false` on all checkouts that don't push;
  publish.yml needs them for `git push origin v$VERSION`, so it
  carries an inline `zizmor: ignore[artipacked]` instead.
- Keep dependabot's 1-day cooldown but annotate with `zizmor: ignore`
  (intentional fast cooldown).

`zizmor .` is now clean.

* ci: add zizmor GitHub Actions workflow

Run zizmor on every push to main and every PR via the upstream
`zizmorcore/zizmor-action`. Findings surface in the GitHub Code
Scanning UI (Security → Code scanning alerts).

* ci(publish): explain zizmor artipacked ignore
2026-05-22 19:41:30 +01:00
Nathan Nguyen 62454ef447 fix: private use-cache request data (#1445)
* fix use cache private request data

* fix private cache nesting guard
2026-05-22 17:21:43 +01:00
Jim Geurts 3d5fc8dd84 fix(app-router): hoist inline beforeInteractive scripts above resource hints (#1558)
* fix(app-router): hoist inline beforeInteractive scripts above resource hints (#1557)

React Fizz puts every stylesheet, modulepreload, and preload link emitted via React Float above user-rendered <script> children of <head>, so an inline `<Script strategy="beforeInteractive">` placed first in the root layout lands near the bottom of head instead — well after styles begin to parse. This breaks the standard no-flash dark-mode pattern.

Capture inline beforeInteractive content via a new BeforeInteractiveContext that the App Router SSR pipeline wraps the rendered tree with. The Script shim registers the escaped innerHTML + nonce + attributes and returns null from render. The stream transform splices the captured tag immediately after `<head ...>` opens — before any chunk that contains React-emitted resource hints. On the client the shim suppresses the inline render when the App Router runtime is present (detected via the new hasAppNavigationRuntimeBootstrap helper) so hydration matches.

External-src and non-App-Router rendering paths are unchanged: external scripts still preload + render in source order, and Pages Router falls through to the existing inline render.

* fix: address PR feedback

- e2e spec now reads `self.__vinextThemeInitRan` (matches the fixture's actual marker; the prior `documentElement.dataset` assertion would always have been undefined)
- gate `renderBeforeInteractiveInlineScripts` on a `/^[a-zA-Z][\w.-]*$/` attribute-name regex so unknown developer-supplied prop keys can't break out of the unquoted name position
- drop unused `BeforeInteractiveProvider` export — the SSR entry uses `BeforeInteractiveContext.Provider` directly
- correct the fixture layout docstring to describe the actual `self.__vinextThemeInitRan` marker and explain why we avoid `<html>` dataset attributes here
- consolidate the duplicate CSP-nonce middleware blocks for `/revalidate-test` and `/beforeinteractive-head-ordering`

* docs/test: address PR nit feedback

- correct the createTickBufferedTransform jsdoc: only `injectHTML` falls back to end-of-stream; `injectAfterHeadOpenHTML` is silently dropped when `<head ...>` is never found in a discoverable chunk (covered by the existing unit test)
- drop the dead pre-request scaffolding from the CSP-nonce test; the meaningful assertions live in the second request, so the first one was just `void html` noise
2026-05-22 16:12:08 +01:00
James Anderson 40d23de157 fix(next-request): throw canonical relative URL error (#1560)
* fix(next-request): throw canonical relative URL error

Calling `new NextRequest('/foo')` previously bubbled up `Failed to parse
URL from /foo` because the constructor's fallback `new URL(input,
"http://localhost")` masked the real problem. Next.js's NextRequest
runs `validateURL(url)` at the top of its constructor and throws an
error containing `Please use only absolute URLs`. Middleware tests
that assert on this message text now match Next.js.

Closes #1521

* fix(next-request): collapse inline URL validation to validateURL helper

Address bot review nit: the 13-line inline URL validation duplicated the
existing `validateURL` helper at line 215. Replace it with a call to the
helper. Bonus: relative URL rejection now also picks up the
`assertSafeNavigationUrl` guard against `javascript:`/`data:`/`vbscript:`
URIs (defense-in-depth).
2026-05-22 16:07:20 +01:00
James Anderson 97c11cdc37 fix(pages-router): rename data-vinext-head to data-next-head (#1559)
Pages Router <Head> now emits the canonical `data-next-head=""`
attribute matching Next.js, rather than the vinext-specific
`data-vinext-head="true"`. This unblocks `_document` head lookups
in the deploy suite.
2026-05-22 15:54:59 +01:00
James Anderson e121d42ef0 fix(middleware): warn when legacy middleware filename is detected (#1561)
Next.js 16 renamed `middleware.{js,ts}` to `proxy.{js,ts}` and emits a
canonical deprecation warning when the legacy filename is detected. vinext
was emitting a custom message that didn't match the wording the Next.js
deprecation-warnings and app-middleware e2e suites assert on.

Update `findMiddlewareFile` to log the exact text Next.js uses (from
`packages/next/src/build/index.ts` adjacent to `MIDDLEWARE_FILENAME`):

  The "middleware" file convention is deprecated. Please use "proxy" instead.
  ...migration codemod and docs link...

The warning still fires only when `middleware.{js,ts}` is found (after
checking `proxy.{js,ts}` first) so behavior is unchanged for projects on
the new convention. Existing test in tests/shims.test.ts is tightened to
assert the canonical wording.

Closes #1455
2026-05-22 15:54:42 +01:00
James Anderson d6bf6413e9 feat(pages-router): consume _next/data JSON endpoint from the client (#1412)
* feat(pages-router): consume _next/data JSON endpoint from the client

The server-side `/_next/data/<buildId>/<page>.json` endpoint landed in
#1384, but the vinext client was still fetching the full HTML page on
every navigation and regex-extracting `__NEXT_DATA__` from it. This
wires the client to actually use the JSON endpoint, matching what
Next.js does and avoiding the HTML round trip.

## Client navigation (shims/router.ts)

`navigateClient` is split into two paths:

- `navigateClientData` (new): when `window.__VINEXT_PAGE_LOADERS__` has
  a registered code-split loader for the target route AND we have a
  buildId, fetch `/_next/data/<buildId>/<page>.json` with
  `Accept: application/json` + `x-nextjs-data: 1`. On 200 parse the
  envelope, look up the page module via the in-memory loader map (Vite
  has already split each page into its own chunk), and re-render.
- `navigateClientHtml`: the previous HTML-fetch + regex-extract logic,
  unchanged. Used in dev (where the inline hydration script does not
  populate the loader map) and as a fallback for any route not in the
  client loader map.

Failure modes — 404, 5xx, network error, malformed JSON, soft
redirect via `x-nextjs-redirect`, missing loader — all queue a hard
navigation (`window.location.href = url`). This is the deploy-skew
safety net: when the server's buildId has rotated, the data endpoint
returns 404 and the client lands on the new build via a full document
load.

## Code-split loader manifest (entries/pages-client-entry.ts)

Vite already code-splits each page into its own chunk via the dynamic
imports in `pageLoaders`. The generated client entry now exposes that
map on `window.__VINEXT_PAGE_LOADERS__` (route pattern → loader thunk),
plus `window.__VINEXT_PAGE_PATTERNS__` (ordered list) and
`window.__VINEXT_APP_LOADER__` (the `_app` thunk, when present). This
replaces Next.js' `_buildManifest.js` indirection — vinext already
generates the equivalent at build time, we just had to expose it.

## Data URL construction (shims/internal/pages-data-url.ts)

New pure helper module mirroring `getAssetPathFromRoute` +
`getDataHref` from Next.js. Handles:

- `/`            → `/_next/data/<id>/index.json`
- `/about`       → `/_next/data/<id>/about.json`
- `/index`       → `/_next/data/<id>/index/index.json` (round-trip)
- `/blog/foo`    → `/_next/data/<id>/blog/foo.json`

Also exports `matchPagesPattern` which uses the existing
`matchRoutePattern` to map a URL pathname to a registered route
pattern.

Round-trip tests verify the server's `parseNextDataPathname` agrees
with the client's `buildPagesDataPath` for every shape, and surfaced a
latent server bug: `/_next/data/<id>/index/foo.json` was parsing back
to `/index/foo` instead of `/foo`. Fixed in `pages-data-route.ts` so
the round-trip is now lossless.

## Prefetch (shims/link.tsx + shims/router.ts)

`<Link>` prefetch and `Router.prefetch()` now prefer the data path:

- Inject `<link rel="prefetch" as="fetch" crossorigin href={dataUrl}>`
  for the JSON, matching the actual navigation's request shape.
- Invoke the loader thunk to warm the chunk in parallel. Vite's
  `import()` caches the result, so the eventual click is a double
  cache hit.

When no loader matches (dev, unmapped route), fall back to the legacy
`<link rel="prefetch" as="document">` so the browser at least preloads
the HTML.

Notably this also fixes a long-standing prefetch bug: the previous
Pages branch of `link.tsx`'s prefetch check
(`__NEXT_DATA__.__vinext?.pageModuleUrl`) was only populated by the
dev server's inline hydration script, so prefetch in production was a
no-op.

## Tests

- `tests/pages-data-url.test.ts` (new): 27 tests covering URL
  construction and the client/server round trip.
- `tests/shims.test.ts` (+8): JSON-path navigation success, 404
  hard-reload (build-skew), `x-nextjs-redirect` soft-redirect handling,
  HTML fallback when no loader matches, dynamic param extraction,
  malformed JSON hard-reload, prefetch JSON path, prefetch fallback.
- Ports from Next.js: `getDataHref` in
  `.nextjs-ref/packages/next/src/client/page-loader.ts:154`,
  `fetchNextData` in
  `.nextjs-ref/packages/next/src/shared/lib/router/router.ts:498`,
  `getAssetPathFromRoute` in
  `.nextjs-ref/packages/next/src/shared/lib/router/utils/get-asset-path-from-route.ts`.

Verified: 1393 vitest tests pass across shims, pages-router,
link/navigation, prefetch, data-route, data-url, entry-templates, and
routing.

Closes #1330 follow-up.

* fix(pages-router): drop unused export on PagesPatternMatch

Knip flagged the exported `PagesPatternMatch` type as unused. It is only
referenced as the return type of `matchPagesPattern` in the same file, so
narrow it to a local type.

* fix(pages-router): merge search params into __NEXT_DATA__.query on data-path nav

bigbonk review found that navigateClientData was synthesising
__NEXT_DATA__.query from dynamic route params only, missing the URL search
params. Code reading window.__NEXT_DATA__.query directly (a public Next.js
surface) would see incomplete data after a JSON navigation, even though
useRouter().query happened to still work because getPathnameAndQuery()
re-reads window.location.search.

Parse target.search and merge it into query alongside the route params,
with route params winning on key collision — same merge order as
getPathnameAndQuery() and Next.js itself.

Also fix a misleading comment in pages-client-entry.ts: the patterns list
is already sorted by pagesRouter()/compareRoutes (static → dynamic →
catch-all), not by the router at runtime.

* test(pages-router): e2e regression guard for JSON nav path

Adds a Playwright assertion that a Link click on a hydrated Pages Router
app on Cloudflare Workers fires a request to /_next/data/<buildId>/<page>.json
and does NOT trigger a full HTML reload. If the build pipeline ever stops
exposing __VINEXT_PAGE_LOADERS__ (or the loader map mis-keys), every other
navigation test still passes — they all assert the URL changed and the new
page rendered, neither of which fails when navigateClient silently falls
back to the HTML path. This test fails loudly in that scenario.

* test(pages-router): use hydration timestamp instead of resource-type sniffing

The original assertion 'no document-resource request to /ssr' caught false
positives: <link rel="prefetch" as="document"> can fire on initial paint
for unrelated reasons, and Chromium does not always classify prefetch hints
in a stable way. Swap to checking that __VINEXT_HYDRATED_AT survives the
click — a document reload would reset it, an in-place re-render preserves it.
Combined with the data-URL network assertion, this remains a tight guard on
the JSON nav path without the flake.

* test(pages-router): use window sentinel instead of timestamp comparison

Timestamps are timing-related signals; swap to a window-scoped sentinel
that a document reload would wipe and an in-place re-render preserves.
Same guarantee, no flake.

* fix(pages-router): handle _next/data requests in Cloudflare Workers example

The Node prod-server normalizes /_next/data/<buildId>/<page>.json before
middleware and flags it as a data request so renderPage emits the JSON
envelope. The Cloudflare worker example never did — data requests hit
matchPageRoute which can't match /_next/data/... and returned 404, so
navigateClientData saw the 404 and triggered a hard navigation. The
client-side JSON nav path was effectively dead on Workers.

Mirror the prod-server normalization in the worker: detect the data path
before middleware, validate the buildId, strip it to the page path, and
pass { isDataReq: true } through to renderPage.

Export ./server/pages-data-route from the vinext package so user-written
workers can import the helpers.

* Revert "fix(pages-router): handle _next/data requests in Cloudflare Workers example"

This reverts commit 719575b9ae.

* fix(pages-router): normalize _next/data requests inside the server entry

The Node prod-server normalized /_next/data/<buildId>/<page>.json before
middleware so renderPage could emit the JSON envelope. Cloudflare Workers
go through a user-written worker entry that doesn't (and shouldn't) know
about the data endpoint protocol, so data requests were reaching
matchPageRoute as /_next/data/... URLs, returning 404, and the client
fell back to a hard navigation — defeating the whole JSON-path
optimisation on Workers.

Move the detection into the generated server entry so it works for any
caller. runMiddleware now normalizes the URL for middleware matcher and
signals via result.rewriteUrl, and renderPage auto-detects data requests
from request.url so the JSON envelope fires whether or not middleware
rewrote the URL. Mismatched buildId short-circuits to a JSON 404 in
both paths so stale clients hard-navigate onto the new build.

* chore: re-trigger CI

* refactor(pages-router): share data-nav target resolver between Link and Router

Extract `resolvePagesDataNavigationTarget` + `prefetchPagesData` into
`shims/internal/pages-data-target.ts` so the Link prefetch path and the
Router (`navigateClient` + `Router.prefetch`) share one decision helper
instead of maintaining two structurally identical copies.

Also strip the locale prefix before matching `__VINEXT_PAGE_PATTERNS__`
so locale transitions (e.g. `/fr/about`) hit the JSON data fast path
instead of falling through to the slower HTML round-trip — route
patterns are locale-unaware (`/about`), but the browser URL for a
locale-prefixed page includes the locale. The data URL itself keeps
the locale prefix because the server uses it to pick locale-specific
gSSP data.

When the data path is used and the URL implies a different locale than
the current `__NEXT_DATA__.locale`, refresh the locale fields on the
synthesised `__NEXT_DATA__` (the JSON envelope itself only carries
`pageProps`, so the locale must be derived from the URL). Without this,
`useRouter().locale` stays stale after a client-side locale transition.

* chore: re-trigger CI
2026-05-22 15:46:38 +01:00
Nathan Nguyen 6920568e62 fix(router): route hybrid pages api fallbacks through pages entry (#1446)
Hybrid App Router plus Pages Router production builds entered through the App Router server and only delegated route misses to renderPage. That meant pages/api routes were outside the fallback path and returned the App Router 404 in mixed apps.

The generated RSC entry now dispatches /api misses to the Pages server entry handleApiRoute, applies middleware response effects to that API response, and the hybrid SSR bridge re-exports the API dispatcher. Regression coverage ports the Next.js og-api Pages API ImageResponse case at the production boundary.
v0.0.52
2026-05-22 14:23:41 +01:00
Nathan Nguyen 88144de251 perf(e2e): preinstall compatible React in deploy harness (#1443)
* perf(e2e): preinstall compatible React in deploy harness

App Router deploy-suite fixtures can start with React versions below the react-server-dom-webpack peer range. The first harness install then resolves that older React pair, and vinext build immediately runs its RSC compatibility upgrade, causing a second package-manager install inside each throwaway app.

Normalize App Router fixture manifests before the initial install by replacing older semver React and React DOM specs with the workspace-compatible specs. Pages-only fixtures and non-semver specs are left untouched.

* chore: rerun ci

* fix(e2e): derive React floor from harness spec

The deploy harness normalized App Router fixture React versions against a hardcoded 19.2.6 floor. That duplicated the compatibility threshold already expressed by vinext's install specs and could go stale when the workspace moves to a newer compatible React pair.

Compare each fixture dependency against the replacement spec that the harness would write. This keeps React and React DOM normalization tied to the package metadata instead of a separate constant.
2026-05-22 14:22:27 +01:00
James Anderson b77dcfebdc test(msw): migrate font-google.test.ts from fetch hijacking to MSW (#1447)
Replaces 16 ad-hoc `globalThis.fetch = async () => new Response(...)`
swaps with a small `mockGoogleFontsCSS(body, opts?)` helper backed by
MSW's `server.use(http.get(...))`. The helper records every URL it
sees so the two tests that previously asserted on `fetchedUrls.find()`
just read `mock.urls` instead, and the one test that asserted on a
hit-count read `mock.count()`.

Two sites need bespoke handling:

  - The Inter/Roboto URL-branching test uses an inline
    `server.use(http.get("https://fonts.googleapis.com/*", ({ request }) => ...))`
    that returns different CSS based on the URL.
  - The "network error" test uses `HttpResponse.error()` to make the
    fetch reject (replacing the previous `throw new TypeError(...)` in
    the swapped fetch).

Handlers are reset between tests by the global `afterEach` in
`tests/_msw/setup.ts`, so the per-test `try { ... } finally
{ globalThis.fetch = originalFetch }` boilerplate disappears (the
`finally` blocks now only call `fs.rmSync` on the temp font root).

This is PR 2 of the MSW migration. PR 1 (#1444) introduced the
infrastructure and migrated `tests/font-google-build.test.ts`. A
follow-up will migrate `tests/fetch-cache.test.ts` and the in-test
fetch stub inside the font block of `tests/app-router.test.ts`.
2026-05-22 13:13:54 +00:00
Nathan Nguyen 7871730c3e fix(app-router): guard deployment fallback hard navigations (#1440)
App Router RSC deployment mismatches and related fallback paths could still write directly to window.location outside the navigation controller. That bypassed the persisted same-target hard-navigation guard, so a canary or rollback mismatch could repeatedly reload the current document instead of stopping after the first hard navigation.

Expose the controller's guarded hard-navigation executor and route RSC compatibility fallbacks, server-action deployment fallbacks, and RSC navigation error fallbacks through it. Add skip/cache canary rollback compatibility coverage plus a direct fallback guard regression test.
2026-05-22 14:09:03 +01:00
James Anderson d03c5aa045 test(msw): introduce MSW infrastructure and migrate font-google-build (#1444)
* test(msw): introduce MSW infrastructure and migrate font-google-build

Replaces ad-hoc `globalThis.fetch` hijacking in tests with Mock Service
Worker. MSW intercepts both `globalThis.fetch` and `node:http`/`https`,
so it covers fetches issued by in-process Vite servers and fixture pages
without each test having to manage its own stub.

The setup file enables `onUnhandledRequest: 'error'` so any future test
that issues an unmocked external request fails loudly instead of
silently hitting the network. Loopback hostnames (localhost, 127.0.0.1,
::1) are bypassed because the integration project deliberately hits
in-process test servers via fetch.

What this PR contains:
- `tests/_msw/{handlers,server,setup}.ts` — shared infra
- `setupFiles` wired into both vitest projects (unit + integration)
- `msw` + `@mswjs/interceptors` added to the workspace catalog
- `msw` postinstall skipped via `allowBuilds` (it only copies a browser
  service worker; we exclusively use `msw/node`)
- `tests/font-google-build.test.ts` migrated as the proof of pattern

Temporary handlers for `httpbin.org/uuid` and
`example.com/not-cacheable` keep the existing fixture pages
(`revalidate-tag-test`, `dynamic-error-fetch`) working under the new
guard. These are placeholders flagged with `TODO(msw-migration)` — a
follow-up will replace those live URLs with test-local endpoints.

This is PR 1 of 3:
- PR 2 will migrate `tests/fetch-cache.test.ts`
- PR 3 will migrate `tests/font-google.test.ts` and the font block in
  `tests/app-router.test.ts`

* test(msw): restrict MSW to the unit project + use passthrough for loopback

Two fixes to the initial MSW wiring that surfaced when CI ran the full
suite:

1. The integration project is now excluded from MSW. Integration tests
   spin up in-process HTTP servers and fixture dev servers and exercise
   them via `fetch("http://127.0.0.1:<port>/...")`. Even with a loopback
   `passthrough()` handler installed, the @mswjs/interceptors layer
   interferes with that traffic in subtle ways:

   - `tests/prerender.test.ts > errors without writing .rsc when the
     middleware short-circuit fallback RSC request fails` timed out at
     30s — the 5xx response body never reached the caller.
   - `tests/ecosystem.test.ts > nuqs` hung in `beforeAll` waiting for
     its fixture process to come up, because the readiness fetch
     stalled inside the interceptor.

   Integration tests already talk to real local servers, not the
   internet, so the unhandled-request guard buys little there. The
   value of MSW for this repo is mocking external HTTP for unit tests
   of fetch wrappers (font-google, font-google-build, fetch-cache),
   which is preserved.

2. The `onUnhandledRequest` callback in `setup.ts` that tried to
   silently let loopback through wasn't sufficient — MSW's interceptor
   still ran for those requests and could stall them. Replaced with a
   default `http.all(LOOPBACK_URL_PATTERN, () => passthrough())`
   handler in `handlers.ts` and a plain `onUnhandledRequest: "error"`
   in `setup.ts`. Unit tests like the middleware-rewrite proxy in
   `tests/shims.test.ts` and the JSX-in-JS suite need this to keep
   their in-process HTTP servers reachable.

* test(msw): address BigBonk review notes

- Update PR description in the commit body to drop the stale mention of
  TODO(msw-migration) handlers — they were removed when the integration
  project was excluded from MSW; the fixture pages run there.
- Tighten the loopback IPv4 octet regex from `\d+` to `\d{1,3}` so it
  doesn't match absurdly long octet strings. `URL.hostname` normalises
  real addresses, so this is purely a readability fix.

* test(msw): address remaining BigBonk notes from the initial review

- Add `0.0.0.0` to the loopback passthrough pattern so test servers that
  bind to the unspecified address keep working without per-test mocks.
- Add a `TODO(msw-3)` comment in `server.ts` flagging the
  `SetupServerApi` deprecation in MSW 2.14.6 — the `FetchInterceptor`-only
  construction will need to migrate to `defineNetwork`'s interceptor
  config when we bump to MSW 3.

Both raised by BigBonk on the first review of this PR (the second review
focused on the stale description and the loose `\d+` octet bound, which
were addressed in the previous commit).

* test(msw): scope server.use() inside the try block in font-google-build

Minor structural fix from BigBonk's review: moves the `server.use()`
handler registration inside the `try` block so all setup and the
matching `fs.unlink` cleanup share the same try/finally scope.

No behaviour change — `server.use()` is synchronous and infallible, so
this is purely a structural tidy-up.

* test(msw): correct comment in font-google-build to match interceptor config

BigBonk caught that the comment described the default `setupServer()`
behaviour (which uses both `FetchInterceptor` and `ClientRequestInterceptor`),
but this project's `server.ts` deliberately constructs `SetupServerApi`
with only `FetchInterceptor`. Update the comment to reflect that — it
intercepts `globalThis.fetch`, not `node:http`/`node:https`.

No code change, comment only.
2026-05-22 13:03:08 +00:00
Nathan Nguyen b6fc03cfc3 fix(router): preserve default locale for unprefixed root links (#1442)
* fix(router): preserve default locale for unprefixed root links

Unprefixed Pages Router URLs represent the default locale, even when the browser's preferred locale has previously been detected as a non-default locale. Treating the ambient runtime locale as authoritative makes a Link from /new to / request /id and returns the wrong page locale.

Derive implicit client navigation locale from the current URL first, then domain/default locale state, and render a locale-qualified default root fallback href so non-intercepted root links do not re-enter preferred-locale detection. The regression tests port the relevant Next.js i18n preferred-locale cases at the Link and router boundaries.

* refactor(router): share client locale inference

The review called out that Link and Router had identical browser locale fallback logic. Keeping that chain duplicated makes future i18n changes easy to apply in one place but miss in the other.

Move the URL-first browser locale lookup into a shared shim helper and add SSR coverage for the intentional default-locale root fallback href.
2026-05-22 13:58:42 +01:00
James Anderson 46ab691094 fix(i18n): normalise default-locale paths before route matching (#1409)
Mirrors Next.js's server-side default-locale path normalisation in
`packages/next/src/server/lib/router-utils/resolve-routes.ts` (lines
~250-263): every request that arrives without a locale prefix is
spliced with `/${defaultLocale}` before any `next.config.js`
redirect / rewrite / header rule (or filesystem route) match runs.

Without this, rules like `source: '/:locale/to-sv'` with
`locale: false` failed to match requests for `/to-sv` because there
was no segment for the `:locale` placeholder. After normalisation the
matcher sees `/en/to-sv` and the rule matches with `:locale=en`.

Per the merged PR #1382's description, the issue text's call for a 308
redirect from `/en/page` to `/page` is incorrect: Next.js serves
the same content under both URLs and normalises the path internally.
This change implements that internal normalisation; no redirect.

The new helper lives next to the existing pages i18n helpers so both
routers can use it. Wired into:

  - `prod-server.ts` (Pages Router prod)
  - `app-rsc-handler.ts` (App Router dev + prod)
  - `index.ts` (Pages Router dev plugin)
  - `deploy.ts` (Cloudflare Workers entry template)

The domain-mapped default locale (`i18n.domains[].defaultLocale`) is
preferred over the global default when the inbound host matches,
matching Next.js's `domainLocale.defaultLocale` branch in
resolve-routes.ts. `/_next/*` and `/__vinext/*` paths are excluded
to mirror Next.js's exception for build assets and prerender manifests.

The fallback "retain the original unprefixed source" branch in
`applyLocaleToRoutes` is intentionally kept as belt-and-braces: the
new normalisation pass renders it dead-code for ordinary requests, but
it harmlessly preserves backwards compatibility with any matcher
caller that still passes raw, unnormalised pathnames.

Refs #1336 (item 4)
2026-05-22 13:55:03 +01:00
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