Commit Graph

221 Commits

Author SHA1 Message Date
ponharu 7c932f39cd fix(headers): strip internal dev request ID headers (#2260) 2026-06-22 23:25:08 +01:00
Jerry Zhao 14549e323f test(shims): expect forward-slash shim ids in the next/error and subpath resolution fixtures (#2265) 2026-06-22 23:19:51 +01:00
Jerry Zhao ecfc68874f test(pages-router): wrap the client-navigation page module URL as a Vite /@fs/ id on Windows (#2266) 2026-06-22 23:19:37 +01:00
James Anderson fbf68d588e fix(app-router): restore cache and parallel route parity (#2261)
* fix(app-router): preserve searchParams cache variants

* test(app-router): cover RSC metadata cache variants

* fix(app-router): isolate metadata searchParams observation

* fix(app-router): preserve static ISR cache proofs

* fix(app-router): limit queryless cache probes

* fix(app-router): skip redundant cache proof probes

* fix(app-router): harden incomplete cache proofs

* fix(app-router): observe loading page execution

* fix(app-router): preserve exotic page exports

* fix(app-router): preserve client page ISR

* fix(app-router): avoid duplicate queryless page execution

* fix(app-router): observe viewport search params

* fix(app-router): clear force-static search params

* fix(app-router): isolate intercept static context

* fix(app-router): preserve intercept request context

* fix(app-router): isolate action static context

* fix(app-router): match static request API semantics

* fix(app-router): preserve dynamic segment constraints

* fix(app-router): apply slot-only segment config

* fix(app-router): preserve draft mode reads in static errors

* fix(app-router): include nested slot constraints

* fix(app-router): refresh slot layout constraints

* fix(app-router): render nested slot layouts

* fix(app-router): preserve nested slot params

* fix(app-router): preserve intercepted slot head

* fix(app-router): scope parallel layout params

* fix(app-router): preserve intercept layout parity

* fix(app-router): preserve intercept ancestor params

* fix(app-router): scope intercept source params

* fix(app-router): preserve intercept source route params

* test(app-router): stabilize parity validation
2026-06-22 23:17:41 +01:00
Divanshu Chauhan ad2fcfb16f fix(dynamic): render ssr:false loading with pastDelay:true to match client (#1967) (#2064)
next/dynamic's ssr:false server branch rendered the loading component with
pastDelay:false, while the client's first (pre-mount) render and Suspense
fallback default pastDelay to true. For a loading component that branches on
pastDelay (the documented `if (!pastDelay) return null` pattern), the server
emitted nothing but the client rendered the spinner, causing a React hydration
mismatch and a visible content jump.

Drop the hardcoded pastDelay:false override on the server so it defaults to
true, matching both the client render and Next.js App Router, which always
renders the loading fallback with pastDelay=true on server and client. Loading
components that ignore pastDelay are unaffected.

Update the three tests that asserted the buggy server-side pastDelay:false, and
add a server-equals-client-first-render parity assertion.

Fixes #1967
2026-06-20 14:47:02 +01:00
MaxtuneLee 42f8700f15 fix(router): enhance navigation with as mask for URL handling (#2076)
* fix(router): enhance navigation with `as` mask for URL handling

* fix(router): implement bracket-pattern interpolation for dynamic routes in Pages Router

* fix(router): preserve masked state in beforePopState

* fix(router): cover basepath navigation in ci

* docs(router): clarify navigation state invariants

* refactor(router): require complete history state

* refactor(router): share dynamic href interpolation

* test(router): cover localized masked navigation

* fix(router): keep interpolation internals private

---------

Co-authored-by: James <james@eli.cx>
2026-06-19 00:07:03 +01:00
James Anderson b2ea860284 fix(proxy): match Next.js file conventions (#2155)
* fix(proxy): match Next.js file conventions

* test(proxy): keep convention fixture out of workspace

* test(proxy): provide middleware path in handler fixture
2026-06-18 23:54:39 +01:00
Jerry Zhao b352d671fa fix(rsc): resolve the app-rsc-handler shim to a forward-slash id on Windows (#2165) 2026-06-18 22:18:03 +01:00
James Anderson 144c30d47e fix(middleware): normalize trailing slash before matching (#2112)
* fix(middleware): normalize trailing slash before matching

* test(middleware): preserve unmatched API fixture

* fix(middleware): normalize matcher source slashes

* fix(middleware): normalize direct matcher inputs

* fix(middleware): preserve explicit slash matchers
2026-06-17 13:19:49 +01:00
Alexander Lichter 56d7311994 perf(dev): externalize App Router request handler (#2108) 2026-06-17 11:55:48 +01:00
James Anderson 06ffe4661c fix(middleware): fail closed for unsafe matchers (#2078) 2026-06-16 14:24:04 +01:00
James Anderson bacee9ef52 fix(pages): emit canonical __NEXT_DATA__ JSON (#2043)
* fix(pages): emit canonical next data script

* test(pages): expect canonical next data markup

* fix(pages): initialize canonical next data before routing

* test(pages): parse canonical next data markup

* fix(pages): preserve router bootstrap ordering

* test(pages): expect readiness bootstrap import

* fix(pages): guard readiness bootstrap without next data
2026-06-15 23:16:23 +01:00
James Anderson 48f4df28cb fix(pages-router): cancel deduped GSSP data waits (#2038) 2026-06-15 22:58:02 +01:00
James Anderson b91d5d1396 fix(pages): interpolate same-segment rewrite params (#2030)
* fix(pages): interpolate same-segment rewrite params

* test(pages): fix same-segment navigation CI
2026-06-15 21:49:28 +00:00
James Anderson 866c71809e fix(pages): preserve rewritten URLs during navigation (#2028)
* fix(router): preserve rewritten pages query navigation

* fix(router): align pages url object locale handling

* fix(router): preserve raw pages url search

* fix(router): escape fragments in raw pages search

* fix(router): preserve localized rewritten pages urls

* test(router): fix pages navigation CI failures

* fix(router): preserve Next search fragment formatting
2026-06-15 22:34:36 +01:00
James Anderson 62db98056c fix(image): allow any quality 1-100 when images.qualities is unset (#2023)
* fix(image): allow any quality 1-100 when images.qualities is unset

The image optimization endpoint returned 400 for any quality other than
75 (e.g. `<Image quality={90}>`) because vinext defaulted the allowed
quality list to `[75]` whenever `images.qualities` was not configured.

Next.js only enforces a quality allowlist when `images.qualities` is set;
when unset it permits any integer quality from 1-100. This aligns vinext
with that behaviour: `parseImageParams`/`resolveDevImageRedirect` now take
an optional allowlist and only restrict quality when one is provided, and
the build/runtime plumbing emits `null` (not `[75]`) when unset so the
"allow all" semantics survive into the deploy worker.

Repro: GET /_next/image?url=%2Fshop%2Ftablet.png&w=750&q=90 -> 400.

Fixes the app-router-playground image optimization failure.

* test(image): update App Router blur-exception tests for quality parity

With `images.qualities` unset, any quality 1-100 is now permitted (Next.js
parity), so q=70 is a normal quality rather than a rejected one. Pin the
blur-quality-exception tests to `qualities: [75]` so they still exercise the
dev-only exception, and add a case asserting q=70 is allowed in production
when qualities is unset.
2026-06-15 10:43:20 +01:00
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 b7ef466742 fix(pages): preserve app props for GSSP requests (#1996)
* fix(pages): preserve app props for gssp requests

* docs(pages): update _next/data comment to reflect full props envelope

* fix(pages-router): preserve _app.getInitialProps during stale ISR regeneration

Stale ISR background regeneration called getStaticProps directly and rendered
HTML and __NEXT_DATA__ with only the page's pageProps, dropping app-level
props from _app.getInitialProps. The foreground path already loads
_app.getInitialProps first and merges its props, but the background
regeneration path skipped that step.

Extract a shared loadPagesAppInitialRenderProps helper from the production
foreground path and reuse it during background regeneration in both
pages-page-data.ts and dev-server.ts. This ensures regenerated pages carry the
same full props envelope as the original render.

Add a regression test in tests/pages-page-data.test.ts.

* fix(pages-router): skip _app.getInitialProps on ISR cache hits

The foreground render path ran _app.getInitialProps before consulting the
ISR cache, so fresh and stale cache hits executed userland App data code
even though the cached HTML was returned verbatim. That wasted work on the
hot path and ran side-effecting App code whose result the cached response
never reflected.

The eager App initial-props load happened up front, ahead of the HIT and
STALE short-circuits. Defer it until the request will actually render:
load it lazily inside the getServerSideProps / getStaticProps /
getInitialProps branches, after the cache short-circuits return.
Stale-while-revalidate already re-runs it inside the regeneration closure,
so regenerated HTML still carries app-level props. The dev SSR handler and
the production page-data resolver are updated in lockstep to preserve
dev/prod parity.

Tests cover the fresh-HIT skip and the stale foreground-skip vs
regeneration-run split at the page-data resolver boundary.

* refactor(pages-router): extract dev App getInitialProps loader into a tested helper

The dev SSR handler ran _app.getInitialProps inside an inline closure that
mutated captured pageProps/renderProps and read res directly, so its lazy
load-only-on-render behavior could only be covered by a heavyweight
integration test that vi.doMock'd the isr-cache and file-matcher modules —
a mock cascade with little signal.

Move the decision into loadDevAppInitialProps in pages-get-initial-props.ts.
It takes the App component, req/res, and an injected AppTree builder (keeping
React out of the module) and returns a discriminated result —
skip / response-sent / render — that the caller applies. The dev-server
closure is now thin glue: build the React tree, delegate, apply the result.

Adds focused unit tests that drive the helper through its real boundary
(genuine getInitialProps functions, plain req/res inputs) — no module mocks:
the skip path, App/page prop merging, pageProps normalization, the
response-sent short-circuit, and the router/ctx wiring passed to userland.

* fix(pages): preserve app props across data requests

* fix(pages): preserve fallback cache expiry

* fix(pages): preserve dev app context during ISR

* fix(pages): hydrate middleware rewrite fallbacks

* fix(pages): retarget middleware data rewrites

* fix(pages): preserve rewrite query on navigation

* fix(pages): finalize rewrite navigation state

* fix(pages): resolve rewrite data targets consistently

* fix(pages): await static props during regeneration

* fix(pages): preserve original query in resolved url

* Revert "fix(pages): await static props during regeneration"

This reverts commit cbdcdf65cd.

* test(app): cover rewrite params in app search

---------

Co-authored-by: James <james@eli.cx>
2026-06-14 20:17:05 +01:00
James Anderson e1958ae3fa fix(router): share Pages Router context across chunks (#2010) 2026-06-14 16:15:23 +01:00
Jerry Zhao de9d03a846 test(shims): normalize expected og shim path for Windows (#2006) 2026-06-14 00:45:25 +01: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 3253aafd77 fix(pages-router): pass rewrite URL to edge API requests (#1998)
* fix(pages-router): pass rewrite URL to edge API requests

* fix(pages-router): preserve edge API request URL parity

* fix(pages-router): configure edge API NextRequest

* fix(pages-router): preserve edge API locale config

* fix(pages-router): preserve edge request metadata

* fix(pages-router): align edge API URL formatting

* fix(pages-router): match API locale casing

* fix(pages-router): match domain locales by locale

* docs(pages-router): clarify edge API basePath parity

* test(deploy): expect metadata-preserving URL clone

* docs(pages-router): note basePath rewrite parity

---------

Co-authored-by: James <james@eli.cx>
2026-06-13 22:05:31 +01:00
Nathan Nguyen dabcb55e63 fix(app-router): expose active source page on window.next (#1995)
* fix(app-router): expose active source page on window.next

App Router pages did not update window.next.__internal_src_page, so Next.js-compatible tooling and deploy tests could not observe the active source page on initial render or client transitions.

The payload only exposed URL route IDs, which drop route groups and bracketed dynamic segment names. The fix carries a source-page metadata field from the app route's filesystem segments, reads it from committed browser router state, and mirrors it onto window.next like Next.js does.

Adds regression coverage for route-group and dynamic source-page encoding plus the window.next writer contract ported from the upstream app-dir test.

* fix(app-router): preserve source page across boundary renders

* fix(app-router): tolerate malformed source page metadata

* fix(app-router): preserve intercepted fallback source page

* fix(app-router): omit unknown boundary source page

---------

Co-authored-by: James <james@eli.cx>
2026-06-13 21:59:39 +01:00
James Anderson bf41ee49dd fix(image): prevent optimizer cache-key amplification (#1944)
* fix(image): validate optimizer parameters

* fix(image): close optimizer amplification aliases

* fix(image): validate pure app dev requests

* fix(image): address optimizer review findings
2026-06-13 01:10:12 +01:00
James Anderson 24d3dfcf00 fix(pages): harden cookie parsing (#1947)
* fix(pages): harden cookie parsing

* fix(pages): preserve cookie object compatibility

* fix(shims): distinguish edge cookie parser

* refactor(cookies): dedupe header parsing
2026-06-13 00:57:45 +01:00
James Anderson a155fdf997 fix(deploy): improve deployment id parity (#1949)
* fix(deploy): improve deployment id parity

* test(router): isolate deployment prefetch globals

* fix(deploy): restrict asset tokens to managed files
2026-06-13 00:10:42 +01:00
Nathan Nguyen ba6ea6f6f8 fix(shims): add useUntrackedPathname hook for error boundary parity (#1933)
* feat(shims): add useUntrackedPathname hook for error boundary parity

Next.js upstream provides a useUntrackedPathname() hook
(packages/next/src/client/components/navigation-untracked.ts) that is
used by the unstable_catchError error boundary. Unlike usePathname(),
useUntrackedPathname():

- Returns null during the missing-params shell prerender (instead of "/")
- Does not register the pathname as a tracked render dependency, avoiding
  unnecessary re-renders of the error boundary

This commit:
1. Adds useUntrackedPathname() to packages/vinext/src/shims/navigation.ts
2. Switches packages/vinext/src/shims/error.tsx to use it
3. Adds unit tests covering server, client snapshot, and Pages Router paths

Refs:
- Next.js source: https://github.com/vercel/next.js/blob/v16.2.6/packages/next/src/client/components/navigation-untracked.ts
- Issue: https://github.com/cloudflare/vinext/issues/1920

* refactor(shims): move useUntrackedPathname to internal module

Address code review feedback on PR 1933:

- Move useUntrackedPathname from public next/navigation API to
  internal/navigation-untracked.ts to prevent public surface leak.

- Change server no-context fallback from null to '/' to match
  usePathname() semantics. Document that null is reserved for
  future missing-params shell detection.

- Update error.tsx to import from the internal module.

- Update shims tests to import from internal module and expect
  '/' for no-context SSR.

- Add integration test for unstable_catchError boundary contract:
  getDerivedStateFromProps clears errors on pathname changes and
  retains them when pathname stays the same.

* fix(shims): gate untracked pathname render snapshot with active count

Address second round of code review feedback on PR 1933:

- Gate client render snapshot with navigationSnapshotActiveCount > 0
  so the hook returns the pending URL only during active transitions.
  After commit, falls back to cached pathname so user pushState/
  replaceState calls are immediately reflected.

- Import getNavigationContext() from navigation.ts instead of
  duplicating the global accessor / hydration context logic.

- Add inverse test: provider snapshot present but inactive
  (navigationSnapshotActiveCount === 0) returns committed pathname,
  not stale provider value.

* test(shims): type-safe unstable_catchError boundary contract test

Export _CatchError, _CatchErrorInternalState, _CatchErrorProps,
_CatchErrorState, and _UserProps from error.tsx with @internal
annotations so tests can reference them without as-unknown-as
assertions.

Rewrite the getDerivedStateFromProps integration test to use
imported types instead of inline type assertions.

* chore: format with vp fmt

* fix(shims): keep _CatchError and underscore types local in error.tsx

Address review feedback: exporting _CatchError and underscore types from
next/error widened the public shim surface. Revert them to local types
and use structural typing in tests instead.

- Make _CatchError, _UserProps, _CatchErrorState, _CatchErrorProps, and
  _CatchErrorInternalState local again (non-exported).
- Update the getDerivedStateFromProps integration test to use structural
  as-unknown-as assertions instead of importing internal types.

* refactor(shims): extract shared Pages Router accessor to internal helper

Create packages/vinext/src/shims/internal/pages-router-accessor.ts
with getPagesNavigationContext() and PagesNavigationContext type.

Import from it in both navigation.ts and navigation-untracked.ts
instead of duplicating the Symbol lookup and error handling. This
prevents the string key and the accessor shape from drifting across
modules if a third consumer is added later.

router.ts still writes the global accessor directly using Symbol.for
as before.

* fix(shims): move pages-router-accessor import to top of navigation.ts

Move the import from the middle of the file to the top import block
and remove the unused PagesNavigationContext type import. Fixes
eslint no-unused-vars and import/first errors.

* ci: trigger rebuild

* refactor(shims): reuse navigation render snapshot hook

---------

Co-authored-by: James <james@eli.cx>
2026-06-12 12:06:47 +01:00
James Anderson ae5c72ec13 fix(shims): match next/error RSC diagnostic (#1939)
* fix(shims): match next error RSC diagnostic

* test(app-router): run catch-error e2e in CI

* revert: avoid dedicated catch-error CI job
2026-06-12 10:46:46 +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
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
James Anderson 9ba0772a2e fix(middleware): clear nextUrl.basePath for absolute paths outside basePath (part of #1830) (#1872)
* fix(middleware): clear nextUrl.basePath for absolute paths outside basePath (part of #1830)

* fix(middleware): reconcile NextURL basePath tests and App Router regression

Address two blocking issues from ask-bonk review on #1872:

1. Update 10 unit tests in tests/shims.test.ts that used basePath-stripped
   URLs (e.g. http://localhost/dashboard with basePath="/app") — these tests
   encoded the old incorrect behavior where basePath was always set from
   config regardless of the URL. Switch them to prefixed input URLs
   (http://localhost/app/dashboard) to match the new correct semantics.

2. Fix App Router regression: createNextRequest in middleware-runtime.ts was
   receiving normalizedPathname already stripped of basePath (App Router
   passes cleanPathname), so the NextRequest URL had no basePath prefix and
   _stripBasePath incorrectly cleared basePath to "". Fix by applying
   addBasePathToPathname before constructing the URL, mirroring the
   un-stripped URL that Next.js's adapter always passes to middleware.

Also use addBasePathToPathname helper in prod-server.ts and deploy.ts
closures instead of duplicating the root-path edge-case logic.

* test(middleware): fix stale test assertions broken by basePath wrapper change

Update 9 tests in deploy.test.ts that checked for the old simple runMiddleware
passthrough ('runMiddleware: typeof runMiddleware === "function" ? runMiddleware : null')
which no longer matches the generated code after the basePath re-add wrapper was
introduced in the deploy adapter.

Fix 1 test in app-route-handler-runtime.test.ts that incorrectly expected NextURL
to re-add the basePath prefix to a URL that was already stripped of it. Per
getNextPathnameInfo semantics (the fix this PR implements for #1830), basePath is
only set when the pathname actually starts with the configured prefix — a stripped
URL stays stripped.

* fix(middleware): gate basePath re-add on in-basePath state and restore route handler URL parity

- Fix the missed import assertion in tests/deploy.test.ts (the generated
  entry now imports addBasePathToPathname alongside hasBasePath/stripBasePath).
- Gate the basePath re-add in createNextRequest on a new hadBasePath option:
  the unconditional re-add regressed the Pages out-of-basePath flow by
  re-prefixing absolute-path requests the adapters deliberately left bare,
  making middleware see nextUrl.basePath === "/root" instead of "". The
  flag defaults to URL-derived (correct for prod/deploy Pages adapters) and
  is asserted true by callers that pass pre-stripped URLs (App Router,
  dev server).
- Re-add basePath in createTrackedAppRouteRequest so App Route handlers see
  the original prefixed request.url / nextUrl.href and an active
  nextUrl.basePath, matching Next.js (the routing layer strips basePath
  before handlers run).
- Re-derive the active basePath from the configured value in
  NextURL._stripBasePath on every parse, so href reassignment toggles
  basePath like Next.js NextURL.analyze().
- Add unit tests covering the App Router nextUrl.basePath re-add, the Pages
  in-/out-of-basePath flows, matcher evaluation against stripped paths, and
  href re-derivation.

* refactor(middleware): extract shared wrapMiddlewareWithBasePath helper

The runMiddleware basePath re-add closure was duplicated verbatim in
prod-server.ts and the generated worker entry in deploy.ts. Extract it
to wrapMiddlewareWithBasePath in server/pages-request-pipeline.ts (both
adapters already import from that module) to keep the two adapters in
sync.

* test(middleware): add unit coverage for wrapMiddlewareWithBasePath

Covers the helper's gating contract directly: pass-through when
hadBasePath is false or basePath is empty, prefix re-add (preserving
query, headers, ctx, and opts), and idempotent re-add for an
already-prefixed URL.
2026-06-11 00:54:56 +01:00
James Anderson 0df69782d7 fix(pages-router): bridge deprecated Router.on<Event> property callbacks (#1869)
* fix(pages-router): bridge deprecated Router.on<Event> property callbacks

Next.js's singleton Router supports a legacy pattern where userland code
sets a property directly on the Router singleton instead of using
Router.events.on():

  Router.onRouteChangeComplete = (url) => { ... }

The reference implementation in packages/next/src/client/router.ts
(lines 105-124) bridges this by listening on Router.events for each
known event and calling the corresponding Router.on<Event> property
if it is set.

Vinext's Router singleton was missing this bridging, so the deprecated
pattern silently did nothing. The e2e test
`withRouter > production mode > allows observation of navigation events
using top level Router deprecated behavior` navigates from /a to /b and
asserts that .active-top-level-router-deprecated-behavior updates — it
never did because Router.onRouteChangeComplete was never invoked.

Fix: after the Router singleton is constructed, register one routerEvents
listener per known event that forwards calls to the corresponding
Router.on<EventName> property when present, matching the reference
implementation's behaviour exactly.

* test(pages-router): add unit tests for deprecated Router.on<Event> bridge; address ask-bonk nits

- Add 4 focused tests in "deprecated Router.on<Event> property bridge" describe block:
  onRouteChangeComplete fires the assigned handler with correct args;
  onRouteChangeStart fires the assigned handler with correct args;
  emitting with no handler assigned does not throw;
  clearing the handler stops it from being invoked.
- Rename _deprecatedRouterEvents → deprecatedRouterEvents (underscore implied unused).
- Remove redundant `as string` cast on the template-literal eventField expression.
- Add one-line comment explaining SSR safety of unconditional module-load registration.
2026-06-09 15:36:36 +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 49d753217b fix(app-router): propagate "use cache" tags to route-handler ISR entries (#1453) (#1848)
* fix(app-router): propagate "use cache" tags to route-handler ISR entries (#1453)

* fix(app-router): bubble nested use-cache tags to parent on data cache HIT (#1453)
2026-06-08 19:42:07 +01:00
James Anderson c32cecdca8 fix(app-router): preserve _rsc query across redirects (#1529) (#1838)
* fix(app-router): preserve _rsc query across config redirects (#1529)

* refactor(prod-server): reuse rawQs for redirect query; clarify .rsc test comment

Address ask-bonk review on #1838:
- prod-server.ts: reuse the existing rawQs instead of re-slicing url for
  the redirect Location query (identical value, removes redundancy).
- app-rsc-handler.test.ts: tighten the .rsc-without-header test comment so
  it no longer overstates RSC re-detection — the guarantee is query
  preservation (mirroring Next.js), not re-arming RSC on the followed hop.
2026-06-08 19:14:41 +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 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
Nathan Nguyen 1a480016f6 fix(app-router): track searchParams access for static bailout (#1788)
* fix(app-router): track searchParams access for static bailout

App Router pages that awaited or used the searchParams prop during prerender could still be classified as static when the prerender request had an empty query string. That seeded no-query output into the deploy cache and served it for requests like ?search=hello.

The dynamic decision was based on query-string content instead of page prop access. Observe the searchParams thenable itself, including React use() status reads, and mark searchParams dynamic only when the prop is consumed.

* chore: rerun ci

* fix(app-router): avoid static cache for searchParams pages

* fix(app-router): tolerate synthetic page paths in manifest codegen

* fix(app-router): observe searchParams access for static bailout

* fix(app-router): preserve app page cache hits

* fix(app-router): observe loading search params during render

* refactor(app-router): extract searchParams probe fan-out into helper

Move the per-request page probe fan-out (matched page + parallel slot
pages + interception page) out of the generated RSC entry and into a
unit-testable buildAppPageProbes() helper in server/app-page-probe.ts,
keeping entries/app-rsc-entry.ts thin (AGENTS.md guidance). Addresses
bonk review concern #3 on #1788.

Document the known slot over-bail (probing inactive parallel-route slot
pages can mark an otherwise-static page dynamic; fails safe toward
dynamic) and track it as a caching-efficiency follow-up in #1798
(bonk review concern #1).

* fix(app-router): skip probing interception-overridden slot pages

Narrow the searchParams probe fan-out so it only probes page components
that actually render for the request. When an interception matches, it
replaces the page of the slot named by intercept.slotKey (the element
builder sets overrides[slotKey].pageModule to the interception page,
which wins over slot.page in app-page-route-wiring.tsx). We now probe
the interception page in place of that slot's own page instead of
probing both — probing the overridden slot page marked an otherwise
static request dynamic for a component that never renders.

Fixes the over-bail flagged in review of #1788.

* docs(app-router): clarify buildAppPageProbes slot precision

Address review feedback: the doc comment overstated precision without
explaining why non-overridden slots are exact. Spell out that a slot
with a page.tsx always renders that page (so probing it is correct),
default-only slots have no slot.page.default so probing is a no-op
(not an over-bail), and a default.tsx awaiting searchParams is
backstopped by the real render's observation (so no under-bail).

* fix(app-router): gate searchParams interception probe on isRscRequest

Interception only fires for RSC navigations (resolveAppPageInterceptState
returns kind:none when !isRscRequest, app-page-request.ts:324). The probe
fan-out called findIntercept unconditionally, so on HTML requests it would
skip the matched route's slot page (which DOES render normally) and probe
the interception page (which never renders). buildAppPageProbes now ignores
the interception match when !isRscRequest, so HTML requests probe every
slot's own page and skip the interception probe.

The source-route interception case (a different route renders) never reaches
this probe: dispatchAppPage returns the intercepted response before calling
probePage, so any interception seen here is the current-route override case.
Documented both in the buildAppPageProbes doc comment.

Addresses review feedback on #1788.

---------

Co-authored-by: James <james@eli.cx>
2026-06-06 23:47:47 +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
James Anderson 8d00797ada feat(cache): extract Cloudflare cache adapters into @vinext/cloudflare (#1748)
* refactor(cache): extract Cloudflare cache adapters into @vinext/cloudflare

Move the Cloudflare KV data cache and edge CDN cache adapters out of
vinext into a new publishable @vinext/cloudflare package:

  - cache/kv-data-adapter(.runtime).ts  (KVCacheHandler, kvDataAdapter)
  - cache/cdn-adapter(.runtime).ts       (CloudflareCdnCacheAdapter, cdnAdapter)

tpr.ts stays in vinext. vinext now depends on @vinext/cloudflare
(workspace:*) and the package declares vinext as a peer dep; both build
from source via tsconfig paths so there is no build-order cycle. The
vinext/cloudflare barrel still re-exports KVCacheHandler for back-compat.

Wires up tsconfig paths, a vitest source alias, root build/postinstall,
and the preview/publish workflows for the new package. Updates internal
consumers (apps/web, examples/workers-cache), docs, and tests.

* ci(create-next-app): install @vinext/cloudflare from local tarball

vinext now depends on @vinext/cloudflare, which isn't published to npm
yet. The create-next-app smoke test packs vinext locally and resolves
its deps from the registry, so the install (and dev server) failed with
ERR_PNPM_FETCH_404 for @vinext/cloudflare.

Pack @vinext/cloudflare alongside vinext and add a pnpm override in the
scaffolded project pointing at the local tarball so the dependency
resolves offline.

* refactor(cloudflare): address review feedback

- Remove the root barrel export from @vinext/cloudflare; expose only the
  ./cache/* subpaths via a wildcard export (no root main/types).
- vinext/cloudflare re-exports KVCacheHandler from the full subpath.
- Drop the redundant .npmignore (the package.json "files" allowlist
  already restricts the publish to dist).
- Remove the unsupported imperative setCacheHandler/KVCacheHandler usage
  from both READMEs; the cache plugin config is the supported approach.
- Simplify test wiring: drop the now-unused @vinext/cloudflare tsconfig
  path and dedupe the vitest source alias into a shared constant.

* chore(cloudflare): drop unused vite devDependency

The @vinext/cloudflare config uses vite-plus and nothing imports vite, so
the vite devDependency was unused. build/check/knip stay green without it.

* Apply suggestion from @james-elicx
2026-06-05 14:57:20 +01:00
James Anderson f0f6aa72e0 feat(cache): configure cache adapters from vite plugin config (#1733)
* feat(cache): configure cache adapters from vite plugin config

Add a `cache` option to the vinext() plugin so CDN and data cache
adapters can be declared in vite.config instead of calling
setDataCacheHandler() / setCdnCacheAdapter() from a worker entry:

  vinext({
    cache: {
      cdn:  { adapter: require.resolve('vinext/cloudflare/cache/cdn-adapter') },
      data: { adapter: require.resolve('vinext/cloudflare/cache/kv-data-adapter') },
    },
  })

Each slot points at an adapter module whose default export is a factory
(DataCacheAdapterFactory / CdnCacheAdapterFactory). The plugin generates
a virtual:vinext-cache-adapters module that the App Router worker entry
calls per request (self-guarded, once per isolate), passing the host env
so binding-backed adapters (e.g. KV) can read their namespace.

Ships ready-made Cloudflare adapter entry points:
  - vinext/cloudflare/cache/kv-data-adapter  (KVCacheHandler)
  - vinext/cloudflare/cache/cdn-adapter      (CloudflareCdnCacheAdapter)

* feat(cache): add typed adapter builders (kvDataAdapter/cdnAdapter)

Instead of `{ adapter: require.resolve(...) }`, each adapter module now
also exports a config-time builder from the same path:

  import { cdnAdapter } from 'vinext/cloudflare/cache/cdn-adapter';
  import { kvDataAdapter } from 'vinext/cloudflare/cache/kv-data-adapter';

  vinext({ cache: { cdn: cdnAdapter(), data: kvDataAdapter({ binding: 'MY_KV' }) } })

A builder returns a plain, serializable { adapter, options } descriptor —
it never touches the Workers runtime, so nothing throws at config / build
/ dev time when bindings aren't available. Descriptor `options` (e.g. the
KV binding name) are inlined into the generated registration module and
forwarded to the factory's { env, options } context, where the binding is
resolved lazily on the first request.

- shims/cache-adapter: descriptors + options-aware factory/context types
- kv-data-adapter: kvDataAdapter() builder + configurable binding/appPrefix/ttl
- cdn-adapter: cdnAdapter() builder
- raw { adapter, options } path form still supported

* test(cache): verify absolute (require.resolve) local adapter path bundles

Real Cloudflare build pointing cache.data at a local adapter file by
absolute path (what require.resolve('./adapter') yields). Proves the
generated registration module resolves the absolute import, bundles the
local adapter into the worker, and does not need any Workers context at
build time.

* refactor(cache): builder require.resolve + register across all routers/runtimes

Addresses review feedback:

* Move adapters into their own runtime modules instead of re-exporting.
  Each adapter is now a builder module (kv-data-adapter.ts / cdn-adapter.ts)
  plus a sibling *.runtime.ts holding the default-export factory. Type
  definitions have a single home in shims/cache-adapter.ts (dropped the
  re-export shim; index.ts imports the config type from there).

* The exposed builder utility resolves the relative runtime path internally
  via import.meta.resolve (the ESM require.resolve), so the descriptor carries
  an absolute path to the runtime factory rather than a bare specifier — the
  example is just kvDataAdapter({ binding }), no require.resolve at the call site.

* Register configured cache handlers EVERYWHERE, not just the App Router worker:
  - App Router: the generated RSC entry passes registerConfiguredCacheAdapters
    into createAppRscHandler, which calls it per request — covering Workers,
    the Node server, and dev through the one shared handler.
  - Pages Router: the generated server entry registers in renderPage and
    handleApiRoute (Node/dev), and the generated worker registers with env
    (Workers, for KV bindings).
  Registration self-guards (first call with real env wins) and is now resilient:
  a factory that throws on an incompatible runtime is logged and skipped, so the
  default handler stays in place instead of failing every request.

Tests: generator-level assertions that every router/runtime entry wires
registration, plus the existing builder/codegen/factory and full-build coverage.
vp check clean; app-router (339) and pages-router (272) suites pass.

* refactor(cache): keep all Cloudflare adapter code under cloudflare/

The adapter factory contract lived in shims/cache-adapter.ts (outside
cloudflare/), and the Cloudflare adapters reached out to it. Move the
contract into cloudflare/cache/adapter.ts so every Cloudflare-specific
cache adapter file is self-contained under cloudflare/ — importing only
cloudflare-local modules and the core CacheHandler/CdnCacheAdapter
interfaces it implements.

The plugin's config schema (CacheAdapterDescriptor / VinextCacheConfig)
is genuinely framework-level (it's the vinext() `cache` option), so it
moves into the codegen module the plugin already owns; index.ts imports
it from there. Builders return a structural { adapter, options } so they
don't import the descriptor type either. Deletes shims/cache-adapter.ts.

* refactor(cache): merge KV/CDN classes into the runtime adapter files

All Cloudflare cache code now lives in one directory, cloudflare/cache/,
and each runtime file holds both the implementation class and its
config-driven factory (no separate class module to reach for):

  - kv-cache-handler.ts        -> cache/kv-data-adapter.runtime.ts
    (KVCacheHandler + ENTRY_PREFIX + createKvDataCacheAdapter default export)
  - cloudflare-cdn-cache.ts    -> cache/cdn-adapter.runtime.ts
    (CloudflareCdnCacheAdapter + createCloudflareCdnCacheAdapter default export)

Updated importers: cloudflare/index.ts re-exports the classes from the
runtime files, tpr.ts pulls ENTRY_PREFIX from there, shims/cdn-cache.ts
imports the edge adapter from there, and the tests follow the moved paths.
git mv preserves history.

vp check clean; cache/kv/cdn/app-route/tpr/shims suites pass (1300+ tests).

* chore(cache): trim low-value comments added in this branch

Remove narrating/redundant comments that just restated the code; keep
the non-obvious why (registration ordering/resilience, import.meta.resolve
rationale, edge cache-control semantics). No code changes.

* review: address PR #1733 feedback

- Make registerCacheAdapters a required field on the RSC handler options
  (the generated entry already passes it; test factory updated).
- Remove the separate cloudflare/cache/adapter.ts contract file; inline the
  factory param types directly into the two runtime adapters.
- Drop the CloudflareCdnCacheAdapter re-export from cloudflare/index.ts.
- Fold the virtual:vinext-cache-adapters declaration into global.d.ts and
  delete the standalone .d.ts.
- Remove the ./cloudflare/cache/* package.json export for now; README uses a
  local-adapter require.resolve example with a note that the built-in adapter
  export paths are pending.
- Rename the config-driven KV default binding to VINEXT_KV_CACHE (imperative
  deploy/tpr path keeps VINEXT_CACHE — flagged on the thread).

* refactor(cache): align KV binding name to VINEXT_KV_CACHE everywhere

Rename the KV cache binding from VINEXT_CACHE to VINEXT_KV_CACHE across the
whole codebase so the config-driven adapter, the imperative deploy-generated
worker, TPR's wrangler detection, and the apps/web example all agree. The
unrelated X-Vinext-Cache response-header constant (VINEXT_CACHE_HEADER) is
untouched.

* tidy

* .

* .

* .

* .

* .

* Move apps/web cache to plugin config

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

---------

Co-authored-by: ask-bonk[bot] <ask-bonk[bot]@users.noreply.github.com>
Co-authored-by: james-elicx <james-elicx@users.noreply.github.com>
2026-06-04 23:30:33 +00:00
James Anderson d8ca7e8177 test(i18n): align shallow redirect-skip assertion with Next.js history state (#1729)
The i18n sticky-locale change (f4d13e25) made router pushState write a
Next.js-shaped history state ({ url, as, options, __N, key }) instead of
{}, and updated 6 matching assertions in shims.test.ts. The shallow
Router.push redirect-skip test (#1522) carried the same {} assertion but
was missed, breaking the unit suite on main.

Align it with the others: assert the first pushState arg via
expect.objectContaining({ __N: true }) while still verifying the visible
URL committed is /redirect-1.
2026-06-03 22:34:47 +00:00
James Anderson f4d13e25b9 fix(i18n): make locale sticky across client navigations (#1407)
* fix(i18n): make Pages Router locale sticky across client navigations

Refs #1336 (item 2).

Writes a Next.js-compatible history state shape on every router
push/replace, captures the active locale in `state.options.locale`, and
teaches the popstate handler to:

1. Ignore history entries written by third-party code (no `__N: true`).
2. Drop the first popstate when it carries the same locale + `as` as the
   current page (Safari tab-restore / BFCache replay parity).
3. Honour the locale recorded in history state when re-fetching the page
   on back/forward, so default-locale roots still go through their
   locale-qualified HTML endpoint.

This matches the Next.js behaviour exercised by
`test/e2e/ignore-invalid-popstateevent` and the client half of
`test/e2e/i18n-preferred-locale-detection`.

Scope boundary: the server-side `Set-Cookie: NEXT_LOCALE=...` on initial
Accept-Language detection is intentionally NOT modified here. The
existing `parseCookieLocaleFromHeader` already gives the cookie priority
over Accept-Language, and changing where the cookie is written would
overlap with #1336 item 4 (default-locale prefix normalisation).

* fix(i18n): preserve back/forward navigation under new history-state shape

The previous commit attached `__N: true` to router-owned history entries,
but two regressions slipped in:

1. `saveScrollPosition` merges scroll fields onto the *current* entry
   before a push. When the current entry was the initial document load
   (`state === null`), the merge produced a state object without `__N`,
   which the popstate foreign-state filter then dropped — breaking
   browser back/forward.

   Fix: when the existing state is null, mint a router-shaped state
   (`{ __N: true, url, as, options, key }`) before merging the scroll
   fields, so every entry the router touches carries `__N`.

2. The Safari-replay filter compared `state.as` against `window.location`,
   but `window.location` has already changed by the time popstate fires
   on a real back/forward. That made every genuine back navigation look
   like a replay and get ignored.

   Fix: compare against `_lastPathnameAndSearch` (the URL the router
   last actively navigated to). After a real back, that tracker still
   points at the entry we were on, so a genuine navigation differs and
   passes the filter — matching Next.js's `state.as === this.asPath`
   semantics.

Verified locally:
- tests/pages-router-i18n-sticky-locale.test.ts: 7/7 pass
- tests/shims.test.ts: 944/944 pass
- tests/e2e/pages-router/{navigation,before-pop-state,hydration,router-events}.spec.ts:
  28/28 pass (these were the CI failures from the previous commit).

* fix(i18n): stamp initial history entry with active locale on install

Closes the remaining gap in the popstate locale-recovery path: the
initial document entry has `state: null` until the first push, so a
back-navigation to it would fall back to the live `window.__VINEXT_LOCALE__`
— which an intervening locale-changing push may have flipped. Stamping
router-shaped state at install captures the SSR-resolved locale into
`options.locale` and lets the popstate handler restore the correct locale.

Also:
- Strip the hash from `state.as` so the Safari-replay filter's
  `withBasePath(state.as) === _lastPathnameAndSearch` comparison works for
  entries created from a hash-bearing URL (the tracker never carries a hash).
- Drop the unnecessary `withBasePath as withBasePathPrefix` import alias.

Adds 3 tests: install-time stamping, no-clobber of pre-existing state, and
the back-to-initial-entry locale-recovery regression.

* fix(i18n): prefer active locale over default + scope Safari-replay filter

Three small follow-ups discovered by CI on top of the i18n-sticky-locale
work:

1. `getCurrentBrowserLocale` was falling back to `__VINEXT_DEFAULT_LOCALE__`
   before `__VINEXT_LOCALE__` when no path prefix was present, which threw
   away the active locale on default-locale paths served under a
   non-default locale. Flip the fallback order so the active locale wins —
   exactly what the new `pages-router-i18n-sticky-locale.test.ts` 'Router
   .push without an explicit locale' case asserts.

2. The Safari-replay filter on the first popstate event was running even
   after the user had already navigated. After a hash-only `Router.push`,
   the entry's `state.as` is recorded without a hash, which happens to
   match `_lastPathnameAndSearch` (also without a hash); the filter then
   incorrectly dropped a genuine `goBack()` popstate, freezing the E2E
   router-events suite. Gate the filter behind a new `_routerDidNavigate`
   flag so it only fires during the synthetic initial-load replay window.

3. Update the 6 `expect(pushState).toHaveBeenCalledWith({}, ...)`
   assertions in `tests/shims.test.ts` to use
   `expect.objectContaining({ __N: true })`, since pushState now carries a
   Next.js-compatible state object instead of `{}`.

* test(i18n): align unprefixed-path locale tests with sticky-locale behavior

The previous "treat unprefixed path as default locale" assertions in
tests/link.test.ts and tests/shims.test.ts contradicted the intentional
sticky-locale flip in ab662c07 (getCurrentBrowserLocale now prefers
__VINEXT_LOCALE__ over __VINEXT_DEFAULT_LOCALE__). Update both tests to
assert the new behavior: a default-locale path served under a non-default
locale keeps its active locale for client-side root navigations.
2026-06-03 23:12:43 +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
James Anderson 1047cccd8a fix(metadata): omit unused parent arg for cached generateMetadata (#1719)
* fix(metadata): omit unused parent arg for cached generateMetadata

A module-level `'use cache'` directive wraps `generateMetadata` in the cache
runtime. vinext always called it as `generateMetadata(props, parent)`, where
`parent` resolves to the merged ancestor metadata. When an ancestor sets
`metadataBase: new URL(...)`, the cache-key encoder (`encodeReply`) awaited the
parent promise and tried to serialize the `URL` instance, logging React's
"Only plain objects can be passed to Server Functions from the Client. URL
objects are not supported." warning.

Match Next.js, which omits the `parent` argument for `generateMetadata`
functions that don't use it (resolve-metadata.ts `getResult` /
`useCacheFunctionInfo.usedArgs[1]`):

- `resolveModuleMetadata` now only passes `parent` when `generateMetadata`
  declares it (`fn.length >= 2`).
- `registerCachedFunction` preserves the original function's arity on the
  wrapper so the arity check works for cached functions.

Adds regression tests for the arity gating, metadataBase preservation, and
arity-preserving wrapper.

* docs(metadata): note arity approximates parent usage; drop dead try/catch

Address /bigbonk review feedback:
- Document that fn.length approximates Next.js's usedArgs[1] analysis and
  diverges on default-parameter signatures.
- Remove the dead try/catch around Object.defineProperty (function length is
  always configurable per spec).
2026-06-02 11:50:17 +01:00