Commit Graph

159 Commits

Author SHA1 Message Date
Nathan Nguyen a3d2f92152 fix(router): honor hybrid pages route priority (#1997)
* fix(router): honor hybrid pages route priority

* fix(router): share hybrid owner decision with client navigation

PR #1997 fixed the server-side route ownership for direct document loads,
but the same invariant broke for client-side soft navigations and the
matching prefetch path:

- navigateClientSide() always delegated to the App runtime's RSC fetch,
  even when the Pages route had higher priority. renderPagesFallback()
  short-circuits RSC requests with null, so the App catch-all won.
- prefetchUrl() prefetched an RSC stream for any URL that matched an App
  route, again ignoring Pages ownership.
- The Pages entry was loaded on every hybrid request, even when a static
  App route had already matched (Pages can never win in that case).

Expose the Pages route manifest on the client via a new
__VINEXT_PAGES_LINK_PREFETCH_ROUTES__ window global emitted by both the
App and Pages browser entries. The link shim consults a shared
resolveHybridClientRouteOwner helper that mirrors the server-side
pagesRouteHasPriorityOverAppRoute comparison. When Pages owns the URL,
the click handler issues a window.location navigation and the prefetch
path returns early.

Gate the renderPagesFallback call behind a static-App-route check in
handleAppRscRequest: when a static App route matches, the bridge
cannot win, so skip the eager Pages entry load. Centralise the
comparison in a new resolveHybridRouteOwner helper so server and
client reach the same answer for the same (URL, route pair).

Adds the missing client-navigation coverage to the use-params e2e
fixture (Link from /app/ to /pages-dir/foobar) and unit tests for
the shared owner decision.

* fix(router): mirror server hybrid owner decision on the client

PR review flagged two split-brain bugs in the previous hybrid
ownership fix: a hand-copied client comparator and a Link-only
ownership gate that left programmatic App Router navigations on
the wrong path.

The hand-copied routePrecedence in hybrid-client-route-owner.ts
omitted the static-prefix reduction that lives in
routing/utils.ts#routePrecedence, and used a strict-less-than
comparison that returned App for identical dynamic patterns. The
server returns Pages for both cases (Pages providers sort ahead
of App providers, and routePrecedence subtracts 50 per static
prefix segment). The split produced a real ownership disagreement
on overlapping patterns like /_sites/:slug* (Pages) vs /:slug*
(App).

Add a shared compareHybridRoutePatterns to routing/utils.ts as
the single source of truth: the static/dynamic short-circuits plus
a sortRoutes call (which carries the static-prefix reduction and
the Pages-first equal-pattern tiebreak). The server
pagesRouteHasPriorityOverAppRoute and the client
resolveHybridClientRouteOwner both delegate to it, so they
cannot diverge. Drop the hand-copied routePrecedence entirely.

Wire the ownership check at the App navigation runtime boundary
so useRouter().push, useRouter().replace, gesturePush, and form
submits all get the same hard-nav contract as the Link click
handler. Specifically:

- navigateClientSide: after same-origin normalization, if Pages
  owns the URL, hard-navigate via window.location and return
  (matching the existing external-URL branch).
- _appRouter.prefetch: short-circuit RSC URL construction for
  Pages-owned targets so we do not warm an unusable cache entry.

Centralise the existing two inline hard-nav branches in
navigateClientSide into a hardNavigateTo helper for clarity.

Tests:

- Direct unit tests for compareHybridRoutePatterns covering
  identical-dynamic tiebreak, static-prefix dynamic overlap,
  static-prefix catch-all overlap, and infix-static bonus.
- Direct unit tests for resolveHybridClientRouteOwner mirroring
  the server assertions plus a basePath-stripping test.
- e2e: useRouter().push('/pages-dir/foobar') from an App page
  resolves to the Pages document.
- e2e: useRouter().prefetch('/pages-dir/foobar') issues zero
  RSC requests for the target URL.

* docs(router): fix stale score in static-prefix catch-all test comment

The hand-copied comparator note quoted the dynamic-segment scores
(51 / 1000) for the optional-catch-all example (/_sites/:slug*
vs /:slug*). The current optional-catch-all scoring actually
produces 1951 vs 2000. The test assertion was correct; only the
comment was stale.

* test(use-params): fix direct-load single dynamic param to use /a instead of /a/b

* fix(router): compare hybrid routes structurally

* fix(router): reject hybrid route conflicts

* fix(router): refresh hybrid route ownership

* fix(router): preserve hybrid routing lifecycle

* fix(router): recheck pages routes after rewrites

* fix(router): preserve rewritten pages queries

* fix(router): preserve rewritten route ownership

* fix(router): resolve client rewrites sequentially

* fix(router): apply rewrite phases sequentially

* fix(router): preserve rewrite params and endpoints

* fix(router): hand off endpoint navigations

* fix(router): preserve rewrite fragment params

* test(e2e): avoid hybrid fixture route conflicts

* test(pages): await async config validation

* test(hybrid): avoid duplicate page fixtures

* chore(router): clarify hybrid priority semantics

* refactor(router): remove duplicate app route matcher

* docs(router): clarify client hybrid comparator

---------

Co-authored-by: James <james@eli.cx>
2026-06-15 00:22:00 +01:00
Nathan Nguyen 10b5086f4d fix(app-router): align router autoscroll with Next (#2004)
* fix(app-router): align router autoscroll with Next

App Router navigation could diverge from Next.js router autoscroll semantics around hydration timing, loading-shell same-page search navigations, and routes whose first committed node is React-hoisted into head. That broke the upstream router-autoscroll deploy-suite because the client marked hydration too early, static loading-shell templates were not usable for navigation, and the fallback scroll path masked the old-handler hoisted-head behavior.

The fix moves the Next-compatible hydration marker into a passive effect, wires production hydrateRoot error callbacks to match Next's implicit root-boundary and recoverable-error handling, lets authoritative loading-shell templates drive static route optimistic payloads, and skips fallback document scrolling when the committed document only exposes a React-hoisted head resource. The ported tests cover the upstream router-autoscroll behavior plus focused unit boundaries for prefetch mode, optimistic routing, head-resource detection, and hydrateRoot callbacks.

* fix(app-router): hoisted-head scroll fallback is per-intent, not global head scan

* test(app-router): add full-chain scroll intent integration test

* fix(app-router): match legacy scroll focus parity

* fix(app-router): preserve latest scroll navigation intent

* fix(app-router): scope scroll and error recovery

* fix(app-router): gate navigation failure recovery

* fix(app-router): match navigation failure handling

* fix(app-router): align scroll completion parity

* fix(app-router): retain latest failure target

* fix(app-router): preserve route error boundaries

* fix(app-router): clear discarded failure targets

* fix(app-router): align failure recovery boundaries

* fix(app-router): disarm refused recovery targets

* fix(app-router): preserve failure target ownership

* fix(app-router): match boundary recovery parity

---------

Co-authored-by: James <james@eli.cx>
2026-06-14 23:59:28 +01:00
Nathan Nguyen 47b38a91f3 fix(app-router): recover SSR shell render errors via __next_error__ document (#1908)
* fix(plugin): resolve vinext/shims/* package subpaths to local shim files

Runtime helper modules embedded into generated entries import vinext's
own shims by package subpath (e.g. `vinext/shims/headers`), while source
checkouts alias userland `next/*` imports to the local shim files. The
two specifiers resolved to different module instances, so request-scoped
singleton state (navigation context, headers) split between the source
shim copy and the package export copy.

The violated invariant is that every shim module must be a per-request
singleton regardless of import specifier. Resolve `vinext/shims/*`
through the same plugin path as the `next/*` aliases so both forms land
on the local shim files.

Exercised by the SSR shell-error recovery browser spec, whose fixture
imports vinext from the source checkout and depends on shared
navigation state across both import forms.

* fix(app-router): recover SSR shell render errors via __next_error__ document

When the HTML (Fizz) render rejects during SSR, vinext re-rendered a
server-side global-error page whose flight payload encodes the error
tree. For an app without a custom global-error.tsx that meant the
default error card with no path back to the real page: an SSR-phase-only
throw (e.g. a client component using the "throw to opt out of server
rendering" pattern) left the browser stuck on the card even though the
client render would succeed.

The violated expectation is Next.js's shell-error semantics: a failed
HTML shell is served as the default `__next_error__` error document
carrying the ORIGINAL flight payload and the bootstrap module, and the
browser re-renders the real tree from that payload with createRoot
instead of hydrating. Local error.tsx boundaries still win — they ship
inside the flight payload and catch the re-thrown error client-side.

handleSsr now resolves to that recovery document instead of rejecting,
but only when both hold:
- the error did not originate in the RSC render (no string `digest`),
  so flight errors, redirect()/notFound(), and server-component throws
  keep driving the existing rejection-based boundary machinery, and
- the app has no custom global-error.tsx (the generated entry knows at
  build time and threads hasCustomGlobalError through dispatch/render
  options); apps with one keep the server-rendered boundary re-render.

The browser entry switches from hydrateRoot to createRoot when the
document root carries id="__next_error__", dropping the error-shell
styles first.

Covered by the new ssr-error-shell-recovery browser spec (recovery to
real content, local error.tsx for SSR-only and unconditional client
throws) and the existing tests/nextjs-compat/global-error.test.ts
boundary-semantics suite.

* refactor(review): address PR 1908 non-blocking notes

- Add static prerender no-boundary recovery regression test to
  ssr-error-shell-recovery.browser.spec.ts
- Document broad __next_error__ browser marker in app-browser-entry.ts
- Extract stripJsExtension to utils/path.ts and wire into all shim
  resolution sites (vinext/shims/* + react-server shims)

Non-functional: targeted regression coverage + code documentation
+ minor resolver hardening per reviewer feedback.

* refactor(review): clarify shell recovery assumptions

* fix(ssr): cancel abandoned prerender streams

* refactor(ssr): clarify error shell root options

* fix(isr): recover shell errors during regeneration

* fix(ssr): scope client recovery to marked shells

* fix(plugin): explicitly filter vinext shim subpaths

* fix(plugin): retain null-prefixed shim resolution

* test(nextjs-compat): cover no-boundary shell recovery fallback

An unconditional client throw during SSR shell recovery without a local error boundary must still land on the default global-error card. Without a regression test, a future change could tear down the recovery shell and leave a blank document.\n\nAdd a production browser case that exercises the no-boundary route and asserts the default error UI after the client re-render throws again.

* fix(app-router): preserve shell recovery error semantics

* test(app-router): harden shell recovery cache semantics

* fix(app-router): mark global error responses uncacheable

* test(app-router): align error response status expectations

* fix(build): preserve null-prefixed og resolution

* fix(cache): delegate cache header cleanup to adapters

---------

Co-authored-by: James <james@eli.cx>
2026-06-14 22:28:25 +00:00
Nathan Nguyen 930e03c449 fix(ppr): gate fallback shells until request-time resume is supported (#1716)
* fix(app-router): prerender cacheComponents root-param fallback shells

PR 1 of 4: core model + build integration.
Model: app-ppr-fallback-shell, pregenerated-concrete-paths, prerender-manifest
Build: prerender/run-prerender fallback shell artifact generation
Tests: createAppPprFallbackShells, pregenerated-concrete-paths core

* feat(ppr): add fallback shell payload identity

* feat(ppr): safely serve fallback shell cache entries

* refactor(ppr): extract fallback shell render pipeline and dedupe getViteMajorVersion

* fix(ppr): restore safe-serving CI checks

The safe-serving branch failed CI because a client-side server-only guard rejected valid 'use server' action modules, a fallback-shell dependency cast no longer matched its helper type, apps/web lost the Cloudflare cache adapter source path during typecheck, and Knip could not resolve the documented Cloudflare cache adapter subpaths.

The server-only guard now reads the directive prologue and skips top-level 'use server' modules, fallback-shell regeneration passes an explicit typed dependency object, apps/web gets the workspace Cloudflare cache path, the Cloudflare package exports explicit adapter subpaths, Knip uses a relative source import in the dispatch unit test, and the readiness test awaits the actual cache-ready promise.

* fix(ppr): guard fallback shell reads for queries

* fix(ppr): normalize pregenerated concrete paths

* fix(ppr): address review comments on fallback shell serving and react runtime check

* fix(ci): restore knip ignores for unlisted binaries and prune dependencies

* fix(ppr): restrict bracket check in fallback detection to legacy manifests

* fix(ppr): prefer stable ESM react-dom/static.edge before CJS fallback

The loadStaticPrerender dev-mode path hard-codes react-dom's internal
CJS file layout (cjs/react-dom-server.edge.development.js). A React
upgrade could silently break fallback-shell rendering in dev without
affecting production because the function never tried the stable ESM
entry in development.

Change the order so that react-dom/static.edge is attempted first in
all environments. Only fall back to the CJS path when the ESM entry
does not export prerender() and we are in a development runtime. This
lets future React dev builds that expose prerender() from the ESM entry
work automatically, and it surfaces the CJS-path failure only when the
stable path is genuinely unavailable.

Also handle the CJS default-export interop shape that Node.js ESM
produces when dynamically importing a CommonJS module.

* refactor(ppr): address four review cleanup items

1. Remove redundant double-normalization in seed-cache.ts
   addPregeneratedConcretePath already normalizes internally, so the
   call-site normalizePregeneratedPathname() was redundant. Dropping it
   gives a single source of truth for pathname normalization.

2. Lazily construct FallbackShellRenderDeps in tryServePprFallbackShell
   Extract the large FallbackShellRenderDeps object into a top-level
   buildFallbackShellRenderDeps helper. The closure in the probe loop
   now just delegates, so the heavy object is only created when a STALE
   entry actually triggers regeneration, not on every HIT probe.

3. Add dev-only warning for legacy bracket heuristic
   When isFallbackShellArtifactPath falls back to the bracket substring
   scan (route.fallback === undefined), emit a console.warn in dev so
   developers know a legacy manifest is being used and concrete URLs
   with literal brackets may be misclassified.

4. Update pregenerated-concrete-paths.test.ts to colon notation
   The production manifest writer uses colon notation (/:slug) for the
   route field, but the test fixtures used bracket notation (/[slug]).
   Update the fixtures so the test format matches production, exercising
   the real key format that the safe-serving guard depends on.

* test(ppr): compact fallback shell dispatch fixtures

* refactor(ppr): name fallback shell render phases

* refactor(app-router): deduplicate PPR fallback shell and app page cache render paths

* fix(ppr): avoid serving incomplete fallback shells

* chore: drop redundant cloudflare cache aliases

* fix(ppr): track fallback navigation hooks

* test(ppr): render navigation hook probes

* test(ppr): type navigation hook probes

* refactor(ppr): remove unused shell regeneration path

* refactor(ppr): remove unused shell result type

* fix(ppr): safely gate fallback shell prerendering

* fix(ppr): keep fallback shell gate internal

* fix(ppr): mark generated dynamic fallback shells

* test(ppr): align fallback fixture and search params

---------

Co-authored-by: James <james@eli.cx>
2026-06-14 00:44:54 +01:00
Nathan Nguyen 4baf85ad5e fix(router): restore pages scroll traversal state (#1999)
* fix(router): restore pages scroll traversal state

* fix(router): preserve pages component identity across HTML fallback

Pages Router HTML fallback could import the module URL from fetched __NEXT_DATA__ even when the generated page loader already existed. In Vite chunk graphs that can evaluate a duplicate page or router module, same-route param navigations remounted the page and lost listener state before routeChangeComplete.

Store the router wrapper component types on the shared browser runtime singleton, prefer registered page loaders on the HTML fallback path, and resolve navigation at the root commit boundary that Next.js awaits instead of waiting for passive effects. Non-DOM document mocks still resolve immediately so unit harnesses do not masquerade as real browser commits.

Validated with the upstream reload-scroll-backforward-restoration deploy suite and the local router-events and scroll-restoration regressions.

* test(router): simulate pages router commits in shims tests

* fix(router): move Pages Router readiness into shared runtime state

Move the pagesRouterReady bit into PagesRouterRuntimeState so duplicated
next/router module instances (client entry + page chunks) observe the same
readiness value. The initial value is still computed from
shouldDeferInitialPagesRouterReady() once per document.

Also tighten the scroll-restoration e2e fixture so routeChangeComplete
assertions cannot pass from stale page state, and add a regression test
that simulates two evaluated router instances on a deferred-ready page.

* fix(router): early-exit scroll restoration when target unreachable, document useRouter fallback

Addresses ask-bonk review feedback on PR #1999:

- restorePagesRouterScrollPosition now breaks early when window.scrollY
  stops changing across frames, avoiding the full 60-frame budget when
  the saved scroll position exceeds the rendered page height.
- useRouter() fallback path now has an explanatory comment documenting
  the non-reactive singleton return is intentional for split-chunk compat.

* fix(router): harden scroll restoration settling

---------

Co-authored-by: James <james@eli.cx>
2026-06-13 22:11:18 +01:00
Nathan Nguyen d1a80cd668 fix(app-router): preserve front redirects through fallback route handlers (#2000)
* fix(app-router): preserve front redirects through fallback route handlers

* test(e2e): assert single bootstrap hydration in front-redirect test

* chore: remove dead stripDeploymentIdQuery export

* fix(app-router): handle cancelled bootstrap streams

* docs(app-router): clarify bootstrap recovery

* test(e2e): tolerate dangling fixture symlink

* test(e2e): run front redirect regression in CI

* fix(app-router): always hide internal RSC queries

* fix(app-router): preserve visible hydration URL

* test(e2e): scope visible URL regression

* refactor(app-router): trim front redirect residue

* test(e2e): group fixture setup command

* test(e2e): tighten front redirect assertions

* fix(app-router): preserve ordinary route handler URLs

* fix(app-router): normalize edge route handler URLs

* docs(app-router): explain route URL parity

* fix(app-router): preserve Node RSC request URLs

* fix(app-router): normalize route handler RSC paths

* fix(app-router): forward RSC tokens through rewrites

---------

Co-authored-by: James <james@eli.cx>
2026-06-13 22:08:02 +01:00
James Anderson 075919357e fix(router): hard navigate Pages links to App routes (#1960)
* fix(router): hard navigate pages links to app routes

* fix(router): normalize localized app route detection
2026-06-12 22:45:19 +01:00
James Anderson 9e3cf90583 test(e2e): disable hash fixture query prefetch (#1948) 2026-06-12 13:04:10 +00:00
Nathan Nguyen e018a08708 fix(pages-router): restore scroll across reload history traversal (#1905)
* fix(pages-router): restore scroll across reload history traversal

Pages Router pages with experimental.scrollRestoration currently expose window.next.router before the popstate runtime is installed and only keep Vinext scroll coordinates in the in-memory history state. After a reload, a back or forward traversal can run before the runtime is ready and has no durable scroll snapshot for the target history entry.

The violated invariant is that the Pages Router owns browser scroll restoration whenever the Next.js scroll restoration flag is enabled, with one stable history key per entry and routeChangeComplete emitted after the new page commits.

Resolve the Next.js config flag into process.env.__NEXT_SCROLL_RESTORATION, install the Pages Router runtime during next/router evaluation, persist Next-style sessionStorage scroll snapshots per history key, and render navigations through a stable commit boundary before emitting routeChangeComplete.

Regression coverage ports Next.js reload-scroll-backforward-restoration and verifies manual restoration plus exact scroll positions across reload, back, and forward traversal.

* fix(pages-router): sequence routeChangeComplete after scroll restoration and settle on render failures

* fix(router): guard against missing window.history in stampInitialHistoryState

* fix(router): defer hash scroll until after routeChangeComplete

Move hash scrolling out of renderPagesRouterElement's commit callback
and into the post-routeChangeComplete path, matching Next.js ordering:
x/y restoration happens during render commit, hash scrolling happens
after the completion event.

Also fix E2E test expectRouteChangeComplete to use one-shot event
listeners instead of checking for stale text, and add listener cleanup
to the fixture to prevent accumulation.

* fix(test): remove post-push deadlock and throw on missing events in E2E helper

expectRouteChangeComplete() now throws if window.next.router.events is
absent instead of silently resolving. Remove the redundant
expectRouteChangeComplete call after pushWithPagesRouter in the third
test — the awaited router.push already resolves after routeChangeComplete,
so registering a listener after it would deadlock.

* fix(test): replace as any casts with type-safe window.next narrowing in E2E scroll tests

* fix(router): narrow renderPagesRouterElement scroll param, drop dead hash branch

* fix(router): reset x/y scroll on hash routes before routeChangeComplete and cancel pending render on supersede

- Pass {x:0, y:0} through commit boundary for all scrollable
  navigations, not just hash-less ones (hash scroll deferred to after
  routeChangeComplete as before).
- Cancel pending render-commit Promise when a newer navigation starts,
  preventing hang when the first root.render() commit is dropped by
  React re-reconciliation.
- Add regression tests for both edge cases.

* fix(router): use SSR-safe layout effect in commit boundary and pin shallow scroll ordering

Address bonk review on #1905:
- swap the commit boundary's raw useLayoutEffect for the SSR-safe
  useNonWarningLayoutEffect pattern from shims/image.tsx so Pages Router
  SSR renders no longer log React's useLayoutEffect warning
- document that the default-noop commit boundary intentionally also wraps
  SSR and initial hydration
- document the intentional shallow-scroll-before-routeChangeComplete
  ordering and add a unit test pinning it

* fix(router): keep history-state scroll restore on popstate when scrollRestoration flag is off

Without experimental.scrollRestoration, popstate must still honour the
__vinext_scrollX/Y saved into history state on push, since a soft popstate
re-renders content the browser's native restoration can't position. Also
document why the popstate-time sessionStorage snapshot depends on
history.scrollRestoration = "manual".

* fix(router): guard render-commit canceller against late commits from superseded trees

* fix(router): defer current-history-key commit until beforePopState allows navigation

When beforePopState cancels a popstate the app stays on the previous
history entry, but _currentHistoryKey had already been updated to the
target key, skewing subsequent scroll-position bookkeeping. Move the
key commit below the beforePopState early-return, matching the
deliberate deferral of _lastPathnameAndSearch.

* chore(router): note intentional eager scroll snapshot; fix fixture link increment

- Document that the outgoing-entry scroll snapshot is deliberately written
  before the beforePopState gate: on cancellation the app stays on
  currentKey, so the saved value remains that entry's live scroll.
- Coerce id to a number in the pages-scroll-restoration fixture Link so
  the href increments numerically instead of string-concatenating.

* fix(router): keep manual scroll restoration opt-in Pages-Router-only

Gate installManualScrollRestoration on the document not being an App
Router one (window.next.appDir === true): with the now-global
__NEXT_SCROLL_RESTORATION define, an App Router app that value-imports
next/router for compat would otherwise have its native browser scroll
restoration disabled at module eval. Upstream only flips
history.scrollRestoration inside the Pages Router constructor, which
never runs on App Router documents.

Also document the hash-only popstate path discarding the per-entry
__next_scroll_<key> snapshot as intentional upstream parity (change()'s
onlyAHashChange branch passes null to this.set and only scrollToHash
runs), and add unit coverage for both behaviors.

* ci: run pages-scroll-restoration E2E project in the CI matrix

Add the missing E2E matrix entry so the ported Next.js regression spec
(reload-scroll-backforward-restoration.spec.ts) actually executes in CI,
matching the shape of the sibling single-shard Pages Router entries.

Also document why the soft-popstate scroll fallback differs between
manual restoration on ({ x: 0, y: 0 }) and off (null -> native auto
restoration handles unstamped entries), per review.

* refactor(router): fold App Router gate into manualScrollRestoration constant

The popstate handler's sessionStorage save/read branches gated on the
module-level constant, which lacked the window.next?.appDir check that
installManualScrollRestoration used, leaving those branches relying
solely on the __N state filter to stay inert on App Router documents.
Fold the appDir check into the constant so every consumer is
Pages-Router-only by the same gate; the App Router bootstrap stamps
window.next.appDir before any client module evaluates, so the
module-eval-time check is reliable.

* test(pages-scroll-restoration): default getServerSideProps id to string "0"

Route params always arrive as strings, so the numeric 0 fallback was
inconsistent with the string values the component relies on
(Number(id) + 1 href, id === "error" check). Default to "0" for type
consistency.

* test(pages-scroll-restoration): align fixture deps and vinext import with pages-basic

Import vinext via the workspace package name instead of reaching into
dist by relative path, and declare the same react/react-dom/vinext
dependencies as the other fixtures so the fixture is self-describing
and the pnpm-lock.yaml importer entry is populated.

* fix(router): snapshot outgoing scroll on hash-only pushes

Upstream Next.js snapshots the outgoing entry's scroll into
__next_scroll_<key> inside Router.push() itself, before change()'s
onlyAHashChange short-circuit, so hash-only pushes still capture the
departed entry's position. Our hash-only push branch returned before
saveScrollPosition() ran, so a later back-popstate to that entry
restored {x: 0, y: 0} instead of the position the user had reached.

Save the outgoing scroll before updateHistory mints the new key,
matching the full-navigation push path and upstream ordering.

* docs(router): clarify manual scroll-target fallback chain precedence

* test(shims): tag scrollRestoration App Router test as eval-order guard

* docs(router): note missing-snapshot fallback intent at forcedScroll default

---------

Co-authored-by: James <james@eli.cx>
2026-06-12 11:31:33 +01:00
Nathan Nguyen fd1023321b fix(router): support experimental gesture push (#1909)
* fix(router): support experimental gesture push

Vinext did not expose useRouter().experimental_gesturePush when Next.js apps enabled experimental.gestureTransition. Upstream gesture-transition fixtures call the method inside a held async transition, so the target route never became visible before the canonical router.push completed.

The fix resolves the config flag into the same __NEXT_GESTURE_TRANSITION define used by Next.js, exposes the optional router method only under that flag, and lets gesture navigation use the existing RSC navigation pipeline with an immediate approved visible commit. The ported e2e fixture preserves the upstream held-transition flow and target/static/dynamic assertions.

* test(router): update navigate mock assertions to expect default transition commit mode

* fix(router): address ask-bonk review comments on gesture push

- track scheduled app router navigation in experimental_gesturePush for
  parity with push/replace, so a refresh() issued in the same task skips
  its redundant re-fetch
- document the synchronous visible-commit mode's null pendingRouterState
  invariant in the browser navigation controller
- add a unit test asserting experimental_gesturePush is only attached
  when the __NEXT_GESTURE_TRANSITION define is set

* style(tests): fix formatting in shims.test.ts

* docs(router): clarify gesture push normalization and flushSync commit paths

* test(router): fix unbound-method lint in shims test and document gesture spec coverage

* docs(router): clarify gesture define boolean form and spec held-state rationale

* docs(test): note gestureTransition is fixture-wide for the gesture spec only

* fix(router): no-op experimental_gesturePush before the navigation runtime is ready

Upstream gesturePush early-returns when getCurrentAppRouterState() is null,
so a gesture dispatched before hydration is a no-op. Previously vinext fell
through navigateClientSide's non-runtime branch and performed a real history
push. Guard on the runtime's navigate function (the same readiness check
navigateClientSide uses) and document that the un-awaited
void navigateClientSide(...) deliberately matches push/replace's
fire-and-forget rejection shape.

* docs(router): note gesturePush guard/dispatch runtime reads have no TOCTOU window

* docs(test): document gesture fixture's double-navigation divergence from upstream

---------

Co-authored-by: James <james@eli.cx>
2026-06-12 03:14:24 +01:00
James Anderson d3bbc4d558 test(e2e): close page before temp prod server in remaining specs (#1928)
Same latent race fixed for inline-css.browser.spec.ts in #1927: the
temp production server was closed during teardown while the page was
still open, so idle-scheduled requests (e.g. Link prefetches via
requestIdleCallback) could hit the closed port and log
ERR_CONNECTION_REFUSED — which the consoleErrors fixture turns into a
flaky failure. Close the page first in hash-rsc-requests and
client-reference-runtime-map too.
2026-06-11 23:31:23 +00:00
James Anderson 56393aedad test(e2e): close page before prod server in inline-css fixture teardown (#1927)
The inlineCssApp fixture closed its temp production server while the
page was still open. Link prefetches are scheduled via
requestIdleCallback and could fire after the test body finished,
hitting the closed port and logging ERR_CONNECTION_REFUSED, which the
consoleErrors fixture then turned into a flaky failure at teardown.

Close the page first so no late request can target the dead port.
2026-06-11 23:26:21 +00:00
Nathan Nguyen dea4472c2f fix(app-router): include parallel slot params in useParams (#1904)
* fix(app-router): include parallel slot params in useParams

App Router navigation state previously used only the primary route match params when setting request navigation context, RSC param headers, render observations, and optimistic navigation payloads. That lost catch-all params from active parallel slot branches, so useParams() diverged from the slot page's params prop after SSR and client navigations.

Next.js derives params from the selected router tree across parallel route branches. Resolve active slot params from route metadata, merge them into the navigation params boundary, and feed that selected param set through server render and optimistic navigation payloads.

Port the upstream parallel-route-navigations fixture and cover the route graph, selected params helper, and optimistic payload boundary.

* fix(app-router): preserve canonical pathname in navigation context after middleware rewrite

* fix(app-router): resolve parallel slot params for static, revalidation, and action paths

* chore: retrigger CI

* fix(app-router): address ask-bonk review comments on parallel slot params

- Remove unused  parameter from
- Add clarifying comment on routeParams fallback being deliberate no-op
- Use  (full matched params) instead
  of narrowed  as base for slot param merge,
  preserving interception-specific extras on ISR regeneration path

* fix(app-router): use full navigationParams in server-action re-render path

The server-action re-render path was passing narrowed
actionRerenderTarget.params to resolveAppPageNavigationParams,
dropping interception-specific extras from source-route intercepts.
Use actionRerenderTarget.navigationParams (full matched params) instead,
matching the pattern used in the dispatch ISR path at
app-page-dispatch.ts:602.

Adds unit tests for the intercepted-slot and mixed intercepted +
non-intercepted slot branches of resolveAppPageNavigationParams.

* docs(router): clarify decode invariant and type cast rationale in parallel-slot params

- app-optimistic-routing.ts: add comment explaining the pre-existing
  double-decode asymmetry between route params and slot params, noting
  TODO to converge decode passes
- app-server-action-execution.ts: expand the comment on the interceptOpts
  cast to document why it's safe and what a follow-up would look like

* fix(app-router): merge slot params for fresh intercepts

---------

Co-authored-by: James <james@eli.cx>
2026-06-11 23:54:27 +01:00
Nathan Nguyen f5bee1a26f fix(app-router): preserve unstable_catchError boundaries (#1906)
* fix(app-router): preserve unstable_catchError boundaries

* test(e2e): add Next-parity test for shouldRerenderAppPageWithGlobalError (#1906)

Add a test proving that for uncaught post-shell RSC errors (with no
client boundary), the server preserves the original HTML stream and the
client renders global-error. This is the Next.js behavior that justifies
keeping shouldRerenderAppPageWithGlobalError unconditional at false.

The server cannot detect userland client boundaries like
unstable_catchError from the route manifest, so the only safe way to
get a global-error rerender is to make a targeted error-boundary test.

The test uses a loading.tsx boundary so the error is truly post-shell,
not caught by the pre-shell error-recovery catch block.

* fix(error): restore sentinel wrapper for falsy thrown values in unstable_catchError

Restores the _CatchErrorState sentinel wrapper ({ thrownValue: unknown }) so
falsy thrown values (null, undefined, 0, "") are routed to the fallback
instead of being treated as no error. Also restores ErrorInfo.error as unknown.

Adds back unit tests for falsy values, class-component lifecycle, and
unstable_retry behavior parity with the component-rendered fallback shape.

Refs: #1906

* fix(error): align ambient ErrorInfo.error type with runtime implementation

* refactor(app-router): remove shouldRerenderAppPageWithGlobalError dead code

The function always returned false and its _options parameter and
ShouldRerenderAppPageWithGlobalErrorOptions type were vestigial.
The call site at app-page-render.ts:892-904 was therefore never taken.

Remove the function, type, import, call site, and the now-redundant
unit test, leaving the shell-level recovery path in
renderAppPageHtmlStreamWithRecovery as the sole error recovery seam.

Addresses ask-bonk review comment on #1906.

* fix(test): restore typed globalAny view and document Fallback as any intent

Addresses two non-blocking review findings from ask-bonk on PR #1906:

- Restored the typed `globalThis as unknown as { window?: unknown }`
  pattern for window stubs in the unstable_catchError test section,
  replacing the `(globalThis as any).window` pattern that was a type-safety
  regression from the prior rewrite.
- Added an inline comment explaining why `Fallback as any` is necessary
  in the class-component lifecycle test: the test probes the inner
  _CatchError class directly, where the fallback function is used as a
  React component type (receiving a single `{ props, errorInfo }` object)
  rather than the public API signature `(props, errorInfo)`.

* chore: trigger CI

* fix(test): rename forwardedProps to props in unstable-catchError test

Addresses ask-bonk review finding on PR #1906: the test was seeding
the inner _CatchError class with the stale key name forwardedProps,
but the production wrapper now passes props (and isPagesRouter).
Since this test only exercises the App Router unstable_retry path
(which reads neither key), behavior was unaffected, but renaming
aligns the test with the current boundary shape for future readers.

* refactor(server): remove dead routeHasLocalBoundary field

Removing shouldRerenderAppPageWithGlobalError (commit abb416b)
deleted its only reader of routeHasLocalBoundary. The field is still
declared in the options type and populated on every render, but
nothing consumes it anymore. Removes:

- routeHasLocalBoundary from AppPageRenderOptions type
- The computation in app-page-dispatch.ts
- Two test stubs in app-page-render.test.ts

Addresses ask-bonk review finding on PR #1906.
2026-06-11 23:21:52 +01:00
Nathan Nguyen b802f7e0b0 test(e2e): use Playwright fixture pattern for inline-css server lifecycle (#1914)
* fix(e2e): poll for server readiness before navigating in inline-css test

The inline-css browser test builds and serves a temporary fixture
outside Playwright's webServer config, so server readiness is not
guaranteed before page.goto() navigates. Under CI load, the browser
can connect before the server is accepting connections, causing
ERR_CONNECTION_REFUSED flakiness.

Added a waitForServer utility in tests/e2e/helpers.ts that polls the
server URL until it responds, then uses it in the inline-css test before
navigation. Uses 30s timeout with 300ms polling and 2s per-request
timeout, matching the pattern from packages/vinext/src/cloudflare/tpr.ts.

Fixes https://github.com/cloudflare/vinext/issues/1913

* fix(e2e): use Playwright fixture pattern for inline-css server lifecycle

Replace the waitForServer polling approach with Playwright's fixture
pattern (base.extend) to manage the inline-css test's server lifecycle.
This matches the pattern used in client-reference-runtime-map.browser.spec.ts.

The previous approach added a waitForServer utility to shared helpers.ts,
but startProdServer already waits for the server to listen via the
server.listen() callback, making the polling redundant. The fixture
pattern guarantees the server is ready before the test body runs and
handles cleanup in the finally block.
2026-06-11 13:11:52 +01:00
James Anderson 2e83e7f558 fix(edge-wasm): handle *.wasm?module imports in non-Cloudflare builds (#1877)
* fix(edge-wasm): handle \`*.wasm?module\` imports in non-Cloudflare builds (#1351)

In plain Node.js builds (deploy-suite / standalone `vinext start`),
Rolldown has no built-in handler for the `?module` query on `.wasm`
imports, so middleware and edge API routes that use:

  import wasm from './add.wasm?module'

would throw at bundle time.

Root cause: the `?module` query is a Cloudflare Workers / workerd
convention handled by `@cloudflare/vite-plugin`'s
`additionalModulesPlugin` (enforce:"pre"). When that plugin is absent
(e.g. after `vinext init` which emits a plain `vite.config.ts` with
only `vinext()`), no plugin resolves the query.

Fix: add a new `vinext:wasm-module-import` Vite plugin (enforce:"pre")
that, in the absence of @cloudflare/vite-plugin:

1. Intercepts `*.wasm?module` imports in `resolveId`.
2. Reads the WASM binary at load time and inlines it as base64.
3. Exports `await WebAssembly.compile(buffer)` via a virtual module —
   valid in Node.js (top-level await in ESM).

The `hasCloudflarePlugin` guard is checked inside the hook handler
(not as an array-spread condition) so it reflects the value set by
the `config` hook after plugin registration.

workerd forbids compiling WASM from bytes at runtime, so this path
intentionally never runs when @cloudflare/vite-plugin is present.

Fixes #1351.

* fix(wasm-module-import): suppress no-control-regex lint on virtual module regexes

Replace bare null-byte with unicode-escape in the two filter/replace regexes
and add oxlint-disable-next-line comments following the same convention used
in client-reference-dedup.ts. Fixes the Check job failure.

* fix(wasm-module-import): exclude @vercel/og from plugin scope, add resolveId tests

- Add importer guard in resolveId to skip imports originating from
  @vercel/og (resvg.wasm, yoga.wasm): vinext:og-font-patch converts
  those to dynamic imports with a .catch() disk-read fallback; if
  wasm-module-import intercepts them, the Node.js fallback never runs,
  the ~1.3 MB resvg WASM ships twice, and findEmittedWasmAsset dedup
  in vinext:og-assets breaks. Guard mirrors the pattern in the existing
  isVinextOgShimImporter() helper.
- Remove dead `return null` after `this.error()` (which throws); use
  `bytes!` non-null assertion to satisfy TypeScript.
- Add resolveId test suite: happy path, ?module query stripping, null
  resolver, og-collision guard (plain + virtual-prefix importer), and
  non-og pass-through.

Fixes ask-bonk findings on PR #1877.

* refactor(wasm-module-import): drop non-null assertion, document TLA and filter intent

* test(wasm-module-import): add build-level coverage, document runtime-gating assumption

* refactor(wasm-module-import): extract to plugins/, guard client environment, watch wasm file in dev

* fix(wasm-module-import): delegate target-owned modules

* refactor(wasm-module-import): share query stripping
2026-06-11 12:06:39 +00:00
James Anderson 835e6b9943 fix(app-router): suppress redirect console errors in production onCaughtError (root-layout-redirect) (#1878)
* fix(app-router): suppress redirect console errors in production onCaughtError (#1830)

When redirect() is called from a 'use client' root layout (or any client
component), React catches the NEXT_REDIRECT sentinel via RedirectErrorBoundary
and then calls the hydrateRoot onCaughtError callback. In dev mode vinext
already filtered these via devOnCaughtError, but the production path had no
onCaughtError at all — React's built-in fallback calls console.error, producing
a spurious browser console error that fails the Next.js compat test assertion
`expect(foundErrors).toBe(false)`.

Fix: add prodOnCaughtError to app-browser-error.ts that silently drops
navigation signal errors (NEXT_REDIRECT, NEXT_NOT_FOUND, NEXT_HTTP_ERROR_FALLBACK)
and logs all other caught errors, preserving React's default behavior.

Add fixture tests/fixtures/root-layout-redirect/ (dedicated 'use client' root
layout with redirect()) and Playwright project root-layout-redirect (port 4184)
with spec porting the Next.js root-layout-redirect > should work using browser.

Part of #1830

* fix: include result/ page in root-layout-redirect fixture

* fix: add lockfile entry for root-layout-redirect fixture, use prod server, import isNavigationSignalError

Three fixes to unblock catastrophically-failing CI:

1. pnpm-lock.yaml: Add missing importer entry for
   tests/fixtures/root-layout-redirect. The fixture was added without
   updating the lockfile, causing ERR_PNPM_OUTDATED_LOCKFILE on every
   CI job during `vp install`.

2. playwright.config.ts: Switch the root-layout-redirect server from
   `npx vp dev` (dev mode) to `vinext build + vinext start` (production
   mode). The fix under test — prodOnCaughtError — is only active in
   production builds; running in dev mode exercised the pre-existing
   devOnCaughtError path and never touched the new code.

3. app-browser-error.ts: Replace the inline isNavigationSignalDigest
   helper with an import of isNavigationSignalError from
   utils/navigation-signal.ts. The module is dependency-free and
   already in the production browser bundle; inlining it reintroduced
   the drift risk that navigation-signal.ts was created to prevent.

Addresses ask-bonk findings: blocking (test ran in dev mode) and
maintainability (inline classifier duplication).

* test: add prodOnCaughtError unit tests, run root-layout-redirect e2e in CI, scope gitignore negation
2026-06-11 01:03:03 +01:00
James Anderson 9194cb55c6 fix(pages-router): hard-navigate to App Router destinations from Pages Router links (#1879)
* fix(pages-router): hard-navigate to App Router destinations from Pages Router links

When a Pages Router <Link> targets an App Router route (detected via the
prefetch manifest and recorded as `components[path] = { __appRouter: true }`),
`performNavigation` now checks for that marker and immediately calls
`window.location.assign/replace` instead of attempting a Pages Router SPA
fetch cycle that cannot succeed.

Mirrors Next.js router.ts:1448-1453. Fixes the deploy-suite test
`pages-to-app-routing > should work using browser`.

- packages/vinext/src/shims/router.ts: add __appRouter hard-nav guard
- tests/fixtures/app-basic/app/about/page.tsx: add id="app-page" to <h1>
- tests/fixtures/app-basic/pages/pages-to-app/[slug].tsx: new GSSP fixture page
- tests/e2e/app-router/pages-to-app-routing.spec.ts: new Playwright regression test

* fix(pages-router): replace no-explicit-any cast with in-operator type narrowing

`(getPagesRouterComponentsMap()[_appPath] as any)?.__appRouter` triggered
the `no-explicit-any` lint rule. Replace with a two-step lookup that uses
the `in` operator so TypeScript narrows the union type
`{ __appRouter: true } | Record<string, unknown>` without an `any` cast.

* fix(pages-router): normalize trailing slash in __appRouter marker key, rename locals, pin fast-path test

- Strip trailing slash on both the write side (markAppRouteDetectedOnPrefetch)
  and the read side (performNavigation) so the marker key agrees when
  trailingSlash:true adds a slash to the prefetch URL but not to the nav URL
  (or vice-versa via router.prefetch with a raw un-normalised path).
  Mirrors Next.js: removeTrailingSlash(removeBasePath(pathname)) at router.ts:1442.
- Rename _appPath/_appPathEntry to appPath/appPathNorm/appPathEntry (they are
  used, not ignored; the underscore prefix was misleading).
- Add two new unit assertions in pages-router-app-prefetch-detection.test.ts:
  one pins that the marker key is always stored without trailing slash, and one
  pins that performNavigation calls window.location.assign (the fast-path) when
  the __appRouter marker is present, so a regression in the new code path cannot
  be silently masked by the pre-existing fallback.

* test(pages-router): address review nits in pages-to-app navigation tests

- e2e: wait for App Router hydration after the hard navigation so the
  test asserts the App Router landing explicitly instead of only
  matching the new DOM
- unit: drop the unnecessary microtask tick before asserting
  window.location.assign — the fast path has no await before it, so
  asserting synchronously also pins that the hard navigation cannot
  lose a race against the SPA path
2026-06-11 01:01:54 +01:00
Nathan Nguyen 08504a7745 fix(app-router): preload next/dynamic chunks with CSP nonce (#1594)
* fix(app-router): preload next/dynamic chunks with CSP nonce

Rendered next/dynamic boundaries produced no SSR preload links for their client chunks. That diverged from Next.js and broke nonce-based CSP tests that inspect preload tags before hydration.

The runtime assumed React.lazy alone was enough for dynamic imports. It never carried the dynamic module IDs from the source call into SSR, and client JS chunk URLs did not use Next.js's static/chunks path shape.

Add a focused dynamic metadata transform, resolve boundary files from the client build manifest, emit nonce-bearing preload hints during SSR, and keep client JS assets under _next/static/chunks. Ported regression coverage verifies the nonce and the emitted path shape.

* fix: honor assetPrefix for dynamic preload globals

* test: cover asset-prefixed dynamic preload nonces

App Router production only covered the default dynamic preload URL path. That left assetPrefix regressions at the manifest-to-runtime emission boundary unguarded.

Add a startProdServer regression that builds app-basic with assetPrefix "/cdn" and asserts nonce-bearing next/dynamic chunk preloads render under /cdn/_next/static/chunks/.

* test: isolate asset-prefixed prod server globals

The assetPrefix regression starts a second production server in the same Vitest process. startProdServer and the generated RSC runtime install global manifest and module-loader state, so leaving the temporary build's globals in place breaks later production requests in the shared server.

Snapshot and restore the relevant globals around the temporary /cdn server while keeping the integration assertion at the real runtime boundary.

* refactor: reuse shared record guard in dynamic preload metadata

The dynamic preload metadata transform carried a local object guard even though the codebase already has a shared record predicate. That made the AST helper drift from the existing non-array record invariant used elsewhere.

Reuse isUnknownRecord from utils/record and keep the plugin-specific AST alias local to the transform.

* fix: make dynamic preload metadata binding-aware

The next/dynamic metadata transform matched calls by imported identifier text. Shadowed parameters and block bindings with the same name were therefore mutated even when they no longer referred to the next/dynamic import.

Walk the parsed module with lexical binding state so shadowed names are excluded, and collect object-form imports only from loader/modules rather than every property on the options object.

* fix: model switch and class scopes in dynamic preload transform

The dynamic preload metadata transform still treated imported next/dynamic names as active inside switch case lexical scopes and named class expression bodies. That could inject loadableGenerated metadata into calls that resolve to local bindings rather than the import.

The scope walker now removes switch case-block bindings while traversing case tests and consequents, leaves the switch discriminant in the outer scope, and scopes named class declarations and expressions through their children. Regression tests cover switch case shadowing and named class expressions.

* fix: address PR review feedback for next/dynamic CSP nonce

- Drop `as="style"` from `<link rel="stylesheet">` — `as` is a
  preload attribute per the HTML spec, semantically incorrect on
  stylesheet links. Browsers ignore it but it produces bad HTML.
- Remove brittle `import(` string gate from the transform — it could
  miss `import ("./x")` (whitespace between import and paren). The
  Vite filter already gates on "next/dynamic" and the AST parse is the
  real validation.
- Add comment explaining MagicString mutation safety in Promise.all.
- Add comment explaining AST end-exclusive assumption on closeParen.
- Add test for whitespace-separated dynamic import syntax.

* fix: update global.d.ts comment to mention asset prefix, fix formatting

* refactor: extract computeClientRuntimeMetadata shared helper

Extract the duplicated client-manifest runtime metadata computation
(lazy chunks, dynamic preloads, client entry) into a single shared
helper in utils/client-runtime-metadata.ts.

Both prod-server.ts (Pages Router Node.js production server) and
index.ts (Cloudflare closeBundle hook) now call the same helper,
eliminating the highest-risk seam in the PR where basePath/
assetPrefix handling produced bugs.

* test: add TSX-generic and object-form metadata regression tests

- Add post-type-strip generic call test verifying multi-line
  dynamic() calls work after TS/JSX stripping by esbuild.
- Add object-form existing loadableGenerated preservation test
  to guard against duplicate injection in the object overload.

* chore: remove unused ClientRuntimeMetadata type export

* revert: discard unrelated lockfile change from previous commit

* test: replace deploy test simulators with computeClientRuntimeMetadata

simulateCloseBundleAppRouter and simulateCloseBundlePagesRouter
no longer hand-roll lazy chunk and dynamic preload computation.
They call the shared computeClientRuntimeMetadata helper directly,
matching production wiring.

Also:
- Update helper doc comment to mention Node server startup
- Remove unused imports (manifestFileWithAssetPrefix,
  manifestFileWithBase, computeDynamicImportPreloads,
  dynamicImportPreloadsWithBase) from deploy.test.ts

* test: add absolute assetPrefix CSP nonce tests and clean up redundant globals

* fix(app-router): keep CSP nonce on next/dynamic preloads from Server Component call sites

DynamicPreloadChunks rendered in the environment of the dynamic() call site. For a Server Component call site it ran in the RSC environment, where the script-nonce React context is unavailable, so the emitted dynamic preload <link>s dropped the request nonce — a CSP violation under `script-src 'nonce-…' 'strict-dynamic'`. The existing tests missed this because every fixture called dynamic() from "use client" modules, which render in the SSR pass where the nonce is present.

Extract DynamicPreloadChunks into its own "use client" module (mirroring Next.js's PreloadChunks and vinext's next/script shim) so it always renders in the SSR pass where withScriptNonce() installs the provider, regardless of whether dynamic() is called from a Server or Client Component.

Add fixture regression tests: server-call-site JS and CSS preloads carry the nonce, an ssr:false boundary emits no server preload (Next.js loadable.tsx parity), and preloads are still emitted without a nonce when no CSP is set.

* test(app-router): add browser CSP-nonce e2e for Server Component next/dynamic call site

The existing csp.spec.ts browser test only exercises the client call-site page (/nextjs-compat/dynamic). Add a Playwright test for the Server Component call site (/nextjs-compat/dynamic/rsc-imports-client) asserting the lazily-loaded client widget hydrates under script-src 'nonce-…' 'strict-dynamic' with no CSP violations in the console.

* fix(app-router): correct next/dynamic preload key-spaces and harden the transform

Addresses review findings on the next/dynamic CSP-nonce feature.

- Lazy chunks must stay in the SSR-manifest key-space (basePath only); only dynamic preloads take the assetPrefix. Applying an absolute-URL assetPrefix to lazy chunks broke the Pages Router modulepreload-exclusion membership test and leaked lazy chunks into <link rel=modulepreload>.

- Resolve symlinks on both sides when computing a dynamic boundary's module ID (reusing relativeWithinRoot/tryRealpathSync), so the key matches the client manifest under pnpm/Cloudflare symlinked layouts instead of silently dropping the preload.

- Throw on dynamic() calls with >2 args (Next.js react-loadable parity).

- Insert the generated options arg right after the first arg, preserving comments/whitespace (the previous substring-comma scan ate commas inside comments).

- Recurse into _next/static/chunks/ in the on-disk client-entry fallback (client JS now lands under chunks/).

- Add typeof-window guard to DynamicPreloadChunks and document the deliberate as="style" omission for stylesheet links.

- Extract buildRuntimeGlobalsScript so the Cloudflare closeBundle injection shares one source of truth with the deploy tests (which now mirror includeClientEntry + the mixed app+pages branch).

Tests: lazy/dynamic key-space split incl. absolute-assetPrefix leak regression and a realistic already-prefixed manifest; mixed app+pages client-entry injection; symlinked module-id resolution; nested dynamic() calls; >2-arg throw; comment-safe insertion; ssr:false positive content assertion; honest dev-mode E2E naming (preload-nonce coverage lives in the prod-server Vitest suite).

* fix(app-router): fix parenthesized next/dynamic loader corruption + harden re-review findings

Follow-up to the re-review of 1709db35.

- Fix a regression where inserting the options arg at the first argument's AST end corrupted a parenthesized loader (dynamic((() => import('./x')))) into a sequence expression that DROPPED the loader. Insert at the call's closing paren (paren-safe) with a comment-aware trailing-comma check (keeps comment-safety, avoids ',,'). Verified all four arg shapes via the real transform.

- Add an end-to-end round-trip test: real computeClientRuntimeMetadata -> globalThis.__VINEXT_LAZY_CHUNKS__ -> real collectAssetTags, asserting a lazy chunk is excluded from modulepreload under basePath+assetPrefix (consumer-side guard for the lazy-chunk key-space fix).

- Replace the hand-rolled simulateAssetTagFiltering with the real exported collectAssetTags (kills drift + a stale 'in index.ts/can't import' comment).

- Add an on-disk client-entry fallback test for the real _next/static/chunks/ layout (prior tests only used the flat layout).

- Hardening: memoize root realpath in toManifestModuleId; .length/keys guards in buildRuntimeGlobalsScript; DEBUG-gated parse-failure log; line:col in the >2-arg error; document the preserveSymlinks:false dependency; negative test for a shadowed dynamic(a,b,c) not throwing; ssr:false asserts visible text; honest typeof-window guard + E2E comments.

* test(app-router): fix stale comments + harden next/dynamic tests (3rd review pass)

Addresses the 3rd re-review of the next/dynamic CSP-nonce work. No runtime behavior change (doc + test hardening + one behavior-neutral nonce-prop cleanup).

- Fix two stale comments claiming DynamicPreloadChunks is NOT 'use client' (the opposite of the implemented, correct behavior) in the prod-server test and the rsc-imports-client fixture — leftovers from when the bug was first reproduced.

- ssr:false / assetPrefix tests: match the fetchPriority attribute case-INSENSITIVELY so a future React lowercasing can't make the ssr:false negative assertion pass vacuously.

- Add bare-promise dynamic(import(...)) transform tests (+ parenthesized variant) via a shared firstDynamicCallArgTypes AST helper; strengthen the double-comma test with an exact-output assertion.

- Make the basePath+assetPrefix round-trip test assert lazy-exclusion by basename, not the exact modulepreload href (that URL is subject to a separate, pre-existing collectAssetTags asset-URL bug).

- Nits: 'absent/undefined map' wording; unify JS/CSS nonce-prop handling; document the process-lifetime root-realpath cache.

Investigation note: a sub-agent confirmed a separate PRE-EXISTING bug (out of scope here): under basePath + a distinct path-style assetPrefix, collectAssetTags emits modulepreload hrefs as /<basePath>/<assetPrefix>/_next/... (404) while assets serve at /<assetPrefix>/_next/...; DynamicPreloadChunks already emits the correct assetPrefix-only URL.

* fix(pages-router): serve assets from assetPrefix (not basePath) when both are set

When basePath and a distinct path/absolute assetPrefix were both configured, the Pages Router emitted asset hrefs as /<basePath>/<assetPrefix>/_next/... (and the client-nav pageModuleUrl likewise), which 404 — assets actually serve at /<assetPrefix>/_next/... . assetPrefix REPLACES basePath for asset URLs in Next.js; App Router (React bootstrapModules) and next/dynamic preloads were already correct — only the Pages Router collectAssetTags / resolveClientModuleUrl paths were wrong.

- Add assetServingUrlFromBaseAnchored(value, basePath, assetPrefix): strips the basePath segment from a base-anchored SSR-manifest value and re-anchors under the assetPrefix (no-op when assetPrefix is unset). Mirrors how next/dynamic preloads are computed.

- collectAssetTags: render <link>/<script> hrefs via the re-anchoring helper while KEEPING the base-anchored value for the lazy-chunk membership test + dedup. Thread basePath/assetPrefix from vinextConfig.

- resolveClientModuleUrl (pageModuleUrl/appModuleUrl, import()ed client-side for navigation): re-anchor the same way.

Tests: collectAssetTags re-anchoring unit tests (path/absolute assetPrefix, client entry, base-only no-op, lazy-exclusion still keyed on the base-anchored value) + an end-to-end Pages Router build with basePath:'/docs' + assetPrefix:'/cdn' that fetches every emitted asset URL and asserts 200 (the previously-missing guard). Found via the sub-agent investigation in the prior review round.

* chore(app-router): address bonk review comments on next/dynamic preloads

No behavior change — review-comment cleanup (comments + one behavior-neutral refactor).

- dynamic-preload-metadata: honor the ResolveDynamicImport `importer` arg in the plugin resolver closure instead of closing over `id` (equivalent today; removes the signature/impl footgun bonk flagged).

- dynamic-preload-metadata: expand the dynamicLoaderNode `?? modules` comment to explain the react-loadable parity (it's a harmless no-op for string-array modules), per the bonk note raised twice.

- lazy-chunks: document that computeDynamicImportPreloads intentionally does NOT subtract shared eager chunks from a boundary's preload set — harmless (browser/ReactDOM.preload/React stylesheet model all dedupe by URL) and mirrors Next.js's per-module file listing (the bonk 'redundant preloads' observation).

Already-resolved bonk items verified: the redundant installClientBuildManifestGlobals call is already guarded behind the App-Router-only else branch (prod-server.ts), and the absolute-assetPrefix CSP test already asserts the nonce on cross-origin https://cdn… preload hrefs.

---------

Co-authored-by: James <james@eli.cx>
2026-06-11 00:59:18 +01:00
James Anderson de33c3e5a0 fix(build): inline ../-relative font assets in OG routes (#1866)
* fix(build): inline ../-relative font assets in OG routes

The vinext:og-inline-fetch-assets plugin only matched paths starting
with "./" (e.g. "./font.ttf"), but Next.js test fixtures for OG custom
fonts use "../"-relative paths like "../../../assets/typewr__.ttf".

Without the inline, at runtime:
- Cloudflare Workers: import.meta.url is "worker" (not a URL), so
  new URL("../../../assets/...", import.meta.url) throws TypeError.
- Node.js: fetch() does not support file:// URLs, so the inlined
  file:// URL produced by the import-meta-url plugin fails.

Fix: relax the regex from `\.\/[^"']+` to `\.[^"']+` so any
dot-relative path (./x, ../x, ../../x, etc.) is inlined as base64.
Apply the same fix to the readFileSync(fileURLToPath(...)) pattern.

Failing tests addressed:
- test/e2e/og-routes-custom-font: should render og with custom font
  for app routes (edge runtime fetch + Node.js fs.promises.readFile)
- test/e2e/app-dir/metadata-font: should handle custom fonts in both
  edge and nodejs runtime

* fix(build): inline OG font assets when a formatter adds a trailing comma

The vinext:og-inline-fetch-assets regex only matched the single-line,
comma-less form of `fetch(new URL(...)).then((res) => res.arrayBuffer())`.
When a formatter (Prettier `trailingComma: "all"`, oxfmt) wraps the call
across lines it appends a trailing comma:

    .then((res) =>
      res.arrayBuffer(),
    )

which no longer matched, so the font was left as a runtime fetch. On
Cloudflare Workers `import.meta.url` is "worker", so
`new URL("../...", "worker")` throws "TypeError: Invalid URL" and the OG
route returns 500 — even though the ../-relative path itself was already
supported. Relax both `.then(...)` alternatives with an optional `,?`.

Add real e2e coverage (`/api/og-custom-font`) mirroring Next.js'
og-routes-custom-font fixture: an edge OG route that loads
assets/noto-sans.ttf via
`fetch(new URL("../../../assets/noto-sans.ttf", import.meta.url))`. It
runs in tests/e2e/og-image.spec.ts across the app-router (Node dev),
cloudflare-dev and cloudflare-workers (workerd) projects, with the route
and font added to both the app-basic fixture and the app-router-cloudflare
example. Also adds a unit test for the formatted/trailing-comma shape in
tests/og-inline.test.ts.

* fix(build): inline OG font assets with a semicolon-terminated block body

Follow-up from the /bigbonk review on PR #1866: the block-body alternative
of the og-inline-fetch-assets regex ended `…\.arrayBuffer\(\)\s*\}?\s*,?\s*\)`
with no allowance for a `;` before `}`, so formatter output such as

    .then((res) => {
      return res.arrayBuffer();
    })
    .then(function (res) { return res.arrayBuffer(); })

was left as a runtime fetch — which throws "Invalid URL" on Workers
(import.meta.url === "worker"). Add `;?` before the block-body `}` and unit
tests for the arrow- and function-expression block-body forms.
2026-06-09 15:07:49 +01:00
Nathan Nguyen 3a1e56748f fix(app-router): match Pages navigation params in hybrid builds (#1741)
* fix(app-router): emit pages client entry for hybrid builds

* fix pages router navigation params readiness

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

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

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

Address review feedback on #1741:

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

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

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

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

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

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

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

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

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

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

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

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

Addresses further code-review findings:

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

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

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

---------

Co-authored-by: James <james@eli.cx>
2026-06-09 11:30:38 +01:00
James Anderson dd3fb05977 test(pages-router): cover javascript: URL blocking (#1576) (#1854)
* fix(pages-router): emit console.error for javascript: URL navigation (#1576)

Finishes the Pages Router half of #1576 (the App Router warnings already
land). Adds the four Pages Router scenarios from the upstream
test/e2e/app-dir/javascript-urls suite — Link `href`, Link `as`,
router.push, and router.replace — as e2e fixtures + a Playwright spec, plus
unit coverage for the shared Link shim's dangerous-click handler.

No production change is required: the Link shim already emits the canonical
"Next.js has blocked a javascript: URL as a security precaution."
console.error on click for both `href` and `as`, and the Pages Router
router.push/replace synchronous guard from #1575 already does the same. This
PR adds the missing Pages Router coverage that proves all four scenarios.

Closes #1576

* test(pages-router): suppress dev error overlay before safe navigation

The router.push/replace javascript: scenarios throw synchronously (the
#1575 behaviour that surfaces the console.error), which trips vinext's dev
error overlay. Its backdrop intercepted the follow-up safe-link click,
timing out the test even though the security block already passed. Suppress
the overlay via the existing disableDevErrorOverlay helper after asserting
the block, matching how other pages-router/app-router specs handle
intentional dev-time errors.

* test(pages-router): cover javascript: URL blocking in production build

The Pages Router javascript: URL guard (Link dangerous-click handler in
shims/link.tsx + router.push/replace synchronous guard in shims/router.ts,
both via shims/url-safety.ts) is already correct on main. The existing e2e
spec, however, only runs against the dev server (vp dev), while the Next.js
deploy suite exercises these scenarios against a production build where the
dev error overlay is absent and the client bundle is minified.

Add a pages-router-prod Playwright spec that re-runs all four upstream
scenarios (Link href, Link as, router.push, router.replace) against
vinext build + vinext start, so a prod-only regression is caught here.

Refs #1576
2026-06-08 21:38:10 +01:00
James Anderson 3f6c174c20 fix(app-router): resolve query-only Link href against current path (#1540) (#1844)
A <Link href={{ query: {...} }} /> with no `pathname` was resolved by
defaulting the base to "/", producing "/?query" instead of a query-only
href. The shallow navigation then recorded the wrong history entry (the
site root) so back/forward traversal landed on the wrong URL/page.

Mirror Next.js's formatUrl() (`pathname = urlObj.pathname || ''`): when
`pathname` is omitted, emit a relative query-only href (`?query`) so the
existing relative-href resolution applies it against the current path.

Adds a ported e2e regression (middleware-rewritten shallow link history
traversal) plus a Link unit test.

Closes #1540
2026-06-08 19:30:07 +01:00
Divanshu Chauhan (divkix) b4c829d6a2 feat(interception): sibling-style interception routes (#1364 Part C) (#1804)
* test(interception): add failing sibling-interception graph tests

* feat(routing): discover sibling-style interception markers

* feat(routing): emit sibling interception manifest facts

* feat(app-router): serialize sibling intercepts in route manifest

* feat(app-router): match sibling intercepts in route matcher

* feat(app-router): render sibling intercept as full page response

* test: add siblingIntercepts field to AppRoute test fixtures

* test(interception): add sibling interception fixtures

* fix(app-router): emit null proof for sibling intercepts to bypass slot-validation

* test(interception): add sibling interception e2e specs

* fix(interception): correct (...)→(..)(..) marker in interception-from-root fixture

* fix(interception): resolve sourceRouteIndex to slot-owner route in intercept lookup

When a route inherits a @slot from an ancestor via discoverInheritedParallelSlots,
the inherited slot's interceptingRoutes are copied from the ancestor. Previously,
createInterceptLookup used routeIndex (the inheriting descendant's index) as
sourceRouteIndex, causing resolveAppPageInterceptState to see sourceRoute ===
currentRoute → kind='current-route' instead of kind='source-route'.

Fix: build a patternToIndex map and resolve sourceRouteIndex via the intercept's
sourceMatchPattern (which names the actual slot-owner route), falling back to
routeIndex only when no match is found.

* fix(interception): use locale-prefixed href so middleware interception fires correctly

* fix(interception): address codex review issues in sibling intercept implementation

- Pass effectiveParams to resolveAppPageHead so generateMetadata/generateViewport
  sees the intercepted route's params instead of the source route's params
- Rename SIBLING_INTERCEPT_SLOT_NAME from __page to __vinext_sibling_intercept
  to prevent collision with a user @__page parallel route directory
- Restore middleware-rewrite fixture link without locale prefix so the
  interception-dynamic-segment-middleware spec exercises the intended
  middleware rewrite path

* fix(test): update slotId assertions and restore locale-prefixed fixture link

- Update test assertions from slot:__page to slot:__vinext_sibling_intercept
  following the rename in commit 2248b8fe
- Restore locale-prefixed href in interception-mw fixture so the
  interception-dynamic-segment-middleware E2E tests pass (reverts the
  accidental revert from 2248b8fe back to the working state from 02e80c26)

* fix(interception): address review findings on sibling interception PR

- Restore locale-less href in interception-mw fixture and fix the
  underlying middleware-rewrite interception gap: add a Referer-based
  fallback in app-rsc-handler so interception fires when middleware
  rewrites the URL before the server matches it (the client-side manifest
  check sees the pre-rewrite target and skips setting the interception
  context header; the Referer carries the source page the server needs)
- Apply interceptLayouts for sibling intercepts in buildPageElements so
  a layout.tsx under the interception marker dir wraps the intercepting
  page, matching Next.js segment-layout wrapping behaviour; add fixture
  layout and E2E assertion
- Add unit test proving findOwnerRouteForDir ancestor walk attaches a
  sibling intercept to the nearest ancestor route when the marker's
  parent dir has no page.tsx (not dropped silently)

* fix(interception): send current pathname as fallback interception context

The client-side manifest check compares the pre-middleware target URL
against declared interception patterns. When middleware rewrites the URL
(e.g. /foo/p/1 → /en/foo/p/1), the pre-rewrite URL has fewer segments
and doesn't match the pattern, so no X-Vinext-Interception-Context
header is sent and the server cannot fire interception.

Fix: when the manifest check yields no match and there is no prior
intercepted navigation in browser state, fall back to sending the
current page pathname as the interception context. This mirrors Next.js
sending Next-URL on every RSC navigation. The server's findIntercept is
double-gated on both source and target matching, so false positives
cannot occur. The previousNextUrl is also populated so back/forward
traversal can restore intercepted state.

Revert the Referer-based server-side fallback (unreliable across
environments) in favour of this explicit browser-side fix.

* fix(interception): gate middleware-rewrite fallback on declared source pattern

The previous fix sent window.location.pathname as fallback interception
context on every unmatched navigation, which broke prefetch cache reuse:
a prefetch stored with null context wouldn't be found when the navigation
used a non-null context key.

Refine the fallback to only fire when the current page URL matches at
least one sourcePatternParts declared in the route manifest — i.e., the
current page is a known interception source. This preserves prefetch
cache reuse for ordinary navigations while still enabling middleware-
rewrite interception for pages that declare an interception relationship.

* fix(interception): support middleware-rewrite targets in fallback context and planner

The previous fallback-context gate only checked whether the current page
is a declared interception source; it did not verify the target URL
could plausibly be a middleware-rewritten version of the declared target
pattern.  This meant the client sent interception context when it
shouldn't (pre-middleware target had no static anchors in common with the
pattern) and the planner hard-navigated when the proof target matched
only after the rewrite.

Three coordinated fixes:

1. Client fallback gate (app-browser-entry / app-browser-interception-context):
   replace the broad source-only check with matchRoutePatternWithOptionalDynamicSegments
   on the target side — the pre-middleware URL must still share every
   static anchor of the declared target pattern, in order, while allowing
   dynamic segments to be absent.

2. Planner interception proof (navigation-planner):
   findRouteManifestInterceptionForProof now accepts a proof whose target
   matched URL equals the pre-rewrite canonical pathname (shorter than the
   full pattern) when matchRoutePatternWithOptionalDynamicSegments passes.
   Exact pattern match still skips the targetRouteId guard; the relaxed
   path skips it too so the middleware-added locale segment does not
   invalidate an otherwise valid proof.

3. displayPathname threading (app-rsc-handler / app-rsc-entry / app-page-element-builder):
   the handler now passes canonicalPathname as displayPathname separately
   from cleanPathname.  buildPageElements uses cleanPathname for all
   internal routing work (slot param extraction, slot overrides) and
   displayPathname only for render identity — keeping the browser-visible
   URL in RSC metadata without affecting internal matching.

* fix(interception): guard siblingInterceptElement construction and document reserved slot name

* fix(interception): surface missing default export on sibling intercept pages

A sibling-style interception whose intercepting page module is missing
its `default` export previously fell back to the source route's page
component (`effectivePageModule?.default ?? PageComponent`), silently
rendering a *different* page than the one requested.

Resolve the effective component from the intercepting page only and
extend the existing no-default-export guard to cover sibling intercepts,
so a missing export surfaces the same explicit "Page has no default
export" error the source/slot paths already produce. For a normal
request this is identical to the previous behavior since
`effectivePageModule === pageModule`.

Addresses bonk review feedback on #1804.

* test(app-router): guard canonical pathname commit on soft-nav config rewrites

The `displayPathname = canonicalPathname` change feeds the RSC payload
identity the client planner uses to commit a navigation, so it affects
every rewritten route, not just interceptions. Existing coverage
(nextjs-compat/hooks.test.ts) only exercises the hard-nav (SSR) path.

Add an e2e that soft-navigates (client-side) to an afterFiles-rewritten
route and asserts the committed URL and `usePathname()` stay canonical
(pre-rewrite), with a marker check proving no full page reload occurred.

Addresses bonk review feedback on #1804.

* fix(interception): restore __pageLoader on slot intercept lookup entries

The main merge auto-resolved incorrectly: it moved the `__pageLoader`
field that #1738 added to the *slot* intercept push block onto the new
*sibling* push block instead (the two `interceptLookup.push({...})` calls
are textually similar). As a result slot intercept lookup entries lost
`__pageLoader`, so resolveAppPageInterceptState never loaded the lazy
intercepting page (`page` stayed null) and the modal slot fell back to
default.tsx — breaking every slot interception.

Restore `__pageLoader: intercept.__pageLoader` on the slot push block.
The sibling block already carries it (see merge commit), and both
intercept types declare the optional field.

Fixes the app-router-dev-server intercept tests and the interception
e2e suites that regressed after merging main.

---------

Co-authored-by: James Anderson <james@eli.cx>
2026-06-08 19:01:29 +01:00
Nathan Nguyen 6005541c19 fix(app-router): restore scroll snapshots on back (#1743)
* fix(app-router): restore scroll snapshots on back

* fix(app-router): invalidate scroll snapshots with bfcache epoch

* fix(app-router): guard synchronous popstate scroll restore

* fix(app-router): strip basePath for snapshot restore targets

* fix(app-router): canonicalize snapshot restore target search

* docs: document sync-over-async invariant in popstate snapshot restore

* docs: document cross-module ordering contracts per review

Adds comments for three load-bearing invariants identified in review:
- rememberHistoryStateSnapshot layout effect ordering
- synchronousPopstateScrollRestoreNavigationId single-slot latch
- restoredBfcacheIds/reuseCurrentBfcacheIds snapshot timing
2026-06-08 12:33:12 +01:00
Nathan Nguyen 5dd7a34eb6 fix(app-router): isolate page CSS chunks in production (#1738)
* fix(app-router): isolate production page CSS chunks

Production App Router builds statically imported every page module into the generated RSC manifest. That let Vite/Rolldown concatenate page-level global CSS from sibling routes into shared CSS assets, so a sibling hash page with scroll-padding-top changed native hash scroll offsets on unrelated pages.

The manifest now emits cached dynamic loaders for page modules and loads the matched page module at the route dispatch boundary. Page generateStaticParams sources use the same loader with an explicit missing-export sentinel so prerender still distinguishes absent exports from malformed results.

* test(app-router): add focused unit test for route module loader

Address the non-blocking review note from PR #1738: per AGENTS.md, behavior
moved out of a template string should get a focused helper test. Cover
createRouteModuleLoader (promise dedup, rejection propagation),
loadRouteModules (page assignment, concurrent dedup, no-op cases, missing
`page` key) and loadRouteMatch (null short-circuit, match load).
Also cover createLazyGenerateStaticParamsSource (delegation, missing
sentinel for null/empty/non-function exports).

Document the rejected-load cache intent and the per-request intercept copy
in the WeakMap so future readers don't try to "fix" them as bugs.

* test: stabilize dev overlay HMR recovery

* chore: retrigger ci

* chore: fix knip check config

* test(app-router): guard intercepted modal CSS + load lazy page before probe

Adds the positive-direction guard the bonk review asked for: navigating
/feed -> intercepted /photos/[id] must load the lazy modal page and emit
its scroll-padding-top CSS chunk. The existing test only proved the
negative (CSS absent on a direct /feed visit), so a regression where the
lazy modal page failed to load its CSS would have passed silently.

Also fixes the probePage() intercept branch, which read intercept.page
without resolving the now-lazy __pageLoader. The render path awaits the
loader (resolveAppPageInterceptState), but the probe path did not, so the
dynamic-bailout probe silently inspected an undefined component and never
observed the intercept page's searchParams/headers access. Resolve the
lazy page before probing to match the render path.

---------

Co-authored-by: James <james@eli.cx>
2026-06-08 12:16:08 +01:00
Nathan Nguyen cda19e1c58 fix(app-router): preserve recent segment state with Activity BFCache (#1739)
* fix(app-router): preserve recent segment state with Activity BFCache

Fresh App Router navigations were reusing the active React tree when moving between dynamic segment values. That leaked client state into a newly entered segment and made the upstream back-forward-cache suite fail before it even reached browser traversal assertions.

The root cause was that vinext had history-entry bfcacheId plumbing but no separate segment state-key cache. Next.js treats those identities separately: render state is keyed by segment identity/pathname, while useRouter().bfcacheId identifies a concrete history entry.

Add state-key maps for app router slots, render retained entries through React.Activity when cacheComponents is enabled, keep the active one-entry keyed reset path otherwise, and port focused Next.js regression coverage for the three-entry BFCache behavior.

* fix(bfcache): store stateKeyMap in retained slot entries and re-provide on render

Review feedback requested two changes:

1. Add stateKeyMap to BfcacheSlotEntry so each retained BFCache entry carries
the state-key map that belonged to it when active, rather than borrowing the
current navigation's map. This prevents nested hidden entries from being rendered
with old elements but new pathname-derived state keys — an identity corruption
bug that upstream Next.js avoids by rendering each retained entry from its own
state.

2. Add a nested dynamic regression test: /nested/[section]/item/[id] with client
counters in both the [section] layout and [id] page. The existing flat /page/[n]
tests do not exercise nested retained-entry identity, which is exactly where the
missing state-key-map bug lives.

Changes:
- BfcacheSlotEntry gains stateKeyMap?: Readonly<Record<string, string>>
- updateBfcacheSlotEntries accepts and stores an optional stateKeyMap argument
- BfcacheSlotBoundary stores the current stateKeyMap alongside elements when
caching the active entry
- Both the Activity visible/hidden path and the non-cacheComponents single-entry
path now wrap rendered entries in BfcacheStateKeyMapContext.Provider using the
entry's own stateKeyMap (falling back to current map for forward compatibility)
- New e2e test verifies nested layout + page counter preservation across
segment navigations

* refactor(bfcache): extract shared provider stack into BfcacheEntryProviders

BfcacheSlotBoundary rendered the BfcacheStateKeyMapContext / ElementsContext /
SegmentContext provider stack in two places: the one-entry keyed reset path and
the per-entry Activity retention list. The two stacks were identical except for
where the React key sat, so the retained-entry bookkeeping and its render shape
were tangled together and harder to audit than needed.

Extract BfcacheEntryProviders as a pure presentational helper and render it from
both paths. The keyed-reset path keeps its remount semantics: the key now sits on
the BfcacheEntryProviders element, so a fresh active stateKey still remounts the
whole provider subtree.

Hook ordering is unchanged. All hooks stay above the early returns in
BfcacheSlotBoundary, so the boundary still satisfies the Rules of Hooks across the
activeStateKey undefined-to-defined transition. Covered by the slot BFCache
entry-state suite in tests/shims.test.ts.

* test(bfcache): isolate cacheComponents BFCache coverage in a dedicated fixture

The back/forward-cache e2e suite ran against the shared app-basic fixture with
cacheComponents: true. app-basic backs the single app-router Playwright server
that every app-router spec uses, so enabling cacheComponents globally changed a
core DOM invariant for unrelated tests: inactive route trees stay mounted as
hidden Activity DOM. The contamination already leaked a :visible workaround into
the interception-modal bfcacheId spec, which had nothing to do with Activity.

cacheComponents is an opt-in semantic mode, not default app-router behaviour, so
it does not belong on the shared baseline fixture. Move the BFCache routes into a
dedicated tests/fixtures/app-bfcache fixture with cacheComponents: true, served by
a new app-router-bfcache Playwright project on port 4183, and run the
back-forward-cache spec there against the project baseURL. The :visible locators
stay in that spec, where duplicate hidden DOM is the expected contract.

Remove cacheComponents and the BFCache routes from app-basic so the shared fixture
models default behaviour again, and revert the :visible workaround in
use-router-bfcache-id.spec.ts now that the modal route no longer retains hidden
DOM. Fix the nested item page import while moving it: nested/[section]/item/[id]/
page.tsx imported ../counter, but the counter lives two levels up at
nested/[section]/counter.tsx, so it must be ../../counter.

Validation: PLAYWRIGHT_PROJECT=app-router-bfcache (5 passed) and the full
use-router-bfcache-id spec under app-router (11 passed), plus the slot BFCache
unit suite (1172 passed) and vp check (formatting + lint + types clean).

* ci: run the app-router-bfcache e2e project in the matrix

The dedicated app-router-bfcache Playwright project was added to
playwright.config.ts but not to the CI E2E matrix, so the back/forward-cache
specs never ran in CI. Add it to the matrix project list so the BFCache suite
runs on every push. It uses the default chromium browser, so the existing setup
and browser-cache steps apply unchanged.

* fix(bfcache): keep persisted slots mounted when cacheComponents is off

Without cacheComponents there is no Activity retention, yet BfcacheSlotBoundary
still keyed the active entry by its segment stateKey. That stateKey tracks the
pathname (createBfcacheSegmentIdentity), so any slot whose identity moves with
the URL (shared layouts, interception source slots) was remounted on navigation,
discarding client state that survives a normal route change. The baseline router
reconciles these slots in place.

This regressed seven existing app-router e2e tests once cacheComponents was no
longer masking it on the shared fixture: interception source-context and modal
preservation in advanced.spec.ts, and layout-persistence.spec.ts dynamic-segment
layout counter survival. Fresh-entry reset is driven by userland bfcacheId keying
in the fixtures, not by remounting the slot subtree, so the keying was pure
overreach in the non-cacheComponents path.

Render the single active entry unkeyed when cacheComponents is off so the boundary
reconciles in place like the baseline. The keyed Activity retention path is
unchanged for cacheComponents.

Validation: full app-router project 436 passed / 0 failed (the seven regressions
now pass); use-router-bfcache-id 11 passed (fresh-push reset intact); app-bfcache
5 passed (Activity retention intact); shims unit suite 1024 passed.

* docs(test): point bfcacheId spec comment at the app-router-bfcache project

The header noted that Activity-backed preservation is covered in
back-forward-cache.spec.ts, but that spec moved to the dedicated app-bfcache
fixture under the app-router-bfcache project. Name the project and path so the
cross-reference stays accurate.

* docs: align cacheComponents comment with unkeyed active-segment behavior

* test(bfcache): assert fresh bfcacheId after Activity-restored return

* style: collapse locator chains per oxfmt

* refactor(bfcache): split slot entry order from snapshots

* fix(bfcache): keep non-cacheComponents slot path baseline

* fix(bfcache): use string cacheComponents define

* test: remove external fetch from revalidate tag fixture

* fix(bfcache): harden Activity state key retention

* docs(bfcache): document cacheComponents Activity caveats

* fix(bfcache): preserve navigation context for fallback SSR

Fallback boundary rendering can outlive the original request-scoped navigation context. That made app SSR fail fast with a missing navigation context when loading-boundary digest routes rendered their HTTP fallback shell.

The SSR boundary contract still requires a navigation context for real request renders, but boundary renderers now synthesize the same typed shape from the request URL and matched params when request scope has already been cleared.

* refactor(bfcache): normalize pathname in state-key map, document invariant callers

Moves hydration-pathname normalization into createBfcacheSegmentStateKeyMap so SSR and client always produce byte-identical Activity keys. Documents the requireNavigationContext guaranteeing callers per review feedback.

* fix(bfcache): tolerate malformed state-key pathnames

* docs(bfcache): document known Next.js divergence and dead-branch contract

* chore(deps): update lockfile for vite 0.1.24

* fix(app-router): preserve encoded delimiters in bfcache keys

* docs(bfcache): track router-owned retention follow-up

* chore: retrigger ci

* fix(app-router): preserve redirect bridge fallback
2026-06-07 23:07:25 +01:00
Nathan Nguyen aab9240e10 fix(app-router): hard navigate streamed redirects (#1742)
* fix(app-router): hard navigate streamed redirects

* fix(navigation): guard page redirect marker checks

* test(app-router): count redirect document loads
2026-06-07 21:55:56 +01:00
James Anderson fe731b4827 test(e2e): mark flaky dev-overlay build-error HMR test as fixme (#1812)
The app-router dev error overlay test "server component HMR surfaces
Vite build errors after a clean load" is flaky in CI: after writing the
clean file back, the build-error overlay intermittently stays visible
past the 10s timeout. Mark it test.fixme to unblock CI while the
recovery path is investigated.

Tracked in #1811.
2026-06-07 10:14:55 +00:00
Nathan Nguyen dab28c4b0f fix(app-router): refetch same-page search navigations (#1744)
* fix(app-router): refetch same-page search navigations

Same-page App Router navigations with a changed search string could replay a prefetched RSC payload instead of requesting the target payload. That diverges from Next.js, which refetches page segments for same-page navigations because search params are page inputs.

Bypass visited and prefetched RSC caches for navigate operations whose pathname is unchanged but search params differ. Add a prerendered production browser regression that ports the Next.js hash navigation request assertion.

* fix(app-router): hide rsc cache query from middleware

* test(app-router): mirror upstream hash scroll offsets

* fix(app-router): address review feedback on rsc query navigation

- Reuse fixture-scoped node_modules linking in the hash RSC spec instead of
  symlinking repo-root node_modules, matching sibling production specs.
- Add an explicit assertion that no request leaked `_rsc` to the fixture
  middleware (HTTP 599) for a clear failure mode.
- Document that the optimistic shell is intentionally not gated by
  shouldBypassNavigationCache (detached commit, always superseded).
- Detect the RSC cache-busting param via the encoding-aware stripper so the
  guard and stripper can never diverge on encoded-key edge cases.

* fix(app-router): share encoding-aware matcher for _rsc guard

Replace the before/after url.search comparison in
requestWithoutRscCacheBustingSearchParam with a dedicated
hasRscCacheBustingSearchParam helper that reuses the stripper's
isRscCacheBustingSearchPair matcher. This makes the guard and stripper share a
single source of truth and avoids spuriously rebuilding/normalizing requests
whose only difference is degenerate empty query pairs (e.g. ?a=1&&b=2).

* fix(app-router): preserve cf metadata when hiding _rsc from middleware

requestWithoutRscCacheBustingSearchParam reconstructed the userland request
with a bare new Request(url, source), which drops the Workers-specific cf
property and omits duplex: "half" for streaming bodies (throwing on
Node/undici). Add a cloneRequestWithUrl helper mirroring cloneRequestWithHeaders
that overrides the URL while preserving headers/cf and setting duplex, and route
the _rsc strip through it.

* fix(app-router): stop _rsc leaking to external rewrite proxies

The beforeFiles/afterFiles/fallback applyRewrite calls forwarded the original
request (with _rsc) to proxyExternalRequest, which reconstructs the upstream URL
from request.url and merges its query params — leaking the internal RSC
transport query to external rewrite destinations. Pass the _rsc-stripped
userlandRequest to all three calls, consistent with the middleware invariant.

Add a regression test that points an external rewrite at a loopback server and
asserts the forwarded URL drops _rsc while preserving userland query params.

* test(app-router): wait for idle network before tracking RSC requests

The query-param link is in the viewport and gets prefetched on load. On slower
runtimes (WebKit), that prefetch could land after rscRequestUrls.clear(), so it
was misattributed to the hash-only navigations and tripped the
"no with-query-param request" assertion. Wait for networkidle before clearing,
mirroring upstream's waitForIdleNetwork() in the ported Next.js test.

---------

Co-authored-by: James <james@eli.cx>
2026-06-07 00:20:27 +00:00
Jerry Zhao c8975a48cf chore(deps): bump playwright to 1.60.0 (#1796)
* chore(deps): bump playwright to 1.60.0

* u
2026-06-06 22:49:23 +01:00
Nathan Nguyen c28098694a test(pages-router): wait for hydration after middleware redirect (#1789)
The middleware redirect E2E test placed a reload marker immediately after the redirected page heading appeared. That proved the document rendered, but not that the Pages Router Link handler had hydrated before the click.

Wait for the shared Pages Router hydration marker before clicking the redirected page's home link so the marker assertion tests client-side navigation instead of racing anchor fallback behavior.
2026-06-06 13:51:13 +01:00
Divanshu Chauhan (divkix) 330de786c5 test(interception): fixtures and E2E specs for 4 dynamic interception suites (#1364 Part B) (#1785)
* test(interception): add fixtures and E2E specs for 4 dynamic interception suites

Adds fixture pages and Playwright E2E specs for the 4 Next.js interception
route test suites tracked in issue #1364 (Part B):

- interception-dynamic-segment: @modal/(.)[username]/[id] and
  @modal/(.)test-nested/@sidebar — 6 tests (all pass)
- interception-dynamic-single-segment: @modal/(.)groups/[id]/new
  from a multi-segment source route — 5 tests
- interception-dynamic-segment-middleware: locale-rewrite middleware +
  (.)[username]/p/[id] interception — 4 tests
- parallel-routes-and-interception-from-root: (...)[locale]/intercepted
  from the app root — 3 tests

Suite 3 middleware rewrite is tightly scoped to /interception-mw/* in
the shared app-basic middleware.ts to avoid interfering with other tests.

Tests for suites 2/3/4 that depend on the computeInterceptTarget fix
(fix/interception-compute-target, Part A) will fail until that branch
is merged, which is expected.

* test(interception): mark 7 known-failing tests with test.fail() pending Part C

The 7 tests across suites 2-4 exercise behavior that Part C (#1364) will
fix. Marking them test.fail() keeps CI green while preserving the specs
as executable documentation of the expected behavior.
2026-06-06 13:36:42 +01:00
Divanshu Chauhan (divkix) cf04ca63e3 fix(form): DISALLOWED_FORM_PROPS stripping, file input warning, viewport prefetch, pages-router E2E (#1752)
* fix(form): implement DISALLOWED_FORM_PROPS, file input warning, viewport prefetch, and pages-router E2E

- Strip method/encType/target props with dev console.error (Gap 1)
- Warn in dev when FormData contains a File value (Gap 2)
- Add viewport IntersectionObserver prefetch for App Router (Gap 3)
- Add prefetch prop (false | null) to FormProps
- Add pages-dir fixture and E2E spec for Pages Router form tests (Gap 4)
- Fix Rules of Hooks: move useRef/useCallback/useEffect before conditionals
- Update unit tests: stub React 19 dispatcher for hook-safe direct rendering

Closes #1355

* test(form): fix 'renders a form with method prop' to assert stripping behavior

* fix(form): address code-review findings — JSDoc, parity warnings, prefetch dedup

- Fix misleading JSDoc: pages-dir prefetch prop "has no effect" (not "emits a warning")
- Add dev warning for invalid prefetch value (not null/false)
- Add dev warning for replace/scroll/prefetch passed with a function action
- Gate viewport-prefetch IntersectionObserver with getPrefetchedUrls() /
  hasPrefetchCacheEntryForNavigation() dedup check to match link.tsx
- Add 5 new test cases covering the three new warning paths

* style(form): fix formatting in form.test.ts

* fix(form): match Next.js warning text verbatim, reuse useMergedRef, skip prefetch for unsafe actions

Addresses bonk review feedback:
- Invalid prefetch warning now matches Next.js verbatim:
  "The `prefetch` prop of <Form> must be `false` or `null`"
- Function-action warnings split into the two distinct Next.js messages
  (one for `prefetch`, one for `replace`/`scroll` with doc links),
  gated separately to match app-dir/form.tsx
- Reuse the shared useMergedRef helper instead of hand-rolling ref merge
- Viewport prefetch now early-returns on unsafe actions, matching the
  submit-path isSafeAction gate
- Update form.test.ts assertions to the new verbatim text

* fix(form): run dev-mode prop validations before DISALLOWED_FORM_PROPS strip to match Next.js order

* fix(form): match Next.js validation ordering and bail for server/client action submitters

Addresses bonk review feedback:
- Run prefetch/replace/scroll dev validations before the DISALLOWED_FORM_PROPS
  strip loop, matching upstream app-dir/form.tsx ordering
- Bail out of GET interception for React server-action submitters
  (`name` starting with $ACTION_ID_/$ACTION_REF_, dev-only silent bail) and
  client-action submitters (`formAction="javascript:..."`), ported from
  Next.js form.tsx / form-shared.tsx
- Add unit tests covering both submitter bail paths

* fix(form): match Next.js dev-validation order and gate submitter warnings

Addresses bonk re-review parity findings:
- Gate the three console.error calls in hasUnsupportedSubmitterAttributes
  behind the dev check, matching Next.js form-shared.tsx (the bail itself
  still runs in all environments). Previously they fired in production.
- Hoist checkFormActionUrl(action, "action") into the top dev-validation
  block so it runs first, matching upstream app-dir/form.tsx ordering.

* refactor(form): drop dead-code GET method check for upstream parity

Addresses bonk re-review finding: the form-level `method` prop is stripped
via DISALLOWED_FORM_PROPS and any non-GET submitter `formmethod` override is
already caught by hasUnsupportedSubmitterAttributes, so the explicit
getEffectiveMethod GET check was dead code. Upstream app-dir/form.tsx has no
equivalent check. Remove it (and the now-unused getEffectiveMethod helper) and
document why the method is always GET at that point.

* fix(form): align viewport prefetch fetch with link.tsx (priority + purpose)

Addresses bonk re-review finding: the Form viewport prefetch fetch() omitted
the `priority: "low"` and `purpose: "prefetch"` options that link.tsx passes
(link.tsx:445-451). Without them, RSC prefetches competed with higher-priority
requests and weren't tagged as prefetches in devtools / Sec-Purpose. Align for
consistency with the Link prefetch path.

* fix(form): thread interception context + mounted slots into viewport prefetch

Addresses bonk re-review substantive finding: the viewport prefetch built
headers with createRscRequestHeaders() (no interceptionContext, no mounted-slots
header) and keyed the prefetched set on the bare rscUrl. The actual navigation
(and link.tsx / the router) compute getPrefetchInterceptionContext(href) +
getMountedSlotsHeader() and key on encodeCacheKey(rscUrl, interceptionContext).

For intercepted target routes or pages with mounted parallel slots, the Form's
prefetched cache key wouldn't match the navigation lookup, so the prefetch was a
silent cache miss (wasted work, no dedup against a nearby <Link>). Mirror the
Link/router computation: build context-aware headers, key the prefetched set on
encodeCacheKey, and thread interceptionContext/mountedSlotsHeader through
hasPrefetchCacheEntryForNavigation and prefetchRscResponse.

* docs(form): note prefetch effect dep-array redundancy is lint-mandated

Bonk flagged actionHref as redundant alongside action in the prefetch effect's
dep array. It is derived purely from action + the module constant, but the
exhaustive-deps lint requires it to be listed since the effect references it.
Document this so it isn't mistaken for an oversight.

---------

Co-authored-by: James <james@eli.cx>
2026-06-06 00:04:08 +01:00
Stephen Zhou 06f37e9c1b fix: client HMR dev overlay recovery (#1758)
* Fix client HMR dev overlay recovery

* Stabilize dev overlay build error recovery test

* Harden dev overlay recovery handling
2026-06-05 15:11:15 +01:00
Stephen Zhou fb233b0a34 feat: improve dev error overlay source frames (#1746)
* Improve dev overlay stack source mapping

* Polish dev error overlay stack display

* Add dev overlay code frames

* Align dev overlay message spacing

* Add dev overlay error copy action

* Fix dev overlay knip exports

* Polish dev overlay layout

* Polish dev error overlay spacing

* Fix dev overlay coverage and source frame metadata

* Fix dev overlay recovery for RSC HMR errors

* Handle Vite HMR build errors in dev overlay

* Fix knip

* Use Shadow DOM for dev error overlay
2026-06-05 12:49:11 +01:00
Stephen Zhou e3090b08fa fix: dev overlay browser sourcemap stacks (#1731)
* Add browser runtime sourcemap regression test

* Update pnpm lockfile

* Fix dev overlay browser sourcemap stacks

* Move dev stack sourcemap resolution to server

* Fix knip unused stack endpoint export

* Fix dev stack sourcemap proxy host handling

* test: cover dev stack sourcemap helpers

* fix: remove unused sourcemap type export

* refactor: share dev stack sourcemap endpoint

* chore: trigger ci rerun

* fix: cap dev stack sourcemap request body
2026-06-04 16:09:44 +01:00
James Anderson 51690038d8 fix(pages-router): cancel in-flight nav on gSSP/gSP data redirect (#1465) (#1691)
* fix(pages-router): return __N_REDIRECT JSON for gSSP/gSP data redirects (#1465)

When getServerSideProps/getStaticProps returns `{ redirect }` on a
client-side `_next/data` navigation, vinext emitted a real HTTP redirect.
The client's fetch transparently followed it to the destination's HTML
(not a valid data envelope), forcing a hard reload with console errors and
leaving navigation on the wrong page.

Mirror Next.js: for data requests, reply 200 with `__N_REDIRECT` /
`__N_REDIRECT_STATUS` in pageProps. The client router now detects these
markers and re-dispatches a fresh client navigation to the destination,
which increments the navigation id and supersedes (cancels) the in-flight
navigation so the intermediate page never commits. HTML requests still emit
a real HTTP redirect.

Fixed in both server paths to keep dev/prod parity (see AGENTS.md):
- prod: buildPagesRedirectResponse() in pages-page-data.ts branches on isDataReq
- dev: writeGsspRedirect() in dev-server.ts branches on isDataReq
- client: navigateClientData() handles pageProps.__N_REDIRECT via
  handleDataRedirect(), preserving push/replace mode
- tests: data-endpoint redirect shape (vitest) + client cancellation (e2e)

* fix(pages-router): hard-navigate to external __N_REDIRECT destination (#1465)

Address review: handleDataRedirect() passed the originating page URL to the
hard-navigation fallback for external/non-absolute __N_REDIRECT destinations,
which would loop back to the redirecting page (e.g. an OAuth callback at
https://example.com/...). Hard-navigate to the destination instead, matching
Next.js's handleHardNavigation({ url: destination }). Drop the now-unused
fallback parameter and add a server test asserting an external destination is
preserved verbatim in the data envelope.
2026-06-03 22:34:36 +01:00
Nathan Nguyen 65eaea49a4 feat(skip): omit proven static layouts from RSC transport (#1437)
* feat(skip): omit proven static layouts from RSC transport

App Router RSC responses re-send every layout payload on each navigation, even
when the client already holds an identical static layout from a sibling route.
That repeats transport bytes on the common sibling-page navigation path.

When the client sends a verified reuse manifest, the render lifecycle now runs a
skip-transport planner. Each manifest entry is cross-checked against the
static-layout cache proof (exact artifact compatibility, variant cache key,
payload hash, and invalidation). Layouts that pass are omitted from the outgoing
RSC payload, recorded under __skippedLayoutIds, and their per-element render
dependencies are released. The skip disposition also bypasses the RSC cache so a
partial payload never enters a shared cache, and it is suppressed for HTML
responses.

This is the enable slice. Layouts are gated on the cache proof, not yet on
per-layout runtime observations. The observation-based safety gates (param
scope, request APIs, finite revalidate, cacheLife, unstable_cache, cache-tagged
and dynamic fetches) land in the stacked hardening follow-up.

* fix(skip): preserve parallel slots owned by skipped static layouts

When the skip-transport merge restores a server-omitted static layout into
preserveElementIds, it must also restore the default/unmatched parallel slots
that layout owns. Otherwise the planner invariant ("every preserved slot's owner
layout is present in preserveElementIds, and vice versa") is violated: in the
topology-unknown path the planner returns empty slot persistence, so a
slot-owning layout skipped server-side commits with a missing slot
(mergeElements starts from the next payload and, with preserveAbsentSlots:
false, never restores it).

mergeSkippedLayoutPreservation now derives the owned slots for newly-preserved
skipped layouts via the planner's resolveDefaultOrUnmatchedSlotPersistenceForLayouts,
covering both the topology-unknown path and the known-topology past-divergence
case. Added a regression with a mounted @slot default that fails without the fix.

Also hardcode the static-layout cache-proof's observed request-API set to []
(with a comment naming the invariant) instead of reading it back from the
per-layout observation, so a future reordering of the classification gate cannot
feed stale request-API reads into the synthetic proof. Reaching that point
already requires staticLayoutIds membership, which excludes any layout that
observed a request API.

* docs(skip): explain broader-than-planner layout membership guard

Per review: clarify that the set-membership check in
mergeSkippedLayoutPreservation is intentionally broader than the planner's
prefix-based persistence, and why preserving a proven-identical layout past the
ancestor divergence point is sound. Comment-only.

* refactor(skip): drop redundant cacheable-fetch observation record

Per review: the post-key-generation recordCacheableFetchObservation in
fetch-cache.ts was strictly redundant. The synchronous record before the first
await already covers every path that reaches it (the only intervening early
return records a dynamic observation instead), and the store is an idempotent
set, so the second call added no coverage.

* fix(test): match real RSC request URL in client-reuse-manifest e2e

The 'sends a client reuse manifest for retained static layouts on soft
navigation' e2e hand-rolled its RSC-request matcher as
url.pathname === '/client-nav-test.rsc', but App Router soft-navigation RSC
requests use the bare pathname with a _rsc query param and an rsc: 1 header
(e.g. GET /client-nav-test?_rsc), so the predicate never matched and
page.waitForResponse timed out at 30s. The manifest header was being attached
correctly the whole time. Use the shared isAppRouterRscRequestForPath helper
(as the sibling test already does) so the matcher tracks the real request shape.

---------

Co-authored-by: James <james@eli.cx>
2026-06-03 16:10:25 +01:00
한만욱 82b2720701 feat(app-router): support useRouter bfcacheId semantics (#1588)
* feat(app-router): implement useRouter bfcacheId semantics

* fix(app-router): refine bfcacheId navigation behavior

* test(app-router): align bfcache history state expectations

* fix(app-router): handle catch-all bfcache identities

* fix(app-router): align bfcache context semantics

* fix(app-router): avoid hidden bfcache DOM retention

* fix(app-router): hydrate bfcache ids from history state

* fix(app-router): handle bfcacheId redirects and slots

* test(app-router): expect active slot route ids

* Fix bfcache id history restoration

* Clarify bfcache id sentinel formatting

* Address bfcacheId review feedback

* Tie bfcacheId sentinels to shared constants

* Address bfcacheId review clarity

* Tighten bfcache slot identity check

* Deduplicate bfcache history sync check

* Handle empty bfcache segment ids

* Fix intercepted active slot route ids

* Clarify bfcacheId form state coverage

* Address bfcacheId review clarity

* fix(app-router): skip invisible tree-path segments in bfcache identity

getVisibleTreePathSegments only stripped route groups, not parallel
@slots or "." default segments. Tree paths contain raw filesystem
segments, so a slot preceding visible URL segments over-counted the
consumed pathname segments and produced a wrong identity prefix. That
re-minted the bfcacheId for layouts/slots that actually persisted across
parallel-route navigations (e.g. @modal), diverging from Next.js
bfcacheId semantics.

Delegate to the canonical isInvisibleSegment helper so the filter stays
in sync with the route graph, and add a parallel-slot layout regression
test (fails without the fix).

Also tighten the hydration-map unit test to actually exercise the
"no seeding from minted ids" property instead of asserting a local
literal, and correct the SSR bfcache context comment.

* fix(app-router): move isInvisibleSegment to browser-safe utils

The previous commit imported isInvisibleSegment from app-route-graph.ts
into app-browser-state.ts, but app-route-graph.ts imports node:path,
node:fs and node:crypto. Pulling it into the browser bundle broke client
hydration (app-with-src E2E: instrumentation-client and
dev-overlay-recovery timed out waiting for hydration / the navigation
runtime).

Move the pure isInvisibleSegment helper into the browser-safe
routing/utils.ts module (already in the client bundle), re-export it
from app-route-graph.ts so Node-side import sites keep working, and have
app-browser-state.ts import it from utils.js. The app-route-graph type
import is now type-only.

* refactor(app-router): address bfcacheId review feedback

- Reject history entries with a missing bfcache version in the
  document-scoped version gate (stop coercing null -> 0) so stale ids
  from older builds / external pushState can't be restored into a fresh
  document on the version-0 path.
- Parse AppElements metadata once per element map per commit and resolve
  slot bindings via a precomputed Map (was repeated readMetadata parses
  plus an O(slots^2) find on every navigation commit).
- Read single history-state keys without cloning the whole state object;
  the write path still clones before mutating.
- Dedupe the two identical non-negative-safe-integer validators and share
  path-segment / catch-all helpers via browser-safe routing/utils.
- Move tree-path consumed-segment counting into routing/utils and fix
  optional-catch-all over-counting.
- Drop the unused bfcacheIds param from createHistoryStateWithPreviousNextUrl.

* refactor(routing): make catch-all segment predicates module-private

isCatchAllSegment/isOptionalCatchAllSegment are only used by
countConsumedPathnameSegments in the same module; drop the exports to
clear knip's unused-export warning.

* test(app-router): lock in bfcache version-gate rejection of missing versions

Extract the version-gate decision into a pure, exported
isHistoryStateBfcacheVersionCurrent(state, currentVersion) in
app-history-state (re-exported via app-browser-state); the browser
entry's isCurrentBfcacheVersion now delegates to it. Add a direct
regression test asserting a history entry with bfcache ids but no
version key is NOT treated as current even when the current version is
0 — the exact cross-document stale-id hole that the earlier `?? 0`
coercion left open.

---------

Co-authored-by: James <james@eli.cx>
2026-06-02 13:53:27 +01:00
Nathan Nguyen 560f270ee6 fix: align app router navigation parity (#1705)
* fix: align app router navigation parity

* fix(app-router): preserve canonical RSC and hard-nav semantics

App Router RSC requests now keep the route pathname shape when adding _rsc. Stripping a trailing slash made trailingSlash routes fetch a non-canonical URL and let the server redirect the client-side RSC request back to the slash form.

The MPA navigation scheduler also keys pending hard navigations by both href and history mode so a later replace does not reuse an earlier push closure. Redirect handling keeps the global browser bridge out of passive server rendering while retaining client-side redirect recovery.

Adds focused regression coverage for trailingSlash RSC requests, same-href push-to-replace hard navigation, and client redirect guard loops. Updates existing navigation observers to the canonical route URL plus _rsc transport.

* fix(app-router): stabilize action and prefetch parity

* fix(app-router): reset MPA dedupe after bfcache restore

A bfcache-restored App Router document can keep the scheduler's last pending hard-navigation marker after the external navigation already left the page. Reissuing the same href and history mode then entered the suspended MPA path without scheduling another location change.

Expose a scheduler reset for the browser lifecycle boundary and call it on persisted pageshow events. Cover the scheduler contract so the same external navigation can run again after reset.

* fix(app-router): isolate redirect bridge from passive boundaries

Unhandled client redirect recovery currently lives inside every RedirectBoundary, while Pages Router params fallback can keep stale App Router params after an App-to-Pages transition. That makes passive boundaries own browser runtime wiring and lets stale app params win over live Pages Router params.

Move the unhandled redirect bridge into BrowserRoot under AppRouterContext, keep RedirectBoundary passive, and prefer live Pages Router params when that compat context exists. Add an App-to-Pages navigation regression fixture.

* fix: extract shared decodeRedirectError helper, rename stale test

- Add decodeRedirectError() to shims/navigation.ts as the single
  source of truth for parsing redirect error digests
- Replace three private helpers in error-boundary.tsx with shared
  decodeRedirectError() call
- Replace inline digest parsing in AppRouterRedirectBridge with
  shared decodeRedirectError() call (fixes unsafe decodeURIComponent)
- Rename test that claimed to test redirect() but actually tests
  <Link> navigation

* refactor: eliminate unnecessary type assertions

- isRedirectError: use in-narrowing instead of as-casts
- AppRouterRedirectBridge: remove as after guard already narrows
- RedirectErrorBoundary: inline digest access, use in for handled check

* ci: retrigger checks

* fix: emit noindex for Forbidden/Unauthorized boundaries, extract retryScrollTo helper

* docs: clarify decodeRedirectError comment about defensive join
2026-06-01 12:37:27 +01:00
Nathan Nguyen 8ae5abd56c fix(app-router): preserve forwarded action redirect wrappers (#1582) 2026-05-31 22:30:57 +01:00
Nathan Nguyen 66519412c3 fix(app-router): reuse pending prefetched RSC payloads (#1704) 2026-05-31 20:08:13 +01:00
Nathan Nguyen b1f2e57bc4 fix(pages): preserve import.meta.url source paths (#1598)
* fix(pages): preserve import.meta.url source paths

Pages Router production bundles previously left direct import.meta.url expressions for the bundler to evaluate from generated output. Server renders could expose dist/server/entry.js, and prerendered Pages output could miss the client entry needed for hydration to replace the server value with the Turbopack-style browser source URL.

The fix rewrites direct import.meta.url reads in user modules to source-file URLs at transform time, keeps new URL asset bases intact, and loads the Pages client entry from the build manifest for Node production and prerender servers.

Regression coverage ports the Next.js import-meta deploy test, including the prerender and hydrated browser path.

* fix(tests): run import.meta browser coverage in e2e

The import-meta regression test launched Playwright from a Vitest integration shard. That shard does not install Playwright browser binaries in CI, so it failed before exercising the runtime behavior.

Move the browser assertion into the existing pages-router-prod Playwright project, keep the lower-boundary server and client chunk assertions in Vitest, and cache the import-meta plugin root paths after an early source-text bailout to address review feedback.

* refactor(import-meta-url): canonicalize each module once per transform

The transform path canonicalized the same module id twice: once in the
eligibility check and again when computing the replacement value, so every
rewritten module paid for two fs.realpathSync.native() syscalls.

The eligibility check now returns the canonical id and threads it through to
the value computation, leaving a single realpath per module.

Also widens the extension match to .cts/.mts/.cjs and documents that the
new URL() skip only covers the bare URL identifier, matching Vite's own
asset-detection scope.

* test(pages): drop unrelated prerender from top-level-await setup

The top-level-await production test ran runPrerender in its setup, but the
assertions pass identically whether the routes are prerendered or rendered on
demand. The step added unrelated build work to a test that is not about
prerendering or client-entry injection.

* fix(pages): share client entry fallback

* fix(import-meta-url): address PR review feedback

- Handle ChainExpression (import.meta?.url) in new URL() asset-detection
  skip so the optional-chain variant preserves Vite asset resolution
- Add explicit ChainExpression check in visit() to avoid fragile
  reliance on coincident inner-node start/end ranges
- Add test for new URL('./file', import.meta?.url) preservation
- Fix decodeHtmlText entity ordering (&amp; before &quot;) in both
  unit and E2E test helpers
- Add doc comments: excluded prefixes rationale, node_modules
  early-exit optimization intent

* fix(pages): select client entry by marker in build manifest

findClientEntryFileFromManifest returned the first chunk flagged
isEntry, but a client build can emit several entry chunks (client
entry plus instrumentation/middleware) and manifest order is not
guaranteed. The on-disk fallback already disambiguates by the
vinext-client-entry / vinext-app-browser-entry marker; apply the
same check to the manifest path so both lookups agree, falling back
to the first entry only when nothing is marked.

* refactor(pages): address review feedback on client-entry/import-meta-url

- prod-server: hoist findClientEntryFile out of the manifest if/else
  since it already handles a missing manifest via the on-disk fallback
- import-meta-url: document the two known limitations (destructured
  access, aliased new URL() base) that match Vite's own behavior
- import-meta-url: drop the dead visit(value.callee) for new URL() —
  the callee is always the bare URL identifier

---------

Co-authored-by: James <james@eli.cx>
2026-05-31 11:37:09 +01:00
Nathan Nguyen 7be428dbc3 feat(app-router): support inline CSS parity (#1595)
* feat(app-router): support inline CSS parity

App Router production builds ignored Next.js experimental.inlineCss, so HTML responses kept stylesheet links, next/font CSS lived in a second style tag, and Next-style RSC requests with RSC: 1 plus _rsc could return HTML.

The missing invariant was that inlineCss changes the production CSS delivery boundary: non-RSC HTML gets inlined style resources, RSC payloads keep CSS bodies out, and client navigations must not reintroduce duplicate stylesheet resources for CSS already present inline.

Resolve the config flag into the generated RSC entry, collect emitted CSS assets into an inline manifest for Node and Workers production, rewrite matching React stylesheet links into hoistable style tags, merge next/font CSS into the first inline style with a fallback path, and recognize Next-style RSC requests.

Regression coverage now ports the upstream app-inline-css behavior at production-browser level and adds focused tests for request normalization, stream rewriting, font merging, and fallback emission.

* feat(app-router): preserve inline CSS import ordering

Inline CSS font merging could prepend generated font rules before a stylesheet import. CSS imports must stay at the beginning of their stylesheet, so that ordering caused imported styles to be ignored when experimental.inlineCss and local font CSS were combined.

Keep import-sensitive stylesheet preambles untouched and let the existing font fallback style carry the font rules instead. Add stream-helper coverage for the import case.

* feat(app-router): harden inline CSS review edges

Inline CSS rewriting only saw complete link tags in a single decoded chunk and treated rel as an exact value. That left valid stylesheet links un-inlined when rel had multiple tokens or when streaming split the tag across chunks.

The production manifest injection also assumed the RSC worker bundle was always at dist/server/index.js, so custom rscOutDir builds missed the inline CSS manifest.

Buffer incomplete link tags across stream flushes, match stylesheet as a rel token, and inject the manifest into the configured RSC output entry. Add regressions for the streaming and custom-output cases.

* feat(app-router): match inline CSS cleanup semantics

Client navigation cleanup only pruned links whose rel was exactly stylesheet and whose precedence marker used data-precedence. That diverged from the server inline CSS rewrite, so tokenized rel values or legacy precedence links could survive after navigation as duplicate stylesheet resources.

The fix moves the cleanup into a testable helper, reuses shared HTML token-list matching for server and browser paths, accepts both precedence marker forms, and only buffers split link tags when an inline CSS manifest is present.

Regression coverage now covers browser cleanup and non-inlineCSS streaming chunk granularity.

* feat(app-router): harden inline CSS HTML rewriting

Inline CSS rewriting could prepend SSR font CSS ahead of namespace-bearing stylesheets and could treat stylesheet-like text inside script bodies as real link tags. That violates CSS ordering constraints and corrupts streamed script text when experimental.inlineCss is enabled.

The stream rewrite now treats @namespace as an unsafe preamble, skips link replacement inside raw-text and comment regions, and buffers incomplete rewrite boundaries across flushes. Regression coverage pins namespace fallback, complete script text, split script text, and unterminated script text.

* test(e2e): specify junction type for fs.symlink to support Windows

* refactor(app-router): drop write-only __PAGE__ wire marker

Every outgoing App Router payload carried `__PAGE__: true` in its wire
metadata, but nothing in the runtime ever read it. Its only consumer was
an inline-css E2E assertion checking that a dynamic navigation returned an
RSC payload rather than HTML, so the marker added permanent per-response
wire overhead with no behavioural effect.

The `__route` key is already present on every payload and proves the same
thing: the response carries page metadata. Remove the marker key, its type
field, and its re-export, and point the unit and E2E assertions at
`__route` instead.

* perf(app-router): skip inline-css manifest scan on the streaming hot path

The tick-buffered SSR transform recomputed `Object.keys(manifest).length`
on every flush through `rewriteInlineCssStylesheetLinks`, even when inline
CSS was disabled and the manifest empty. Streaming flushes are a hot path,
so this ran per chunk of every response for no benefit.

Compute the manifest-present flag once at transform creation and reuse it
to gate both the split-link boundary buffering and the link rewrite, so
non-inline-css responses skip the rewrite entirely. Also document the
shared-lastIndex constraint on the module-level rewrite regexes and the
startup/build-time population contract for `__VINEXT_INLINE_CSS__`.

* fix(app-router): forward SSR script nonce to inline-css <style> tags

Sites running `Content-Security-Policy: style-src 'nonce-…'` saw the
inlined `<style>` blocks blocked at parse time and rendered unstyled
when `experimental.inlineCss` was enabled. The `<link rel="stylesheet">`
tags the feature replaces aren't subject to inline-style CSP, but the
new inline blocks are — and React Fizz typically doesn't put a nonce on
the `<link>`, so the rewrite path had no nonce to carry over.

Thread the SSR-time `scriptNonce` through `createTickBufferedTransform`
into `rewriteInlineCssStylesheetLinks`, and prefer the link's own nonce
when present (otherwise fall back to the SSR nonce). Add unit coverage
for the SSR-only, link-only, and missing-nonce cases.

Also replace `html.toLowerCase().lastIndexOf("<link")` in
`splitTrailingIncompleteLinkTag` with a forward `g+i` regex scan so the
streaming hot path no longer allocates a lowercased copy of every flush
(can be tens of KB on large responses).

* chore(check): register experimental.inlineCss as supported

So `vp check` advertises App Router CSS inlining alongside the other
experimental flags. Notes that next/font CSS is folded into the first
inline `<style>` block.

---------

Co-authored-by: James <james@eli.cx>
2026-05-28 18:17:06 +00:00
Nathan Nguyen 5e90f16a56 fix(app-router): preserve push autoscroll across refresh (#1602)
Calling router.push() and router.refresh() in the same transition could leave vinext on the current page instead of committing the pushed route. That diverged from Next.js and broke the default autoscroll target for the pushed page.

The refresh path dispatched a current-route RSC refresh before the nested push transition could finish scheduling, so the refresh superseded the intended navigation.

Track App Router push/replace scheduling for the current task, keep refresh cache invalidation, and skip only the overtaking current-route refresh. Add Next parity coverage for refresh, server-action refresh, focus targets, fragments, hoisted children, and metadata routes.
2026-05-28 14:20:04 +01:00
한만욱 07be579048 test(app-router): cover clean replace after search load (#1626) 2026-05-28 14:16:35 +01:00
James Anderson f75845ef14 fix(pages-router): emit page scripts with defer in <head> by default (#1636)
* fix(pages-router): emit page scripts with defer in <head> by default

Next.js' Pages Router emits page scripts with the `defer` attribute in
`<head>` when `experimental.disableOptimizedLoading` is false (the
default). vinext emits `<script type="module">` (which already defers
implicitly), but upstream tests assert the literal `defer` attribute
and were failing against the deploy suite.

Add `defer` to module scripts emitted by `collectAssetTags` when
`experimental.disableOptimizedLoading` is not enabled. Surface the
flag in `ResolvedNextConfig` so prod-server respects an explicit
opt-out. Browser behaviour is unchanged (module scripts already
defer), but parity tests now pass.

Fixes #1519.

* test(e2e): update hydration regex to allow defer attribute

The script tag regex in `tests/e2e/cloudflare-pages-router/hydration.spec.ts`
required `type="module"` to be immediately followed by `src="…"`, but the
optimized-loading fix inserts a `defer` attribute between them. Relax the
regex to allow any whitespace-separated attributes between `type="module"`
and `src` so future attribute additions don't break it either.
2026-05-28 14:12:15 +01:00
James Anderson 53a97e2002 fix(navigation): stabilize Pages Router useParams snapshot for SSR (#1639)
`useParams()` / `useSearchParams()` / `usePathname()` from `next/navigation`
work as cross-router compat shims under Pages Router, deriving their state
from `getPagesNavigationContext()` published by `next/router`. React's
`useSyncExternalStore` requires `getServerSnapshot` to return a stable
reference (Object.is) across calls in a single render — otherwise React
logs:

  "The result of getServerSnapshot should be cached to avoid an infinite loop"

and may re-render until the snapshots stabilize, which is wasteful and a
hydration-mismatch hazard. The SSR branch of `getPagesNavigationContext`
was returning a fresh object each call.

Cache the shape on a `WeakMap` keyed on the ALS-isolated SSR context, so
the same `{ pathname, searchParams, params }` shape (with reference-stable
`params` / `searchParams`) is returned for every hook call inside a single
request. The WeakMap key is per-request, so concurrency safety is
preserved.

Adds a Vitest unit assertion (reference equality across repeated SSR
calls) and a Playwright regression matching the Next.js deploy-suite case
(`test/e2e/app-dir/use-params/use-params.test.ts` — 'should work on pages
router'): a Pages Router dynamic page using `useParams()` inside a fixture
that also has an `app/` directory must render the matched param after
hydration.

Refs #1466
2026-05-28 14:11:36 +01:00
James Anderson d458b71e6b test(link): port Next.js OnNavigate fixture as Pages Router regression (#1357) (#1628)
* test(link): port Next.js OnNavigate fixture as a Pages Router regression

Add a local Playwright fixture mirroring `.nextjs-ref/test/e2e/link-on-navigate-prop/shared/OnNavigate.tsx` so the upstream `onNavigate` parity scenarios are exercised against vinext's own Pages Router runtime.

The `onNavigate` prop itself was implemented in #1371 and is covered by unit tests in `tests/link-navigation.test.ts`. The nightly Next.js deploy suite (workflow run 26552059147) still surfaces failures under `test/e2e/link-on-navigate-prop/index.test.ts` for the Pages Router half of the suite, where even a plain `<button onClick={() => setIsLocked(...)}>` does not update state. The App Router half of the same suite passes, suggesting a Pages Router-specific hydration regression rather than a missing `onNavigate` implementation.

This commit ports the upstream fixture so the same scenarios run against vinext in CI's existing Playwright suite, narrowing the reproduction without depending on the full Next.js deploy harness.

Refs #1357.

* test(link): address bot review on OnNavigate Pages Router port

- Assert `isClicked` / `isNavigated` flip to `true` in the internal-click
  test by stubbing `history.pushState` so vinext's Link runs its synthetic
  handlers in full but the URL never commits and the source DOM stays
  available for state assertions. Split the navigation check into its own
  test that asserts `#subpage-heading`.
- Port the three remaining upstream scenarios from
  `.nextjs-ref/test/e2e/link-on-navigate-prop/index.test.ts`:
  modifier-key click, plain external link (alert-driven), and external
  link with `replace` (history-length check). Add the corresponding
  `#external-link` and `#external-link-with-replace` anchors to the
  fixture to match `shared/OnNavigate.tsx`.
- Explain in the download-test why the native `click` listener doesn't
  interfere with React's synthetic onClick / onNavigate.
- Switch `setIsLocked((v) => !v)` to `setIsLocked(!isLocked)` to match
  upstream's exact callback shape (parity port).

Refs #1357.
2026-05-28 14:05:53 +01:00