Commit Graph

28 Commits

Author SHA1 Message Date
James Anderson 5343a3ff32 fix(app-router): cache segment prefetches by rendered search params (#2477)
* fix(app-router): cache segment prefetches by rendered search params

* fix(app-router): protect rendered prefetch alias header

* fix(app-router): repair segment prefetch merge
2026-07-02 19:42:46 +00:00
James Anderson 239595e69e fix(app-router): reuse metadata rewrite prefetches (#2455)
* fix(app-router): reuse metadata rewrite prefetches

* fix(app-router): normalize rsc reference validation ids

* Revert "fix(app-router): normalize rsc reference validation ids"

This reverts commit bbff1eb13a.

* chore: format link shim
2026-07-01 17:02:35 +00:00
James Anderson ebf8e59289 fix(app-router): avoid runtime route-tree prefetch sizing (#2467)
* fix(app-router): start optimistic prefetches immediately

* fix(app-router): support route tree prefetch inlining

* fix(app-router): dedupe max prefetch inlining requests

* fix(app-router): cap route-tree prefetch size reads

* fix(app-router): avoid runtime route-tree prefetch sizing

* Fixed CI type error, flagged parity gap

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

* fix(app-router): preserve negative prefetch inlining thresholds

* fix(app-router): normalize rsc reference validation ids

* Revert "fix(app-router): normalize rsc reference validation ids"

This reverts commit 68b96b596c.

* fix(app-router): keep route-tree prefetch metadata scoped

---------

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-07-01 17:47:08 +01:00
James Anderson bc1dfce00f fix(app-router): evict segment prefetches under memory pressure (#2466)
* fix(app-router): evict segment prefetches under memory pressure

* fix(app-router): hold prefetch queue slots until body read
2026-07-01 10:54:47 +01:00
James Anderson ad14e9b039 fix(app-router): avoid repeated hover prefetches (#2396)
* fix(app-router): avoid repeated hover prefetches

* fix(app-router): floor prefetch fallback stale time
2026-06-28 23:24:30 +01:00
James Anderson f0ba7b4afe fix(app-router): reuse committed client cache payloads (#2251)
* fix(app-router): seed client navigation cache

* fix(cache): parse prerender cache metadata marker

* test(cache): cover prerender metadata done marker

* fix(cache): preserve static committed stale time

* fix(link): honor dynamic stale time for auto prefetches

* fix(router): retain committed client cache state

* fix(router): publish client cache after commit

* fix(router): gate hydration cache publication

* fix(router): guard late client cache publication

* fix(router): isolate client cache parity coverage

* fix(router): preserve authoritative navigation commits

* fix(router): preserve committed navigation streams
2026-06-28 22:01:58 +00:00
James Anderson c5bd7b33c2 fix(app-router): skip prefetches for bots (#2323)
* fix(app-router): skip prefetches for bots

* fix(link): scope bot suppression to app prefetch

* fix(app-router): skip form prefetches for bots
2026-06-26 19:59:29 +01:00
Nathan Nguyen b5619c19f9 refactor(navigation): route prefetch reuse through planner (#1962)
* refactor(navigation): route prefetch reuse through planner

Navigation cache reuse decisions were still split across the browser executor, which made visited-response reuse, prefetch consumption, and optimistic shell attempts hard to verify at the control-plane boundary.

Move the reuse decision table into navigationPlanner with typed candidate facts, keep cache reads and React effects in the executor, and preserve silent stale-prefetch eviction for navigation probes.

* refactor(navigation): exercise visited cache miss planning

Visited response cache misses were represented in the planner API, but the browser executor still returned before invoking that classifier when the entry was absent or intentionally bypassed.

That made the missing-candidate branch planner-owned in tests but executor-local in production. The executor now builds candidate facts for both present and missing states, asks the planner for the decision, and keeps cache mutation in the executor as the effect boundary.

* fix(navigation): avoid prefetch probes on visited cache hits

Visited-response reuse should stay on the soft-navigation fast path without touching the prefetch cache. The planner extraction accidentally computed prefetch availability before deciding to reuse a visited response, so stale prefetch entries could be silently evicted on a path that previously left them untouched.

Add a planner prefetch-probe decision and have the browser executor consult it before reading the prefetch cache. This keeps cache mutation in the executor while making the no-probe condition explicit and tested.

* fix(navigation): preserve prefetch fallback behavior

* fix(navigation): keep slot mismatch probe behavior

---------

Co-authored-by: James <james@eli.cx>
2026-06-13 22:12:34 +01:00
Nathan Nguyen 610cf2e63a fix(app-router): honor per-response dynamic stale times on the client (#1712)
* fix(app-router): honor per-response dynamic stale times on the client

App Router navigation reused and evicted cached RSC responses using one global prefetch TTL, so pages with different unstable_dynamicStaleTime values could not stay fresh independently. Traversal also needed a route instance boundary so browser history restores hidden state instead of reusing a forward-navigation component instance.

The client cache now snapshots dynamic stale-time response metadata, computes expiry per entry, preserves staleAt across cache replays, and derives mounted-slot cache variants from the decoded payload. Browser traversal keeps route instances under React Activity while normal navigations allocate fresh route instances, matching Next's BFCache/stale-time contract.

* fix(link): make prefetch dedupe freshness-aware for navigation prefetches

The old prefetched.has(cacheKey) gate in prefetchUrl returned early for
any exact key match, including stale entries. This meant an expired
prefetch with the same cache key would never reach
hasPrefetchCacheEntryForNavigation, so it would never be deleted and
never refreshed.

The new helper is built to check resolvePrefetchCacheEntryExpiresAt,
delete stale entries, and return false. But the Link path bypassed it
when the old prefetched Set contained the exact key.

Fix: gate the exact-key check on the helper's freshness decision. When
the helper reports the exact entry is stale and deletes it, also remove
the key from the prefetched Set so the fall-through path can issue a
fresh fetch. Keep the second helper call for equivalent _rsc variants.

Add an integration test proving visible Links re-prefetch when their
exact cache entry goes stale, not just that the helper reports stale
entries as unavailable.

* refactor(app-router): collapse redundant prefetch/visited-cache gates

Address review feedback on the client dynamic-stale-time changes:

- link.tsx: the prefetch dedupe path called
  hasPrefetchCacheEntryForNavigation twice when an exact entry already
  existed. Collapse to a single freshness-aware gate that covers both the
  exact entry and equivalent _rsc variants.
- app-browser-entry.ts: drop the redundant early refresh return in
  getVisitedResponse; isVisitedResponseCacheEntryFresh already bypasses
  the cache for refresh navigations (and the miss path evicts the entry).

* docs(app-router): document intentional refresh eviction in visited cache

Per review feedback: getVisitedResponse now routes refresh navigations
through isVisitedResponseCacheEntryFresh (the single source of truth),
which returns false for refresh and therefore falls through to the read
eviction. Document at the fall-through delete that a refresh intentionally
drops any prior snapshot, since the navigation re-fetches and re-stores a
fresh one.

* docs(navigation): clarify load-bearing prefetch-cache dual checks

Per review feedback, document two checks that look superficially redundant
but preserve observable contracts:

- isPrefetchCacheEntryCompatibleWithMountedSlots: the two clauses accept
  either the entry's pinned request-time slot context or the server-declared
  snapshot header; they diverge in the prefetchRscResponse case.
- consumePrefetchResponse expiresAt guard: only synthesize expiresAt onto the
  returned snapshot when the entry already carried one, so entries without an
  explicit expiry round-trip unchanged for callers/tests.

---------

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

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

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

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

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

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

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

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

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

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

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

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

* fix: extract shared decodeRedirectError helper, rename stale test

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

* refactor: eliminate unnecessary type assertions

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

* ci: retrigger checks

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

* docs: clarify decodeRedirectError comment about defensive join
2026-06-01 12:37:27 +01:00
Nathan Nguyen 66519412c3 fix(app-router): reuse pending prefetched RSC payloads (#1704) 2026-05-31 20:08:13 +01:00
James Anderson cd62cb2ebb fix(app-router): honor experimental.staleTimes in client prefetch cache (#1490) (#1660)
* fix(app-router): honor experimental.staleTimes in client prefetch cache (#1490)

The App Router's client-side prefetch cache previously used a hard-coded
30s TTL and ignored `experimental.staleTimes` from next.config. As a
result, users could not extend (or shorten) how long prefetched route
segments were considered fresh, breaking parity with Next.js'
`test/e2e/app-dir/app-client-cache/client-cache.experimental.test.ts`.

Surface `experimental.staleTimes.{dynamic,static}` through
`ResolvedNextConfig` and inject it into the client bundle as
`process.env.__NEXT_CLIENT_ROUTER_{DYNAMIC,STATIC}_STALETIME` (matching
the Next.js variable names plumbed by `build/define-env.ts`). The
prefetch cache TTL in `shims/navigation.ts` now reads the static value
at module load time, converting seconds to ms.

Defaults mirror Next.js (`dynamic: 0`, `static: 300`). When the env var
is absent — primarily in unit tests that import the module outside the
plugin's `define` pipeline — we fall back to vinext's historical 30s
default rather than the Next.js 300s so existing tests keep passing.

Refs: .nextjs-ref/packages/next/src/client/components/router-reducer/reducers/navigate-reducer.ts
Refs: .nextjs-ref/packages/next/src/build/define-env.ts

* review: address bot feedback on staleTimes PR

- Remove invokeLoaderSideEffects scope creep (belongs to #1500 / PR #1657)
- Reject negative staleTimes at resolution time alongside Number.isFinite,
  matching the seconds < 0 guard in resolvePrefetchCacheTtl
- Drop unused __NEXT_CLIENT_ROUTER_DYNAMIC_STALETIME define; vinext does
  not yet distinguish prefetch kinds, so the value was never consumed
2026-05-28 20:59:44 +01:00
Nathan Nguyen 82b6388ece fix(app-router): render optimistic loading shells for dynamic navigation (#1421)
* fix(app-router): render optimistic loading shells for dynamic navigation

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

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

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

* chore(app-router): address optimistic routing review
2026-05-22 10:07:26 +01:00
Nathan Nguyen 0d8d3e3c60 refactor(router): quarantine navigation runtime topology (#1322)
* refactor(router): quarantine navigation runtime topology

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

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

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

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

* fix(router): narrow prerender RSC script parsing

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

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

* fix(router): address navigation runtime review

* fix(router): resolve navigation runtime review threads
2026-05-20 10:29:13 +01:00
Nathan Nguyen a5805b655a fix(navigation): support prefetch invalidation callbacks (#1244) 2026-05-15 21:52:16 +00:00
Nathan Nguyen d3f028206f fix(navigation): throw when app router context is missing (#1242)
next/navigation useRouter returned the module-level app router singleton even when no AppRouterContext provider was mounted. That masked components rendered outside the App Router boundary, diverging from Next.js and hiding integration bugs.

The shim now reads the mounted AppRouterContext and throws the Next.js invariant when it is absent. The App Router browser and SSR roots provide the existing appRouterInstance so legitimate app renders keep the same public router surface.
2026-05-15 17:33:16 +01:00
James Anderson b4898289e7 fix(app-router): match Next.js RSC Content-Type and 404 plain-text body (#1233)
* fix(app-router): match Next.js RSC Content-Type (no charset suffix)

Next.js sets the RSC (React Server Components / 'flight') response
Content-Type to exactly 'text/x-component' (RSC_CONTENT_TYPE_HEADER in
.nextjs-ref/packages/next/src/client/components/app-router-headers.ts:17,
used by FlightRenderResult in
.nextjs-ref/packages/next/src/server/app-render/flight-render-result.ts:15).
vinext was sending 'text/x-component; charset=utf-8'. The suffix breaks
Next.js's own segment-cache deduplication checks (action-handler.ts uses
.startsWith(RSC_CONTENT_TYPE_HEADER) on the unsuffixed string) and fails
Next.js e2e tests that assert strict equality, e.g.:
  - test/e2e/app-dir/app/index.test.ts L362, L371
  - test/e2e/app-dir/segment-cache/deployment-skew/deployment-skew.test.ts L80

Replace the duplicated literal at seven sites with the existing
VINEXT_RSC_CONTENT_TYPE constant in app-rsc-cache-busting.ts.

* fix(app-router): match Next.js 404 plain-text body 'This page could not be found'

Next.js's plain-text 404 response body is exactly
'This page could not be found' (no trailing period in the response body;
the React-rendered not-found component uses the same text with a period).
vinext was returning 'Not Found' / '404 Not Found' / '404 - Not found',
which fails Next.js e2e tests that assert .toContain('This page could
not be found'), e.g.:
  - test/e2e/app-dir/app/index.test.ts (several cases)
  - test/e2e/app-dir/parallel-route-not-found/parallel-route-not-found.test.ts L52
  - test/e2e/app-dir/prefetching-not-found/prefetching-not-found.test.ts L16

Next.js source citations (in .nextjs-ref):
  - packages/next/src/server/route-modules/pages/pages-handler.ts L121, L535
  - packages/next/src/build/templates/app-route.ts L170, L349
  - packages/next/src/build/templates/app-page.ts L701, L1043
  - packages/next/src/pages/_error.tsx L7
  - packages/next/src/client/components/builtin/not-found.tsx L7 (HTML variant)

Updates the body in notFoundResponse() and the open-redirect / pages-router
SSR-fallback / worker-entry inline 404 sites. The HTTP status reason phrase
('Not Found') is unchanged. Route-specific bodies like '404 - API route not
found' are intentionally retained.
2026-05-15 12:39:05 +01:00
Nathan Nguyen 4aa7663084 fix(app-router): hard navigate stale RSC build payloads (#1178)
* fix(app-router): hard navigate stale RSC build payloads

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

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

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

* chore: fix e2e fixture hook lint suppression

* chore: avoid hook-like fixture callback name

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

* fix(app-router): use public RSC compatibility IDs
2026-05-15 12:01:33 +01:00
Nathan Nguyen 9469e7798e fix(router): gate refresh and traverse outcomes (#1102)
* fix(router): gate refresh and traverse outcomes

Implements #726-CORE-05/06A and #726-CORE-06B from issue #726.

Compare pending operation visible commit versions against the latest visible state before applying refresh, traverse, or same-URL navigation payloads. Return explicit navigation payload outcomes so stale and hard-navigation results settle without seeding visited-response cache entries.

Make router.prefetch entries expose a cache-seeded outcome only after the snapshot is ready and pending work is cleared, and keep the ambient next/navigation declarations aligned with the runtime cache shape.

* chore: retrigger ci
2026-05-06 17:47:08 +01:00
Nathan Nguyen b94650bcfb refactor(app-router): fence AppElements wire-key construction (#1088)
* refactor(app-router): fence AppElements wire-key construction

AppElements transport keys could still be constructed or recognized through scattered raw string prefixes outside the wire codec. That kept the flat payload format acting as an informal semantic API and left legacy compatibility behavior covered only by indirect route tests.

Make AppElementsWire the exported constructor and parser boundary for route, page, layout, template, slot, cache, metadata, and unmatched-slot wire values. Route graph semantic ID minting stays separate, while render wiring, layout classification, browser slot merging, and prefetch cache tests now consume the codec boundary.

Tests cover canonical key construction and parsing, legacy metadata read/write behavior, unmatched-slot marker compatibility, and a source-boundary check that rejects new raw AppElements wire-key construction outside the codec and route graph.

* refactor(app-router): address AppElements wire review

Make slot-key recognition a cheap structural predicate, document the absolute tree-path parser contract, and broaden the boundary coverage to catch raw wire-key concatenation.

Also route layout classification ids through AppElementsWire so raw layout-key construction stays fenced inside the codec boundary.
2026-05-06 08:23:01 +01:00
Nathan Nguyen 57e6b7001e feat(app-router): track visible commit versions for browser operations (#1061)
Browser router state changed without a versioned visible commit baseline or a typed operation record, leaving later stale-result gates without a stable comparison point.

Add pending and committed OperationRecord metadata, thread explicit operation lanes through browser navigation, refresh, traverse, same-URL server actions, and HMR, and advance visibleCommitVersion through the reducer path that accepts a new visible router-state commit.

Also harden the prefetch-cache unit test to wait for async router.prefetch setup rather than relying on a fixed scheduler tick.
2026-05-05 19:05:51 +01:00
Nathan Nguyen 9d5d1ce50d fix(app-router): validate RSC cache-busting params (#991)
* fix(app-router): validate RSC cache-busting params

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

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

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

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

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

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

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

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

Client-side RSC fetches always send the RSC: 1 header, so restrict the
.rsc destination conversion to requests that carry that header. Direct
browser hits without the header continue to redirect to the canonical
HTML URL, matching the pre-PR behaviour for config redirects.
2026-05-04 18:55:43 +01:00
Nathan Nguyen 5f6bccbd03 feat: track previousNextUrl for intercepted App Router entries (#755)
* docs: refresh stale app browser entry comments

* feat: track previousNextUrl for intercepted App Router entries

* refactor: bind getBrowserRouterState() once in refresh case

Avoid calling getBrowserRouterState() twice in the refresh branch of
getRequestState by binding previousNextUrl to a local. Behavior-preserving
nit raised in PR review.

* refactor: drop dead history-state interception-context read path

After previousNextUrl became the source of truth for interception context
in history state, nothing in production writes the legacy
__vinext_interceptionContext key anymore. The fall-through read in
getCurrentInterceptionContext was vestigial and getCurrentInterceptionContext
now derives strictly from the current pathname.

- Removes the unused VINEXT_INTERCEPTION_CONTEXT_HISTORY_STATE_KEY export
- Removes readHistoryStateInterceptionContext
- Updates the prefetch-cache unit test to assert the new pathname-derived
  contract instead of the legacy history-state override

* test(e2e): cover back/forward restoration of intercepted modal view

Adds the back-then-forward case for App Router intercepting routes:
after intercepted navigation from /feed to /photos/42, going back to
/feed and then forward should restore the modal (rather than rendering
the full /photos/42 page). Directly exercises the previousNextUrl read
in the traverse branch of getRequestState.

* docs: reword stale 'belongs to PR 5' comments

Two inline comments in app-browser-entry.ts referenced "PR 5" as a future
PR, but this is PR 5. Reworded to describe the deferred scope as-is:
interception context on server-action POSTs and HMR re-renders is
intentionally not propagated.

* fix: distinguish explicit null from undefined in previousNextUrl fallback

---------

Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: James <james@eli.cx>
2026-04-12 13:01:50 +00:00
Nathan Nguyen b8c44fdd52 feat: encode interception context in App Router payload IDs and caches (#753)
* test: add E2E verification for layout persistence flat payload pipeline

Prove the flat keyed map architecture works end-to-end:
- Layout state persists across sibling navigation (counter survives)
- Template remounts on segment boundary change, persists within segment
- Error boundary clears on navigate-away-and-back
- Back/forward preserves layout state through history
- Parallel slots persist on soft nav, show default.tsx on hard nav

Zero production code changes — test fixtures and Playwright specs only.

* test: harden layout persistence hydration assertions

* test: address layout-persistence spec gaps

* test: tighten not.toBeAttached() assertions for absent elements

* chore: trigger CI

* feat: encode interception context in App Router payload IDs and caches

* test: update App Router entry snapshots for interception encoding

* fix: reuse committed interception context for soft navigations

* chore: fix formatting in app-browser-entry after rebase

* fix: update mounted-slot test helper calls for 4-arg createResolvedElements signature

PR 3 added mounted-slot tests using createResolvedElements(routeId, root, extraEntries).
PR 4 inserted interceptionContext as the 3rd param, shifting extraEntries to 4th.
The object was being swallowed as interceptionContext, causing getMountedSlotIds to return [].
Also add missing renderId to the interception context pending-commit test.

* chore: document intentional interception context omission in HMR handler

Add a comment to the HMR rsc:update handler noting that interception
context is intentionally omitted, matching the existing documentation
pattern in the server action handler.

https://claude.ai/code/session_01EnvJuSwsZS9n862q7Veamo

* fix: prefetch cache test now exercises interception-context partitioning

The test was passing pre-computed cache keys as the rscUrl parameter to
storePrefetchResponse(), which internally calls createAppPayloadCacheKey
again. With the default interceptionContext=null the key passed through
unchanged, so the test only verified Map key uniqueness rather than the
actual partitioning logic. Pass raw RSC URLs with separate interception
context parameters instead.

Also add a comment clarifying that PendingNavigationCommit's top-level
interceptionContext/rootLayoutTreePath/routeId are convenience aliases
for the corresponding action fields.

https://claude.ai/code/session_01EnvJuSwsZS9n862q7Veamo

* fix: strip NUL bytes from interception context request header

Defensively remove \0 from the incoming X-Vinext-Interception-Context
header to prevent a malicious client from crafting cache keys that
contain the internal NUL separator.

https://claude.ai/code/session_01EnvJuSwsZS9n862q7Veamo

* fix: resolve rebase conflicts in slot preservation tests

After rebase onto upstream main, two fixes needed:
- Pass extraEntries as 4th arg to createResolvedElements (3rd is now
  interceptionContext)
- Add missing interceptionContext field to test action objects

https://claude.ai/code/session_01EnvJuSwsZS9n862q7Veamo

* test: update entry template snapshots for NUL-byte header sanitization

https://claude.ai/code/session_01EnvJuSwsZS9n862q7Veamo

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-04-11 17:28:31 +01:00
Nathan Nguyen ca0d542947 Fix parallel slot persistence and cache variants (#811)
* Fix parallel slot persistence and cache variants

* Address App Router review follow-ups

* Fix generated mounted-slot regex escaping

* Address review feedback on slot normalization and cache comments

Align generated entry's __normalizeMountedSlotsHeader regex with the
typed module in app-elements.ts (both now use /\s+/). Clarify the
HTML vs RSC cache asymmetry comment in stale regen path.

* Remove redundant .map(trim) and clarify regen slot context

split(/\s+/) never produces tokens with leading/trailing whitespace,
so .map(trim) was a no-op in both normalizeMountedSlotsHeader and its
generated counterpart. Also add a comment clarifying that background
regen intentionally inherits the triggering request's slot context.

* Clarify regen dedup and prefetch slot-mismatch tradeoffs

Add comments explaining that pathname-only regen dedup is intentional
(avoids concurrent regen storms across slot variants) and that
consumePrefetchResponse unconditionally removes the entry before the
slot-mismatch check (simplest correct behavior, prefetch is wasted on
mismatch).

* Document storePrefetchResponse slot-awareness gap

Add JSDoc note that storePrefetchResponse is slot-unaware: the
snapshot's mountedSlotsHeader comes from the response headers rather
than the caller, so consumePrefetchResponse may reject the entry on
slot context mismatch.
2026-04-10 15:29:33 +01:00
Divanshu Chauhan (divkix) ef4192db49 fix: cross-route client navigation hangs in Firefox (#652) (#690)
* fix: cross-route client navigation hangs in Firefox (#652)

Replace flushSync-based RSC tree rendering with a two-phase navigation
commit that uses startTransition for same-route navigations and
synchronous updates for cross-route navigations. This fixes the Firefox
hang where startTransition never commits when the entire component tree
is replaced, and also resolves the Suspense double-flash from #639.

Key changes:
- navigation.ts: ClientNavigationState on Symbol.for global for module
  instance safety, render snapshot context for hook consistency during
  transitions, RSC response snapshot/restore for visited cache, unified
  navigateClientSide() entry point, history suppression helpers
- app-browser-entry.ts: BrowserRoot component with useState-managed tree,
  NavigationCommitSignal that defers URL commit to useLayoutEffect,
  visited response cache with LRU eviction, navigation ID counter for
  stale navigation bailout, manual scroll restoration
- link.tsx: remove duplicated helpers, delegate to navigateClientSide
- form.tsx: delegate App Router GET navigation to navigateClientSide

Closes #652

* fix: detect React Flight Thenables in updateBrowserTree and apply params eagerly

createFromFetch() returns a Thenable (not instanceof Promise), so the
async branch was never taken. The raw unresolved Thenable was set as
React children, causing suspension without a Suspense boundary and
empty content after cross-route navigations.

- Duck-type the .then() check to handle both Promises and Thenables
- Replace stageClientParams with applyClientParams so useSyncExternalStore
  subscribers see correct params immediately
- Remove dead stageClientParams function and replaceClientParamsWithoutNotify import

Fixes useParams E2E failures in CI (hooks.spec.ts:161, hooks.spec.ts:180).

* fix: await createFromFetch before passing RSC payload to tree

The Flight Thenable from createFromFetch was passed unresolved to
renderNavigationPayload/updateBrowserTree. The .then() callback fired
unreliably across consecutive navigations, causing empty content.

Await createFromFetch in both navigation paths (cached and fresh) so
renderNavigationPayload always receives a resolved ReactNode. Add stale
navigation checks after the new await points.

The HMR handler and server action handler already awaited correctly.

* fix: preserve app router params across cached navigation

Preserve the X-Vinext-Params header when snapshotting prefetched and visited RSC responses so cached navigations keep dynamic params intact. Stage client params until the URL commit lands so useParams, history state, and redirect handling stay aligned during App Router transitions.

* fix: address review feedback on navigation caching and history management

- Remove double history push for RSC navigations: let the commit effect
  in navigateRsc own push/replace exclusively. Also fixes a hidden bug
  where the early push broke isSameRoute detection (always true),
  negating the Firefox cross-route hang fix.
- Enforce prefetch cache size limits in prefetchRscResponse() by
  extracting shared eviction logic from storePrefetchResponse().
- Add TTL check in consumePrefetchResponse() so expired snapshots are
  discarded instead of served as stale UI.
- Prevent navigationSnapshotActiveCount leak when async payloads reject
  in updateBrowserTree by adding error handlers that balance the
  activate/commit pairing.
- Move BrowserRoot setBrowserTreeState assignment from render to
  useLayoutEffect to avoid side effects under React Strict Mode.
- Make visited response cache true LRU (delete+set on hit) and add a
  30-minute upper bound for traversal cache hits to prevent arbitrarily
  stale back/forward content.

* fix(navigation): address PR review feedback for Firefox navigation fix

Fixes the following review issues from ask-bonk:

Major fixes:
- Fix prefetch cache eviction order to add entry first, then evict (prevents
  temporary overflow past MAX_PREFETCH_CACHE_SIZE)
- Add error handling in renderNavigationPayload to ensure
  navigationSnapshotActiveCount is decremented on synchronous errors,
  preventing counter leaks

Minor fixes:
- Add cross-reference comments between Pages Router and App Router scroll
  restoration handlers (navigation.ts and app-browser-entry.ts)
- Add JSDoc to getPathnameSnapshot/getSearchParamsSnapshot explaining cached
  behavior vs live window.location reads
- Add comment documenting server action navigation flow (doesn't use
  activateNavigationSnapshot because it stays on same URL)
- Add comment explaining Map iteration safety in resolveCommittedNavigations
- Use stripBasePath in isSameRoute comparison for robust basePath handling
- Explicitly pass undefined for historyUpdateMode in redirect chains with
  explanatory comment (prevents double URL push)

All 1094 tests pass.

* fix: prevent stale navigation bailout from leaking staged params

Addresses review feedback on PR #690:
- Move stageClientParams() to after final navId check in both cached and fresh paths
- Build navigationSnapshot from local params instead of latestClientParams
- Move storeVisitedResponseSnapshot after final stale check
- Add doc comment about history method patching behavior

This fixes a race where cancelled navigations could leak their staged params
into subsequent unrelated navigations when commitClientNavigationState()
was called later without clearing the pending state.

Test: Add E2E test for cancelled slow navigation param leak scenario

* fix: remove duplicate onRouterTransitionStart call and fix formatting

The refactor to delegate Link navigation to navigateClientSide() left a
duplicate notifyAppRouterTransitionStart() call in link.tsx, causing the
callback to fire twice per push navigation.

* fix(navigation): address code review feedback on Firefox cross-route navigation hang fix

Fixes four edge cases identified in code review:

1. NavigationCommitSignal cleanup now resolves pending promises on unmount
   to prevent hanging callers if React unmounts the component (e.g., error
   boundary triggers full-page nav)

2. Added dev-mode warning for navigationSnapshotActiveCount going to 0 before
   decrement to catch activate/commit pairing bugs early

3. Changed prefetch eviction from if to while loop to match visited cache
   pattern and prevent temporary cache overflow during rapid prefetch calls

4. Moved storeVisitedResponseSnapshot below final navId check to avoid storing
   stale cache entries from superseded navigations

Also added cross-reference comments between the two popstate handlers in
app-browser-entry.ts and navigation.ts for future maintainers.

* fix(navigation): resolve infinite recursion in getServerSearchParamsSnapshot

The function getServerSearchParamsSnapshot had infinite recursion when
called on the server - it would call itself infinitely when
getClientNavigationState() returned null.

Fixed by implementing proper server context handling:
- Use _getServerContext() to access server navigation context
- Cache ReadonlyURLSearchParams wrapper using symbol-based caching
- Return cached empty instance when no context available
- Follows established patterns from getServerParamsSnapshot and
  getSearchParamsSnapshot

Fixes CI failures in PR #690 where 18 tests were failing with
"Maximum call stack size exceeded" errors during SSR.

* style(navigation): fix lint errors from stricter oxlint rules

Convert interface declarations to type aliases per consistent-type-definitions rule:
- CachedRscResponse, PrefetchCacheEntry, ClientNavigationRenderSnapshot
- VisitedResponseCacheEntry in app-browser-entry.ts

Add oxlint-disable block comments for react-hooks/rules-of-hooks violations:
- useChildSegments, useClientNavigationRenderSnapshot, usePathname
- useSearchParams, useParams

The original conditional hook pattern (if isServer return early) is intentional
and required for SSR/client duality. Server components must not call client-only
React hooks. Block-level disable comments suppress the false positives while
maintaining the correct runtime behavior.

* fix(navigation): address review feedback on Firefox cross-route navigation fix

- Fix isHashOnlyChange to strip basePath for consistent comparison with isSameRoute
- Add snapshotPending guard to prevent double-decrement in catch block
- Document storePrefetchResponse API contract (callers manage prefetchedUrls)
- Remove duplicate prefetched.add from _appRouter.prefetch (ownership now in prefetchRscResponse)
- Add invariant comment to drainPrePaintEffects explaining activate/commit pairing
- Improve test error handling in cancelled navigation test with debug logging

* fix: address PR review — prefetch dedup, comments, and test robustness

- Add dedup guard to router.prefetch() matching link.tsx pattern
  (check/add prefetchedUrls before calling prefetchRscResponse)
- Fix incorrect comments claiming prefetchRscResponse manages prefetchedUrls
- Add doc comment to storePrefetchResponse noting caller owns URL tracking
- Document module-level state assumption in app-browser-entry.ts
- Add inline comments in drainPrePaintEffects for winning/superseded pairing
- Add element visibility assertion in E2E cancelled-navigation test

* chore: review changes implemented

* Address ask-bonk review feedback: remove dead code and add defensive comments

- Remove vestigial response field from PrefetchCacheEntry (never read by consumePrefetchResponse)
- Add defensive comment for _snapshotPending guard brittleness
- Document isSameRoute race condition during rapid navigations
- Update test helper to use snapshot instead of response

All changes are non-behavioral documentation/clarity improvements.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* fix: prevent double-decrement of navigationSnapshotActiveCount on renderNavigationPayload throw

When updateBrowserTree throws synchronously inside renderNavigationPayload,
the inner catch already calls commitClientNavigationState() and re-throws.
Previously the outer navigateRsc catch saw _snapshotPending === true and
decremented again, leaving the counter off by 1 for concurrent navigations.

Fix by wrapping each renderNavigationPayload call in try-finally that always
clears _snapshotPending, so the outer catch guard correctly skips the
already-committed case.

* docs: document latestClientParams staging invariant in stageClientParams

Per bonk review: stageClientParams diverges latestClientParams from
ClientNavigationState.clientParams between staging and commit. Server
action snapshots read latestClientParams, so add a comment clarifying
this narrow race is acceptable since the commit effect fires synchronously
in the same React commit phase.

* fix: address two blocking concerns from bonk review

1. Guard outer catch against hard-navigating to stale URL when superseded:
   - Hoist navId above the try block so it is accessible in the catch
   - Add 'if (navId !== activeNavigationId) return' before window.location.href
   - Move navId check before createFromFetch in cached route path to avoid
     wasted parse work and the same stale-URL risk on throw

2. Guard handleAsyncError in updateBrowserTree against counter corruption:
   - Add snapshotActivated parameter (default false) to updateBrowserTree
   - Only call commitClientNavigationState() when snapshotActivated is true
   - renderNavigationPayload passes true; server actions and HMR pass false
     (they never call activateNavigationSnapshot so must not decrement)
   - Update comments on server action and HMR call sites to document why

Also add a note on restoreRscResponse that reconstructed Response.url is
always empty string — the Response constructor does not accept a url option.

* fix: tighten cached-route stale-nav guards and fix stale comment

- Add navId check between cachedNavigationSnapshot creation and
  createFromFetch in the cached route path, so superseded navigations
  bail out before the async parse rather than only after
- Fix stale line reference in stageClientParams comment (533 -> 544/558)
- Document slow-route fixture delay / click timeout coupling in E2E test
  so future changes to the fixture delay don't silently break the test

* fix: stage params after _snapshotPending=true in both nav paths, fix stale comment

Move stageClientParams() to after _snapshotPending = true in both the
cached-route path and the non-cached path. This makes the intent clearer:
params are staged as part of an in-flight snapshot, not as a standalone
side-effect before the snapshot flag is set.

If renderNavigationPayload throws synchronously, its inner catch calls
commitClientNavigationState() which flushes pendingClientParams — committing
params for a route that never rendered. The hard-nav fallback in the outer
catch resets everything, so this was self-correcting, but reordering makes
the relationship explicit and guards the intent.

Also replace the stale "lines 544/558" reference in the stageClientParams
comment with the function name (registerServerActionCallback) so it doesn't
rot as the file evolves.

* fix: expand module-singleton comment, remove redundant stale-nav check, document BASE URL

- Expand the module-level state comment to call out Map fragmentation as
  the most severe consequence of multiple instances: pendingNavigationCommits
  and pendingNavigationPrePaintEffects would split across instances, causing
  drainPrePaintEffects to miss effects queued by the other instance and
  permanently leaking navigationSnapshotActiveCount.

- Remove the redundant navId !== activeNavigationId check between
  createClientNavigationRenderSnapshot (synchronous) and createFromFetch
  (async). The pre-check before createClientNavigationRenderSnapshot is
  sufficient; the meaningful post-check is after the await of createFromFetch.
  Added a comment noting the function is synchronous so no recheck is needed.

- Add a comment to BASE in navigation-regressions.spec.ts explaining it
  follows the established repo convention (same pattern in navigation.spec.ts,
  server-client-only.spec.ts, after.spec.ts, etc.).

* docs: annotate stageClientParams throw path in both nav paths

If stageClientParams throws (e.g. a param object with a throwing getter),
_snapshotPending is true but renderNavigationPayload was never called.
The outer catch then calls commitClientNavigationState() which decrements
a counter never incremented by activateNavigationSnapshot() — safe due to
the > 0 guard — but pendingClientParams and hasPendingNavigationUpdate
could persist until the hard-nav fallback resets JS state.

Add a brief NB comment on both stageClientParams call sites noting that
the outer catch hard-navigates, which resets all JS state, making this
benign in practice.

* fix: store visited response snapshot only after successful render

Move storeVisitedResponseSnapshot to after the renderNavigationPayload
try-finally block so we only cache RSC responses from navigations that
actually rendered successfully. Previously, the cache write happened
before renderNavigationPayload — if it threw, a future back/forward
navigation could replay a snapshot from a navigation that never rendered.

The hard-nav fallback in the outer catch made this self-correcting in
practice, but caching only on success is the correct invariant.

Also expand the cached-path navId comment to acknowledge the style
asymmetry vs the non-cached path, explaining why no extra check is
needed between createClientNavigationRenderSnapshot (synchronous) and
createFromFetch.

---------

Co-authored-by: Nathan Nguyen <146415969+NathanDrake2406@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: James <james@eli.cx>
2026-04-01 22:12:52 +01:00
Stephen Zhou c17d6941be chore: migrate to vite plus (#535)
* chore: migrate to vite plus

* Disable typeAware and typeCheck

* Update CI

* Fix CI

* Fix test

* Clean

* Run test with vp

* Try revert

* react: false In test

* Fix test

* Revert "Try revert"

This reverts commit 009da10473.

* Update

* Update

* Try revert ci changes

* revert

* Run vp migrate

* Disable typeAware and typeCheck for now

* Better resolve for test

* Use vp dev instead of vite

* Update expect

* Fix NormalizeManifestModuleId

* Try increase timeout

* Update to use vp

* Try new check

* Bring back npx vp

* Migrate CI

* Make next-intl resolvable

* Update

* Update

* Update
2026-03-15 10:50:13 +00:00
Divanshu Chauhan (divkix) 1c3401e245 perf: TTL-based eviction sweep for prefetch cache (#434)
* perf: add TTL-based eviction sweep for prefetch cache

Before this change, expired prefetch cache entries were only cleaned on
individual access or displaced by FIFO when at the 50-entry cap. On
link-heavy pages, expired entries wasted slots and caused premature
eviction of still-valid entries.

Now storePrefetchResponse() sweeps all expired entries before falling
back to FIFO eviction, so fresh entries survive longer under pressure.

* fix: sync prefetched URL set on cache eviction

Evicted entries were not removed from getPrefetchedUrls(), causing
Link's prefetched.has() check to return true for stale URLs and skip
re-prefetch. Clean up the prefetched set in both sweep and FIFO paths.

Also derive test counts from MAX_PREFETCH_CACHE_SIZE constant and
assert prefetched URL set consistency after eviction.

* fix: address review comments on prefetch cache TTL sweep

- Hoist Date.now() to top of storePrefetchResponse so sweep and new entry timestamp use the same instant
- Export MAX_PREFETCH_CACHE_SIZE in next-shims.d.ts to match runtime export
- Use fixed arbitrary time values in tests instead of real wall clock
- Clarify why prefetchedUrls count excludes the new entry in the below-capacity test

---------

Co-authored-by: James <james@eli.cx>
2026-03-11 12:39:49 +00:00