107 Commits

Author SHA1 Message Date
Nathan Nguyen 314fbea508 fix(app-router): align RSC navigation scroll targeting with Next.js (#1599)
* fix(app-router): align RSC navigation scroll targeting with Next.js

App Router RSC navigations scrolled the document after appNavigate resolved, which lost Next.js segment targeting and focus semantics. That left persistent layout offsets and horizontal scroll behaviour coarser than Next.js during normal navigation.

Next.js treats scroll as a navigation intent consumed by the newly navigated segment after commit. This adds a small intent state, wraps the page slot in a client scroll target, and keeps the existing hash-only and popstate paths separate.

Adds focused Playwright coverage ported from Next.js autoscroll and navigation focus tests.

* test(form): stop asserting app router scroll detail

* fix(app-router): prevent focus from changing scroll

* fix(app-router): share scroll intent across Vite module instances

The App Router scroll intent was stored in plain module-level variables in
app-router-scroll-state.ts. navigateClientSide stages the intent and the
committed AppRouterScrollTarget consumes it, but next/navigation and the
scroll-target module can resolve to separate Vite module instances. If the
writer and consumer hold different copies, the staged intent is invisible to
the consumer and the post-commit scroll/focus silently no-ops.

This is the same multi-instance hazard the rest of the navigation state already
guards against with Symbol.for globals (see _CLIENT_NAV_STATE_KEY in
navigation.ts and AGENTS.md "RSC and SSR Are Separate Vite Environments").

Move the single pending intent and the id counter onto a Symbol.for global so
every module instance shares one slot, matching the established navigation
state pattern. Function signatures are unchanged, so callers need no edits.

Also document why AppRouterScrollTarget must remain a class component:
findDOMNode needs a mounted class instance to locate the first rendered DOM
node without a wrapper element, mirroring Next's default scroll handler.
2026-05-27 12:25:54 +01:00
Nathan Nguyen 5fe9789bbd fix(app-router): preserve history index on external state writes (#1600)
* fix(app-router): preserve history index on external state writes

External history.pushState and history.replaceState calls preserved vinext interception metadata but dropped the traversal index. That left userland or library shallow history entries without the metadata App Router traversal uses to classify and anchor back/forward state.

createExternalHistoryStatePreservingMetadata now preserves vinext navigation metadata as one unit from the current history entry. The shim regression covers object, null, undefined, and index-only external state writes.

* test(app-router): seed stale history context through unpatched state

Public History API calls now sanitize vinext metadata before delegating. The refresh regression test was seeding stale intercepted context through that public wrapper, so it tested the wrapper boundary instead of the stale internal-state condition.

Use the saved original replaceState handle to create the same framework-owned stale state the test is meant to refresh away.
2026-05-26 15:49:58 +01:00
Nathan Nguyen bca641535b fix: resolve relative server action redirects (#1579)
* fix: resolve relative server action redirects

Server action redirects could carry dot-relative locations from the action response. Treating those locations as raw browser redirects resolved them from the current document URL and forced a reload, so nested App Router actions could land on the wrong route or lose client state.

Resolve server action redirect locations against the action initiation pathname using Next.js directory-style semantics, keep same-origin redirects on the App Router navigation path, and hard-navigate only for external destinations or parse fallback.

Adds browser-level coverage ported from the Next.js relative server action redirect test plus focused helper assertions for the URL resolution contract.

* chore: rerun ci

* chore: rerun ci again
2026-05-26 11:45:53 +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
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 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
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 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 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
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
Nathan Nguyen 0d8d3e3c60 refactor(router): quarantine navigation runtime topology (#1322)
* refactor(router): quarantine navigation runtime topology

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

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

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

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

* fix(router): narrow prerender RSC script parsing

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

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

* fix(router): address navigation runtime review

* fix(router): resolve navigation runtime review threads
2026-05-20 10:29:13 +01:00
James Anderson 9415a238f4 feat(config): implement assetPrefix (#1311)
* feat(config): parse and apply next.config assetPrefix at build time

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

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

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

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

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

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

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

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

Covers the surface area added in the previous two commits:

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Addresses james-elicx review feedback on #1311.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Addresses round-4 reviewer feedback on #1311.

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

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

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

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

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

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

Addresses round-5 reviewer feedback on #1311.

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

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

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

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

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

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

Addresses non-blocking round-5 review feedback on #1311.
2026-05-19 11:53:49 +01:00
Divanshu Chauhan (divkix) a85c2c1dee fix: add x-action-forwarded guard and E2E tests for server action forwarding loop (#1292)
* feat(server-action): add x-action-forwarded guard to prevent forwarding loops

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

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

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

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

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

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

* test(request-pipeline): add x-action-forwarded to INTERNAL_HEADERS snapshot
2026-05-18 18:07:05 +01:00
Nathan Nguyen f4a37b4ade fix(app-router): track hash-only traversal metadata (#1306)
* fix(app-router): track hash-only traversal metadata

Hash-only App Router navigations wrote null history state even though they are app-owned browser-visible entries. That kept traversal direction unknown, but it broke the metadata invariant introduced by the App Router navigation lifecycle work.

Route hash-only push and replace through a browser-entry-owned commit hook so the same traversal index allocator and metadata shape are used without fetching RSC. Also update the local traversal index during same-route hash popstate so replace-after-back uses the current entry index.

Closes #1252

* fix(app-router): avoid stale hash replace scroll state

Hash-only App Router replace now passes the effective scroll option into the browser-entry commit hook.

Default-scroll replaces strip vinext scroll restoration keys before adding traversal metadata, avoiding contradictory history entries whose URL points at a hash target but whose state restores the old scroll position. Explicit scroll:false keeps the previous scroll restoration state.

Add browser coverage for replace-after-back to #content, including the traversal back to the replaced entry.
2026-05-18 11:20:02 +01:00
Nathan Nguyen 16f9ea393f fix(app-router): model RSC redirect and traversal lifecycle (#1248)
RSC redirects were followed by mutating browser history before the redirected payload had passed the visible commit lifecycle. That could collapse the initiating history entry and leave back/forward traversal without an explicit intent model.

Keep redirect hops inside the initiating navigation until an approved commit publishes history, add explicit terminal redirect decisions, and record per-entry traversal metadata so unknown browser history state stays unknown instead of being guessed.

Add focused unit coverage for redirect decisions and traversal metadata plus a browser regression for router.push redirect back navigation.
2026-05-18 10:42:19 +01:00
Nathan Nguyen 656d954abf feat(router): promote intercepted preservation through planner (#1249)
* feat(router): promote intercepted preservation through planner

Intercepted navigation could still preserve source UI through context-bearing payload shape and same-URL history state. That made modal preservation depend on transport metadata instead of the visible route world that had actually been committed.

The planner now requires explicit interception proof before preserving source layouts or unrelated slots, and the browser lifecycle clears stale previous-next history state when normal target payloads commit. Wire metadata carries proof, while missing, stale, or incompatible proof falls back to hard navigation.

Tests cover approved source-slot preservation, missing, stale, and malformed proof rejection, current-context refresh and action handling, traverse restoration, and direct-target refresh clearing stale interception state.

* fix(router): normalize intercepted proof paths

Interception proof could be built from browser-derived percent-encoded paths while committed route snapshots used the server-normalized route-state form. Non-ASCII intercepted source or target paths could then fail proof validation and fall back to a hard navigation.

Normalize proof matched URLs before encoding source and target route IDs, and normalize client planner snapshot matched URLs to the same route-state representation without changing the user-facing navigation snapshot.

Adds regressions for encoded non-ASCII proof generation and planner validation.

* review: address ask-bonk PR review comments

- Add interceptedRejectedTargetMismatch reason code for target URL
  mismatch in planner traces (was misusing UnknownSource)
- Document double-attempt replaceState fallback for browser quirks
- Clarify navigate branches: proven interception vs legacy fallback
- Document cache migration safety for legacy interceptionContext
- Tighten isInterceptionMetadataValue type guard with string checks
- Extract interception attachment into shared step in wire metadata
  entries to remove asymmetric branching

* fix(router): ignore context-only interception metadata

Context-only AppElements payloads could enter intercepted preservation planning or leave context-suffixed route identity as later proof authority. That made transport metadata influence visible-world preservation when a normal response was rendered from an intercepted request context.

Only explicit interception proof now enters the preservation branch. Planner snapshots also canonicalize context-suffixed route IDs when no proof is present, keeping render keys partitioned while preventing stale context from poisoning future explicit interception proof.

Tests cover the direct planner decision and the two-step browser lifecycle regression.

* test(router): cover intercepted proof rejection matrix

Planner traces now expose distinct rejection reasons for mismatched intercepted targets and incompatible roots. Without direct tests, those branches could regress while the broader intercepted route scenarios still passed.

Add focused planner tests for target mismatch and incompatible root rejection, use the local target snapshot consistently, document two lifecycle ordering contracts, and avoid the no-op interception metadata spread on non-intercepted payloads.
2026-05-18 10:18:49 +01:00
Nathan Nguyen 7283e44beb fix(router): keep next/router import free of popstate side effects (#1294)
* fix(router): keep next/router import free of popstate side effects

Importing next/router currently installs the Pages Router popstate listener as module-level browser state. That listener can be pulled into App Router client bundles by compatibility imports, where it races the App Router history runtime and can turn back/forward traversal into a document navigation.

Move Pages Router popstate installation behind an internal vinext runtime module that only Pages Router entrypoints call after hydration setup. Keep the existing popstate decision logic in the router shim, but make global listener ownership explicit at the Pages boundary.

Add unit coverage for side-effect-free next/router imports and an App Router e2e regression that imports next/router, traverses history, and asserts no document request occurs.

* refactor(router): remove redundant beforePopState assertion

The Pages Router popstate handler already narrows the beforePopState callback before invoking it. Call the narrowed function directly so the production router code does not carry an unnecessary type assertion.
2026-05-18 10:08:40 +01:00
Nathan Nguyen d558923fde fix(script): dedupe concurrent same-src loads (#1263)
* fix(script): dedupe concurrent same-src loads

Multiple next/script components with the same src could append duplicate DOM scripts when they mounted before the first script load event fired. That double-executed third-party code and diverged from Next.js' in-flight script cache behavior.

The shim now tracks remote scripts that are currently loading separately from scripts that have completed loading, and fans out load/error callbacks without adding another script element.

Adds a Pages Router browser regression that verifies simultaneous same-src scripts produce one DOM script and one execution.

* fix: address review feedback for script load dedupe

- Defer loadedScripts.add(key) to after in-flight promise resolves
- Add onReady() callback for duplicate callers joining in-flight load
- Clean up loadingScripts entry on promise settlement to allow retry
2026-05-16 20:16:38 +01:00
Nathan Nguyen ba1829a804 fix(image): clear blur placeholder after image load (#1267)
* fix(image): clear blur placeholder after image load

Blur placeholders currently stay on the img background after the high resolution image finishes loading. That is wrong for transparent image formats because the low quality placeholder remains visible through the rendered pixels.

The shim treated placeholder styling as static props instead of load lifecycle state. Track blur completion per src, clear local and remote placeholder backgrounds after successful load or error, and cover the transparent-image regression in an app-router browser test.

* chore: rerun ci

---------

Co-authored-by: James <james@eli.cx>
2026-05-16 20:06:34 +01:00
Nathan Nguyen d280e3620b fix(app-router): skip RSC navigation for hash-only history traversal (#1270)
App Router popstate currently sends every history traversal through __VINEXT_RSC_NAVIGATE__, even when the target pathname and search already match the committed route and only the hash entry changed. That violates the hash-only traversal invariant and replays cached RSC payloads unnecessarily.\n\nCompare popstate targets against the committed App Router pathname and search before dispatching an RSC traversal. Same-route entries now restore scroll directly, while real route traversals keep the existing pending-navigation coordination.\n\nAdd an App Router E2E regression that wraps __VINEXT_RSC_NAVIGATE__ and verifies hash back/forward traversal does not call it.

Co-authored-by: James <james@eli.cx>
2026-05-16 18:59:46 +00:00
Nathan Nguyen be9f338306 fix(router): derive shallow dynamic params from the URL (#1272)
* fix(router): derive shallow dynamic params from the URL

Pages Router shallow navigation across one dynamic route template updated the browser URL but kept router.query params from the original __NEXT_DATA__ payload. That left client state stale after transitions such as /posts/42 to /posts/43.

The router now matches the current pathname against the hydrated route pattern before falling back to __NEXT_DATA__.query. This preserves shallow data-fetch skipping while keeping dynamic params aligned with the visible URL.

Tests cover the router shim boundary and the browser-visible Pages Router shallow transition.

* refactor(router): reuse route-pattern matching for shallow params

The shallow dynamic params fix originally duplicated route-pattern parsing inside the router shim.

Reuse the existing routePatternParts and matchRoutePattern helpers instead, keeping the __NEXT_DATA__ fallback behavior unchanged.

---------

Co-authored-by: James <james@eli.cx>
2026-05-16 18:58:11 +00:00
Nathan Nguyen deef64470b fix(app-router): keep refresh transitions pending (#1269)
router.refresh() currently skips the pending browser router state even when it is dispatched from a React transition. That lets useTransition return to idle while the refresh RSC request is still in flight.

The refresh path should follow the same App Router invariant as other programmatic navigations: publish a deferred router-state promise before the request and resolve it when the refreshed tree commits.

Allow programmatic refresh navigations to create pending router state, and cover the behaviour with a browser regression that observes isPending during a delayed same-route refresh.
2026-05-16 19:36:48 +01:00
Nathan Nguyen 7d1f482050 fix(link): preserve unsafe href click handlers (#1264)
Vinext strips dangerous Link href values before rendering the anchor. That keeps javascript: URLs inert, but the unsafe render branch also dropped the developer-provided onClick handler, so links used as click targets stopped working after hydration.

The dangerous-href branch now preserves the anchor ref, link status provider, and user click handler while continuing to omit href. The regression fixture verifies that the click handler runs and the browser URL stays unchanged.
2026-05-16 19:13:44 +01:00
Nathan Nguyen 2468ae5fba fix(app-router): guard stale popstate scroll restoration (#1253)
* fix(server): progressive actions trigger error boundaries on HTTP fallbacks

* fix(server): type progressive action errors

* fix: guard stale popstate scroll restoration with active nav id

* revert(server): drop action error changes from popstate PR

The popstate race branch included server action commits from a separate branch. That made the PR review surface change progressive action error semantics unrelated to browser history restoration.

Restore those files to main's behavior in this branch and keep only the popstate scroll restoration fix. Also correct the new popstate tests so targeted check and unit coverage pass.

* test(app-router): wait for RSC cache seeding

The same-build visited payload E2E asserted cache replay after visible commit from a void router.push. The visited-response cache is populated when the internal RSC navigation promise resolves, so the test could navigate away before the cache entry existed and count a second fetch.

Wrap the internal navigation function in the test and wait for the captured promise before asserting the replay path.
2026-05-16 17:54:34 +01:00
Nathan Nguyen 7d299088b8 fix(app-router): promote default slot persistence through route state (#1229)
* fix(app-router): promote default slot persistence through route state

Default and unmatched parallel slots were still inferred from AppElements transport markers. That made soft navigation persistence depend on wire shape instead of planner-owned route state, which breaks the #726 invariant that AppElementsWire is transport only.

Thread slot binding metadata through AppElements, browser state, and the navigation planner. The commit boundary now preserves previous slot content and binding proof only when target route-state marks the slot default or unmatched and the visible state proves a mounted/default value exists.

Tests cover the new planner contract, metadata validation, route wiring, mergeElements behavior, and browser commit lifecycle for default and unmatched slot targets.

* fix(app-router): align intercepted slot binding metadata

Intercepted slot payloads could render active modal content while their route-state metadata still described the slot as default. The browser planner could then preserve the previous default slot for an ID that the fresh payload should replace, breaking intercepted navigations in app-router E2E.

Derive slot IDs through one helper for both metadata and rendering, assert graph/wire ID divergence, and compute binding state from the same resolved override default export used by the render path. Move the app hydration marker to the committed root layout effect so E2E link clicks wait for a hydrated tree.

* fix(app-router): harden slot binding preservation

Review follow-up for #726-CORE-14. Centralize AppElements slot binding normalization so duplicate slot ids and stale owner layout ids fail at the wire boundary instead of creating quiet last-write-wins behavior.

Alias planner slot binding snapshots to the AppElements binding shape, share slot id ordering helpers, document the no-proof compatibility law, and add negative planner/wire tests plus a browser-visible soft navigation E2E. Refs #726.

* fix(app-router): align slot binding proof with rendering

* docs(app-router): clarify slot preservation invariants
2026-05-16 01:02:56 +01:00
Nathan Nguyen 1e7005bbb6 fix(app-router): refresh after discarded revalidating actions (#1240) 2026-05-15 21:19:35 +01:00
Nathan Nguyen 4aa7663084 fix(app-router): hard navigate stale RSC build payloads (#1178)
* fix(app-router): hard navigate stale RSC build payloads

App Router soft navigation currently accepts any valid RSC payload after deploy. That lets stale tabs decode RSC responses produced by a different build, which can break client navigation when the server and browser artifacts no longer match.

The missing invariant was that every RSC response consumed by the browser must identify the build that produced it, and the browser must reject payloads from a different build before handing them to React.

Emit a vinext-owned build ID header on App Router RSC responses, preserve it in prefetched and visited response snapshots, and hard navigate when a response is missing or mismatches the current client build ID.

* chore: fix e2e fixture hook lint suppression

* chore: avoid hook-like fixture callback name

* fix(app-router): preserve build IDs for cached RSC payloads

* fix(app-router): use public RSC compatibility IDs
2026-05-15 12:01:33 +01:00
James Anderson 81d5426ee6 fix: enable JSX in plain .js files (Next.js compat) (#1199)
* fix: enable JSX in plain .js files (Next.js compat)

Add vinext:jsx-in-js transform plugin to handle JSX in plain .js files.
Next.js allows JSX in .js files (Babel/SWC handle it transparently),
but Vite 8's built-in vite:oxc plugin excludes .js files by default
(exclude: /\.js$/) AND infers lang: "js" from the extension (which
disables JSX parsing). Neither can be fixed via config alone.

The plugin runs with enforce: "pre" before vite:oxc, transforming .js
files with OXC using lang: "jsx" so JSX is compiled before vite:oxc
sees the code.

Tested: 972 tests pass (jsx-in-js + shims + routing), zero regressions.

* fix: suppress rules-of-hooks lint for Playwright fixture use()

The oxlint-disable-next-line only covered the function signature line,
not the use() call deeper in the function body. Use block-scoped
disable/enable to suppress the entire fixture definition.
2026-05-14 13:49:33 +01:00
Stephen Zhou 9126344cd8 fix: use loader for client reference (#1141)
* fix: split client chunk

* add test

* fix ci deps

* install deps

* use loader

* tweaks
2026-05-14 13:18:58 +01:00
MK (fengmk2) 9d5a2e9a77 chore(deps): bump vite-plus to 0.1.21 (#1190)
- Bump vite, vite-plus, vitest catalog entries to 0.1.21
- Refresh pnpm-lock.yaml
- Regenerate AGENTS.md via vp config postinstall
- Drop unused imports (PHASE_PRODUCTION_BUILD, patternToNextFormat) flagged by upgraded lint rules
- Move react-hooks/rules-of-hooks suppression in tests/e2e/fixtures.ts to the use(errors) call site
- Add react/no-children-prop and react/no-danger-with-children suppressions on the intentional precedence test in tests/head.test.ts
2026-05-14 09:14:31 +01:00
Nathan Nguyen 2d6978f1f9 fix(link): align app-router prefetch scheduling with intent (#1149)
* fix(link): align app-router prefetch scheduling with intent

Link currently schedules viewport RSC prefetches outside production and does not promote user mouse or touch intent into an immediate prefetch. That diverges from Next.js Link behavior and can waste dev-server work during rapid iteration while missing an important production navigation warmup path.

The shim now gates automatic prefetching to production, shares same-origin prefetch normalization, and schedules high-priority RSC prefetches from mouse and touch intent while preserving user handlers. Focused Link navigation tests cover development suppression, intent prefetch, prefetch=false, and dangerous-link handler passthrough.

* fix(link): align app-router prefetch scheduling with intent

Link currently schedules viewport RSC prefetches outside production and does not promote user mouse or touch intent into an immediate prefetch. That diverges from Next.js Link behavior and can waste dev-server work during rapid iteration while missing an important production navigation warmup path.

The shim now gates automatic prefetching to production, shares same-origin prefetch normalization, and schedules high-priority RSC prefetches from mouse and touch intent while preserving user handlers. Focused Link navigation tests cover development suppression, intent prefetch, prefetch=false, and dangerous-link handler passthrough.

* test: tighten link prefetch coverage

* test(link): tighten prefetch decision coverage

* test(link): address prefetch review notes
2026-05-13 11:33:30 +01:00
Nathan Nguyen d85791c2fb feat(app-router): promote segment reset semantics (#1187)
* feat(app-router): promote segment reset semantics

Implements #726-CORE-11/12 from #726.

Add typed App Router segment state-key derivation, wire template/loading/error/access boundaries to semantic reset keys, and preserve pathname fallback semantics for legacy boundary callers.

Cover dynamic params, route groups, catch-all keys, template reset ownership, and boundary reset behavior.

* fix(app-router): treat empty boundary reset keys as absent

Empty segment identities can be produced for root, route-group-only, and default slot paths. Normalize empty reset keys to null so pathname fallback continues to clear caught boundary state.

* fix(app-router): scope route reset keys to branches

Use typed segment state identities and full visible route-state paths for route-wide wrappers so unrelated branches do not collide on the same leaf value.

Add hostile unit and browser tests for dynamic and static branch collisions.
2026-05-13 10:02:02 +01:00
Yoinky3000 493e72d256 fix(app-router): catch redirect diges, render loading.tsx during cross-route navigation and invalid with calling refresh() (#1152)
* fix(app-router): catch server redirect digest, render loading.tsx across navigation

* Update page.tsx

* fix(app-router): tighten RedirectBoundary digest handling and dedupe nav-signal check

Addresses code-review findings on PR #1152:

- RedirectBoundary.reset() now clears both `redirect` and `redirectType`
  so the state never sits in a half-cleared (`null`, "replace") shape.
- getDerivedStateFromError re-throws on malformed `NEXT_REDIRECT;` digests
  (empty URL segment) instead of silently swallowing the error. Mirrors
  the server-side guard in parseNextRedirectDigest (next-error-digest.ts:51).
- Document the `error.handled` branch as a Next.js parity placeholder so
  it's clear it is not dead code, just unused by vinext today.
- Extract `getErrorDigest` / `isNavigationSignalError` into a shared
  utils/navigation-signal.ts, removing the duplicated digest classification
  in shims/error-boundary.tsx and server/dev-error-overlay.tsx.
- Update stale `snapshotRscResponse` reference in the cached-route comment
  to the current body.tee + createFromFetch flow.
- Strengthen the delayed-redirect-under-loading E2E test to also assert
  the loading.tsx fallback paints during the 50ms server delay.
- Format fix on tests/fixtures/app-basic/app/page.tsx that was failing
  the CI Check job.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(app-router): invalidate client RSC caches on router.refresh()

router.refresh() previously only re-fetched the current URL via the
"refresh" navigationKind, leaving the visited-response and prefetch
caches untouched for sibling routes. After a session change (e.g.
authClient.signOut() → router.push("/") → router.refresh()), a
subsequent <Link> click to a previously visited auth-gated route
would render the stale cached RSC payload and bypass the server's
redirect() entirely.

Matches Next.js's refresh-reducer.ts, whose header comment is
explicit: "During a refresh, we invalidate the segment cache but not
the route cache. The segment cache contains the actual RSC data
which needs to be re-fetched." Next does this via an O(1) version
counter (invalidateSegmentCacheEntries → currentSegmentCacheVersion++);
vinext's bounded LRU is small enough that Map.clear() on the
visited-response + prefetch caches is equivalent in observable
behavior.

Pre-existing in upstream vinext (verified against 8edc010); only
surfaced after the redirect-digest fix earlier in this PR, because
before that the unhandled NEXT_REDIRECT digest aborted the page
render outright and masked the stale-cache reuse.

- Expose clearClientNavigationCaches as window.__VINEXT_CLEAR_NAV_CACHES__
- router.refresh() calls it synchronously before scheduling the RSC
  re-fetch inside React.startTransition. Synchronous ordering matters:
  if the clear ran inside the transition, prefetches kicked off during
  the transition's renders could repopulate stale entries into the
  just-cleared cache.
- Unit test pins the clear-before-navigate ordering.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(app-router): address review — remove HandleRedirect reset, fix boundary nesting, add malformed digest test

Address ask-bonk review comments from all three rounds:

1. Remove reset() from HandleRedirect — calling reset() inside startTransition
   clears the boundary state, re-rendering children and re-mounting the page
   that threw redirect(). For deterministic redirects (auth guards) this
   creates an infinite loop. Next.js intentionally does not call reset() in
   HandleRedirect (redirect-boundary.tsx); the boundary stays in its 'caught'
   state rendering null until router.push()/replace() mounts fresh children.

2. Move RedirectBoundary inside Suspense (loading boundary) — Next.js nesting
   is Error > Loading > AccessFallback > Redirect > content. The previous
   placement had RedirectBoundary outside Suspense, which unmounted the
   loading fallback during redirect transitions. Now RedirectBoundary wraps
   the page slot directly, and Suspense wraps both.

3. Add comment documenting dual-format digest parsing — the parts.length >= 5
   branching handles both vinext 3/4-part and Next.js 5-part digests, which
   was non-obvious to reviewers.

4. Add test for malformed-digest re-throw guard — verifies that
   getDerivedStateFromError re-throws when the URL segment is empty
   (e.g. NEXT_REDIRECT;push;) rather than silently swallowing the error.

* fix(app-router): correct nesting-order comment, add error.handled test

Address ask-bonk round-4 review:

1. Fix the boundary nesting comment in app-page-route-wiring.tsx — it
   incorrectly claimed the order matches Next.js's
   Error > Loading > AccessFallback > Redirect > content. The actual
   vinext order is Error > AccessFallback > Loading > Redirect > content;
   the AccessFallback positioning is a pre-existing divergence.

2. Add test for the error.handled branch in RedirectErrorBoundary —
   verifies the Next.js parity placeholder returns null state when a
   redirect error is already marked as handled by an outer boundary.

* refactor: use shared header constants instead of string literals

Replace two hardcoded header strings in the visited-response cache
snapshot with the already-imported VINEXT_MOUNTED_SLOTS_HEADER and
VINEXT_PARAMS_HEADER constants from server/headers.ts.

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: James <james@eli.cx>
2026-05-12 16:48:15 +00:00
Nathan Nguyen ac75d43e7e fix(app-router): preserve UI during action refresh (#1147)
* fix(app-router): preserve UI during action refresh

Server action responses currently always render a page payload, and router.refresh can put BrowserRoot into a pending state that mounts loading.tsx while the same route refresh resolves. That diverges from Next.js, where non-revalidating actions can skip page rendering and refresh/revalidation should preserve the current page UI.

The server action path now omits the root payload when no data was invalidated, suppresses loading boundaries for action rerenders, and carries the same suppress-loading variant through RSC request, cache-busting, and ISR cache keys. The browser action callback now resolves no-root action payloads directly and avoids pending router state for refresh.

Regression coverage includes the no-rerender action payload, loading-boundary suppression, cache key variants, cache-busting compatibility, request normalization, and an app-router Playwright case that catches route loading mounts after action plus router.refresh.

* fix(app-router): carry semantic RSC render modes

Replace the cross-layer suppress-loading boolean with AppRscRenderMode so browser requests, request normalization, rendering, cache-busting, Vary, and ISR keys all carry the same semantic payload variant.

Decode server action responses before clearing client navigation caches so no-root return-only action results keep visited response and prefetch caches intact while redirects, root payloads, and legacy bare payloads still invalidate.

Add coverage for refresh() inside server actions and for normal versus preserve-current-UI ISR RSC cache keys.

* fix(app-router): default helper render mode to navigation

* docs: add review-requested comments for structural discriminator and ISR key format

* fix: add explicit null case to parseAppRscRenderMode switch for exhaustiveness check

---------

Co-authored-by: James <james@eli.cx>
2026-05-12 15:09:45 +01:00
Nathan Nguyen 11cc64a3af fix(app-router): preserve useActionState form state after hydration (#1162)
* fix(app-router): preserve useActionState form state after hydration

Progressive multipart server actions executed before the initial HTML render were dropping React form state. That meant useActionState hydrated from the JavaScript initializer instead of the server action result after a no-JS form submission.

The progressive action path now decodes React form state, carries it through the App Router render pipeline, passes it to React's server and client hydration APIs, and marks form-state HTML responses as uncacheable so action results cannot be replaced by stale ISR HTML.

Adds targeted unit coverage for action decoding, SSR forwarding, cache policy, cache read bypass, and a Playwright no-JS useActionState regression.

* fix(app-router): keep progressive action result type internal

Knip reported ProgressiveServerActionResult as an unused exported type. The type is only part of this module's implementation detail for the progressive action return path, so keep it module-local.

* fix(app-router): separate progressive action cache bypass

* chore(app-router): tighten form state bootstrap wiring
2026-05-12 14:25:30 +01:00
Nathan Nguyen 04140e47a3 feat(app-router): promote same-layout ancestor persistence (#1167)
* feat(app-router): promote same-layout ancestor persistence

App Router soft commits previously preserved absent non-slot entries through the merge layer and kept root-layout hard-navigation policy split between browser state and planner. That made same-layout ancestor persistence implicit and allowed unknown root identity to behave like proof of reuse.

The planner now returns explicit preserveElementIds for proven same-root transitions, browser state stores layoutIds from wire metadata, and visible commits preserve only planner-approved ancestors while leaving parallel-slot persistence semantics intact.

Adds coverage for layout id metadata, unknown-root fallback, stale element removal, and visible layout persistence.

* fix(app-router): validate layout persistence proofs

App Router payload metadata treated every string in __layoutIds as planner preservation authority. That let a bad producer, stale payload, or future refactor pass a page, slot, or malformed key as proof for layout ancestor reuse.

The violated invariant is that layout persistence proof must be validated before it reaches the commit planner. Parse __layoutIds through the AppElements wire-key codec and accept only layout keys.

Also add dynamic segment browser coverage for /blog/[slug] navigation, proving param-sensitive layout and page content update while the layout client counter persists.

* fix(app-router): scope boundary layout proofs

HTTP access fallback payloads derived layout metadata from the full route definition. Layout-level access fallbacks can render only ancestor layouts, so the old proof could let the planner preserve child layouts that were absent from the response.

Derive boundary layout metadata from the rendered layout module subset and mark the root boundary unknown when no route layout is rendered.

* docs(app-router): document layout id ordering

Document the ancestor-first layoutIds invariant at the planner snapshot boundary so future payload producers do not break same-layout prefix persistence.
2026-05-12 14:19:21 +01:00
Nathan Nguyen 826bab4530 feat: implement next web vitals hook (#1161) 2026-05-11 17:15:12 +01:00
Nathan Nguyen 07be32258f fix(router): preserve filesystem routes before afterFiles rewrites (#1166)
* fix(router): preserve filesystem routes before afterFiles rewrites

afterFiles rewrites were evaluated before App and Pages filesystem route matches in several runtime paths. That let a rewrite override an existing non-dynamic page, which diverges from Next.js route ordering.

The fix checks the page/app match first and only applies afterFiles rewrites when no non-dynamic route wins, while still allowing afterFiles to run before dynamic routes. Regression coverage exercises App RSC handler, Pages dev/prod, and generated Worker wiring.

* test(router): align chained afterFiles rewrite expectations

The chained rewrite fixture expected an afterFiles rewrite to override a concrete /intermediate page. That is the route-ordering behavior this branch fixes.

Update the fixture to cover both intended contracts: middleware can chain into afterFiles when no page file wins, and concrete page files are not overridden by afterFiles rewrites.

* refactor(router): remove route-ordering type assertions

Validate generated Pages route metadata at the boundary instead of asserting its shape, and make the app handler test fixture route matching explicit.

* test(router): cover afterFiles order in Pages Worker

Add a built Cloudflare Pages Router regression where a concrete page route must win before an afterFiles rewrite. Align custom Pages Worker entries with the generated worker route-match gate.
2026-05-11 17:13:51 +01:00
Nathan Nguyen 9d5d1ce50d fix(app-router): validate RSC cache-busting params (#991)
* fix(app-router): validate RSC cache-busting params

App Router RSC responses could vary by request headers while client fetches and server validation still relied on Vary-aware caches. CDNs that key only by URL could reuse a mounted-slot or interception-context RSC payload for a different request.

Next.js validates a cache-busting _rsc search param against the RSC request headers and redirects malformed requests to the canonical URL. Vinext now applies the same invariant to its .rsc request model, centralizes the RSC Vary header set in normal server modules, and de-duplicates middleware Vary additions.

Tests cover missing and stale _rsc values, variant-header hashes, generated-entry delegation, browser RSC request markers, response Vary propagation, and App Router RSC integration.

* fix(app-router): hide RSC cache tokens from navigation URLs

* fix(app-router): hide RSC cache token from searchParams

* fix(app-router): address RSC cache-busting review

* fix(app-router): preserve HTML redirect destination for direct .rsc navigations

Config redirects from next.config.js were unconditionally converted to
.rsc destinations for any request whose URL ended in .rsc. This broke
direct browser navigations to .rsc URLs (e.g. a bookmark or external
link) because the browser was redirected to an RSC payload it could not
render.

Client-side RSC fetches always send the RSC: 1 header, so restrict the
.rsc destination conversion to requests that carry that header. Direct
browser hits without the header continue to redirect to the canonical
HTML URL, matching the pre-PR behaviour for config redirects.
2026-05-04 18:55:43 +01:00
James Anderson fffda16106 feat(dev): React-based runtime error overlay (#1029)
Surfaces three error sources that previously only reached the console:
React render errors caught by an error.tsx boundary (onCaughtError),
render errors with no boundary above them (onUncaughtError), and plain
script errors / unhandled promise rejections via window listeners.

The overlay renders into a separate createRoot mounted on a detached div
appended to body, so it survives an unmount of the main hydrateRoot tree
— necessary because most errors we want to surface take that tree down.

Includes a /dev-overlay-test fixture and Playwright spec covering all
three error sources, dismiss, and prev/next pagination across multiple
errors.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 18:12:03 +01:00
James Anderson 2cf21433cb fix(routing): mirror inherited parallel slots to descendant sub-pages (#1039)
* fix(routing): mirror inherited parallel slots to descendant sub-pages

discoverInheritedParallelSlots was hard-coding pagePath to null for any
ancestor slot, so inherited slots only ever rendered default.tsx — even
when an obvious mirror like @breadcrumbs/about/page.tsx existed for the
matching route. Track each ancestor's segment index, then look up a
mirror via either a literal filesystem match or, when route groups appear
on either side, by URL-parts comparison through convertSegmentsToRouteParts.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(routing): extract slot-specific params for inherited mirrors

When an inherited parallel slot's mirrored sub-page declares dynamic
markers under different names than the route's (e.g. slot uses
@breadcrumbs/distinct/[name] for a route at distinct/[id]), the runtime
was passing the route's matchedParams through unchanged — so the slot
page received {id} instead of {name} and rendered with an empty value.

Three pieces:

* `findMirroredSlotPage` now also accepts pattern-compatible matches and
  returns the slot's URL parts and param names. Specificity scoring
  picks the most-specific compatible sub-page (literal > dynamic >
  catch-all).
* Each ParallelSlot carries optional `slotPatternParts` and
  `slotParamNames`, serialized through the manifest.
* `buildPageElements` extracts a per-request `slotOverrides[key].params`
  by matching the request URL against `slotPatternParts` whenever a
  slot's param names aren't a subset of the route's. `pageModule` on
  `AppPageSlotOverride` becomes optional so a params-only override
  doesn't have to swap the slot's page.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(routing): address bonk review on inherited parallel slot mirror

* Swap inverted scoring for required vs optional catch-alls — `:name+`
  (matches ≥1 segments) is more constrained than `:name*` (≥0), so it
  now scores higher, preventing a less-specific optional catch-all from
  shadowing a required one when both compete for the same route.
* Tighten findMirroredSlotPage docstring: the implementation collapses
  exact URL-parts equality and pattern compatibility into one scored
  loop, where literals naturally win. Rewrite the comment to reflect
  what the code actually does.
* Document patternsCompatible's conservative rejection of literal slot
  segments paired with dynamic route segments (and the consequence that
  a literal slot sub-page never matches a catch-all route).
* Note in buildSlotOverrides why an empty slotParamNames also skips the
  override (no dynamic markers means nothing to extract).
* Add unit tests for two cases that were only covered indirectly:
  graph-level slotPatternParts/slotParamNames when route and slot use
  different param names, and a slot owned at an intermediate ancestor
  rather than appDir.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 18:02:20 +01:00
James Anderson aba335d5f3 fix(dev): self-host Google Fonts in dev mode (#1038)
The build pipeline already rewrites `next/font/google` calls to embed
self-hosted @font-face CSS pointing at `/<assetsDir>/_vinext_fonts/...`,
but the call-site rewrite was gated behind `isBuild`. Dev requests for
those URLs 404'd because nothing copies the cached `.vinext/fonts/`
files into the dev server's served paths, and once `_selfHostedCSS` is
present the shim no longer emits the fonts.googleapis.com `<link>` —
so dev showed unstyled glyphs with no CDN fallback.

Drop the build-only gate and add a `configureServer` middleware that
serves the cached files directly under the same URL prefix the
@font-face CSS embeds. Reuse the prod `CONTENT_TYPES` map so dev and
prod hand out identical MIME types.

Regression spec navigates to a new `/font-google-test` route in
app-basic that uses Geist + Geist_Mono, asserts no CDN URL leaks into
the HTML, and fetches a preload URL to verify the dev middleware
returns a real font/woff2 body.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 17:49:33 +01:00
Nathan Nguyen da4d0b232c fix(app-router): preserve falsy thrown values in error boundaries (#1000)
App Router error boundaries treated the stored error value itself as the presence flag. That makes thrown values like undefined, null, 0, an empty string, or false indistinguishable from an empty boundary state, so the children can render again instead of the segment fallback.

The boundary state now stores either null or a wrapper containing the thrown value, and route error component typing admits unknown values to match React and Next.js behavior. The regression coverage checks both the state transition contract and a client component route that throws every falsy literal.
2026-05-01 23:34:26 +01:00
Nathan Nguyen cf5471e95a fix(app-router): fixing cache request leaks (#907) 2026-04-29 14:59:14 +01:00
Divanshu Chauhan (divkix) f972d45034 fix: resolve symlink paths in standalone BFS package copy (#724) (#942)
* fix: resolve symlink paths in standalone BFS package copy (#724)

When resolvePackageJsonPath finds a package via the lookup-paths
fallback, the returned path may be a symlink (e.g., under a hoisted
node_modules/ directory). Calling createRequire() on a symlink
path causes Node module resolution to miss transitive dependencies
because it walks up from the symlink ancestor directories rather
than the real (e.g., .pnpm/) path.

Fix: call fs.realpathSync() on the resolved package.json path
before using it for createRequire() and readPackageJson().

Also adds E2E test coverage for the standalone output mode.

* fix: address review feedback

Add standalone-output to CI E2E matrix, rename server header test
to reflect what it actually asserts, fix unused req parameter.
2026-04-28 21:19:09 +01:00
Nathan Nguyen 0b6eee2221 fix(app-router): restore hash anchors on history traversal (#929)
* fix(app-router): restore hash anchors on history traversal

App Router hash-only entries can be pushed without saved vinext scroll state while vinext has browser scroll restoration set to manual. On forward traversal, the URL moves back to the fragment but the browser no longer performs native anchor scrolling, so the page can remain at the top.

Restore saved numeric scroll state first, then fall back to Next-compatible hash target scrolling when no vinext scroll state exists. The regression test drives a hash-only Link entry through back and forward traversal.

* test(app-router): cover hash scroll edge cases
2026-04-28 12:31:40 +01:00
Nathan Nguyen e9c4fb75a3 test(app-router): stabilize RSC fetch error redirects (#925)
The RSC fetch error E2E used direct private navigation calls and Playwright route mocking for a redirect chain. Those assumptions are timing-sensitive and can skip the intended RSC request under parallel Playwright runs.

Drive the cases through fixture links and serve the redirect-to-500 chain from middleware so the test observes real browser fetch behavior. This keeps coverage for hard navigation to the post-redirect URL while removing the route redispatch dependency.
2026-04-28 12:03:10 +01:00
Nathan Nguyen 18962c51d6 fix(app-router): action redirects (#910)
* Fix app form action redirects

* Trigger CI rerun

* Refactor progressive action handling

* Address app action review feedback

* Trigger CI rerun
2026-04-27 20:40:03 +01:00
Nathan Nguyen 67a929bf25 Preserve middleware headers on app boundary responses (#908)
* Preserve middleware headers on app boundary responses

* test: stabilize app router redirect e2e
2026-04-26 17:51:02 +01:00