Commit Graph

6 Commits

Author SHA1 Message Date
Nathan Nguyen 5dd7a34eb6 fix(app-router): isolate page CSS chunks in production (#1738)
* fix(app-router): isolate production page CSS chunks

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

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

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

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

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

* test: stabilize dev overlay HMR recovery

* chore: retrigger ci

* chore: fix knip check config

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

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

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

---------

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

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

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

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

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

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

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

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

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

* Fix bfcache id history restoration

* Clarify bfcache id sentinel formatting

* Address bfcacheId review feedback

* Tie bfcacheId sentinels to shared constants

* Address bfcacheId review clarity

* Tighten bfcache slot identity check

* Deduplicate bfcache history sync check

* Handle empty bfcache segment ids

* Fix intercepted active slot route ids

* Clarify bfcacheId form state coverage

* Address bfcacheId review clarity

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

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

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

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

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

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

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

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

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

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

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

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

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

---------

Co-authored-by: James <james@eli.cx>
2026-06-02 13:53:27 +01:00
Nathan Nguyen 8f224051b7 Preserve intercepted app-router state across server actions (#854)
* Preserve interception context on app-router actions

* Keep intercept slot overrides on action rerenders

* Pin React versions in static-export fixture

* refactor(app-page-request): unify intercept resolution into a single state machine

Collapse the parallel GET/POST intercept lookup paths into one shared
internal helper that returns a tagged union: none, current-route, or
source-route. Both resolveAppPageInterceptMatch and
resolveAppPageActionRerenderTarget now read from the same lookup result,
eliminating the double findIntercept() scan on current-route fallbacks.

Also add the two missing resolveAppPageActionRerenderTarget fallthrough
test cases (no intercept, current-route intercept with no matching
action route) and a regression assertion that the current-route branch
only performs one intercept lookup.
2026-04-20 07:49:56 +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
Steve Faulkner 12fea722b6 Initial public release of vinext 2026-02-24 09:29:39 -06:00