Commit Graph

14 Commits

Author SHA1 Message Date
James Anderson 70c500bf65 fix(app-router): scope static params across parallel routes (#2317)
* fix(app-router): scope static params across parallel routes

* fix(app-router): collect static params from parallel layouts

* style(app-router): format parallel route fixtures

* fix(app-router): scope parallel segment params
2026-06-26 20:52:20 +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
Nathan Nguyen 301418a3f1 perf(app-router): defer layout, template and boundary modules to first request (#2145)
* perf(app-router): defer layout, template and boundary modules to first request

The generated RSC entry statically imported every route's layout, template,
loading/error/not-found/forbidden/unauthorized boundary and parallel-slot
module at the top level. On a cold isolate — and on the dev server's first
request — that eagerly evaluated the whole app's segment tree, including
routes never visited in that lifetime. Page and route-handler modules were
already lazy; the surrounding segment modules were not.

Emit those modules as lazy `() => import()` loaders paired with `null`
placeholders in the manifest, and hydrate them onto the route's synchronous
fields in `ensureAppRouteModulesLoaded` before any read (extended from
page/handler to layouts, templates, boundaries and parallel-slot modules).
Intercept layouts hydrate via the dedicated `loadAppInterceptLayouts` because
they live on the match object, not the route. Every reader already runs behind
`ensureAppRouteModulesLoaded` (handler dispatch and `buildPageElements`) or the
intercept resolvers, so no synchronous module field is read before hydration.

The shared intercept `interceptLayouts` arrays keep their `readonly` public
types; the loader is their sole writer and fills slots through one
boundary-local cast, so route metadata stays immutable to callers.

Dev cold start is unchanged on page-heavy apps — pages were already lazy — but
on a boundary-heavy app (80 independent sections, each with
layout+template+error+loading+not-found) first-request module compile drops
704ms -> 534ms (~24%) and time-to-first-200 1917ms -> 1665ms. The saving scales
with the number of unrelated segments deferred.

* test(app-router): localize react max header font fixture

* fix(app-router): dedupe lazy intercept module loads

* fix(app-router): share intercept load state across matches

* docs(app-router): explain root layout loader reuse

---------

Co-authored-by: James <james@eli.cx>
2026-06-19 14:49:29 +01:00
James Anderson 3ba2528c14 fix(app-router): preserve page notFound precedence during metadata failure (#1957) 2026-06-12 23:18:27 +01:00
Nathan Nguyen dea4472c2f fix(app-router): include parallel slot params in useParams (#1904)
* fix(app-router): include parallel slot params in useParams

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

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

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

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

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

* chore: retrigger CI

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

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

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

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

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

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

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

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

---------

Co-authored-by: James <james@eli.cx>
2026-06-11 23:54:27 +01:00
Nathan Nguyen 5dd7a34eb6 fix(app-router): isolate page CSS chunks in production (#1738)
* fix(app-router): isolate production page CSS chunks

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

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

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

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

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

* test: stabilize dev overlay HMR recovery

* chore: retrigger ci

* chore: fix knip check config

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

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

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

---------

Co-authored-by: James <james@eli.cx>
2026-06-08 12:16:08 +01:00
Nathan Nguyen 49d2f0966d feat(skip): plumb client reuse manifests through the app request path (#1717)
* feat(skip): plumb client reuse manifests through the app request path

The browser never sends a client reuse manifest and the server has no path
to receive one, so the static-layout skip planner (#1674) and observation
foundations (#1672) have nothing to act on. Skip transport stays fully off,
but the transport wiring those slices depend on is missing.

Add a bounded browser producer that derives a client reuse manifest from the
visible AppElements state and attaches it as X-Vinext-Client-Reuse-Manifest
only on soft navigations. Parse the header into a render/send-by-default
disposition and thread the parsed result through the RSC handler, the
generated app entry, page dispatch, and the render lifecycle. Wire the
per-layout observation tracker and segment-config classification into dispatch
so the enable-transport slice can read them.

No RSC payload is omitted and no response cache-control changes: the parsed
manifest is carried but never authorizes a skip yet. Unproven and absent-
manifest requests render and send exactly as before.

Slice 3 of 4 from #1437; enable-transport follows.

Refs #726

* fix(skip): address review feedback on manifest plumbing

- app-page-execution: defer layout debug classification until after the
  observation override so the emitted reason agrees with the final flag;
  emit a runtime-probe reason when an observation flips a build-time static
  layout to dynamic.
- app-page-dispatch: document classifyLayoutSegmentConfigFromModule as the
  request-time counterpart to the build-time classifier, noting the
  intentional revalidate===false divergence and the dev-mode classification.
- app-render-dependency: mark registerAppElementRenderDependencies as
  write-only until the enable slice's consumer lands.

* docs(skip): clarify layout classification comments per review

- app-page-dispatch: correct the classifyLayoutSegmentConfigFromModule
  docblock — the build-time classifier already normalizes revalidate=false
  to Infinity, so both agree on that case; the real difference is scope
  (request-time classifies dev-mode layouts not captured at build time).
- app-page-dispatch: document the merge precedence in
  createEffectiveLayoutClassifications — module segment config is
  authoritative over the build-time map.

* test(skip): pin observation override flipping build-time static layout to dynamic

Adds a parity assertion for the request-time behavioral change the review
flagged: isLayoutObservationDynamic downgrades a build-time `static` layout
to `d` (and emits a runtime-probe reason) when the observed render is unsafe
for static reuse, while a safe observation keeps it static. Complements the
existing children-subtree probe assertion in app-page-route-wiring.test.ts.

* fix(skip): fold probe-scoped dynamic usage into layout observation

The probe rewrite runs layout probes inside runWithIsolatedProbeDependencies,
which opens a child unified scope and resets dynamicUsageDetected. A
markDynamicUsage() during the probe that leaves no other observable trace
(e.g. "use cache: private") was therefore discarded on scope exit, so the
parent's consumeDynamicUsage() in the Layer-3 path returned false and the
layout could classify static where the pre-PR direct probe classified dynamic.

recordProbeDependencies now peeks the child scope's dynamic-usage flag before
it is reset and folds it into the per-layout observation
(dynamicUsageObserved), which isAppLayoutObservationUnsafeForStaticReuse
treats as unsafe. This keeps the observation signal a superset of the
markDynamicUsage() triggers the Layer-3 dynamicDetected path covered, so the
two signals can no longer diverge into a false-static. Adds peekDynamicUsage()
(non-resetting read) and a regression test.

* perf(skip): defer client reuse manifest production to the cache-miss path

The manifest header is excluded from VINEXT_RSC_VARY_HEADER, so it never
affects the cache-busting URL or the visited-response cache lookup. Producing
it up front ran createClientReuseManifestHeaderFromVisibleAppState (iterates
visible layout ids, builds a cache-variant proof per static layout, then
binary-searches the byte budget) on every soft navigate — including ones that
resolve entirely from the visited-response cache or a prefetch and never send
a request, where the work is pure waste.

Move production to just before the network fetch, gated on the same
`navigationKind === "navigate"` condition and computed from the captured
nav-start router state, so the emitted header is unchanged on the request path
while the cache-hit/prefetch-hit hot paths skip it entirely.

---------

Co-authored-by: James <james@eli.cx>
2026-06-02 20:25:25 +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 faa5c2feca fix(app-router): propagate generateStaticParams validation errors (#1108)
Request-time dynamicParams=false validation swallowed thrown and rejected generateStaticParams failures. That made application errors look like either successful validation or a 404 when a later source excluded the params.

The validation helper now lets generateStaticParams failures reject, matching the Next.js static params builder contract. The dispatch caller no longer passes a logging callback whose only recovery path was to continue after the failure.

Regression tests cover thrown, rejected, and partially failing generateStaticParams sources.
2026-05-06 17:37:55 +01:00
Nathan Nguyen 08d11a10c2 fix(page-request): catch generateStaticParams errors per-source, not per-loop (#1045)
When validateAppPageDynamicParams iterates multiple generateStaticParams
sources, a throw in one source previously short-circuited the try-catch
wrapping the whole loop, skipping remaining sources. Move the try-catch
inside the loop so each source is independently guarded.
2026-05-04 16:51:46 +01:00
Nathan Nguyen 54a1286839 fix(app): apply layout segment config to pages (#993)
* fix(app): apply layout segment config to pages

* fix(app): address segment config review nits
2026-05-03 18:57:48 +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 0dbfeff1af feat: flat keyed payload for App Router layout persistence (#750)
* Extract app page route wiring helpers

* Add slot client primitives

* Fix app page error boundary serialization

* Fix client error boundary pathname reset

* Document Next.js error boundary verification

* Implement flat App Router payload for layout persistence

* fix: address review findings in flat payload implementation

- Fix stale closure on readBrowserRouterState by using a useRef updated
  synchronously during render instead of a closure captured in
  useLayoutEffect. External callers (navigate, server actions, HMR) now
  always read the current router state.

- Restore GlobalErrorBoundary wrapping that was dropped when switching
  from buildPageElement to buildAppPageElements. Apps with
  app/global-error.tsx now get their global error boundary back.

- Add exhaustive default case to routerReducer so new action types
  produce a compile error and a runtime throw instead of silent undefined.

- Remove dead code: createRouteNodeSnapshot, AppRouteNodeSnapshot,
  AppRouteNodeValue were defined but never imported.

- Remove deprecated buildAppPageRouteElement and its test — no
  production callers remain after the flat payload cutover.

- Short-circuit normalizeAppElements when no slot keys need rewriting
  to avoid unnecessary allocation on every payload.

- Align test data in error boundary RSC payload test (matchedParams
  slug: "post" -> "missing" to match requestUrl /posts/missing).

* fix: normalize flat payload after use(), not before

createFromReadableStream() returns a React thenable whose .then()
returns undefined (not a Promise). Chaining .then(normalizeAppElements)
broke SSR by assigning undefined to flightRoot.

Fix: call use() on the raw thenable, then normalize synchronously
after resolution. Also widen renderAppPageLifecycle element type to
accept flat map payloads.

* fix: produce flat RSC payload on all rendering paths

The SSR entry always expects a flat Record<string, ReactNode> with
__route and __rootLayout metadata from the RSC stream. Three paths
were still producing bare ReactNode payloads:

1. renderAppPageBoundaryElementResponse only created the flat map for
   isRscRequest=true, but HTML requests also flow through RSC→SSR
2. buildPageElements "no default export" early return
3. Server action "Page not found" fallback

All three now produce the flat keyed element map, fixing 17 test
failures across 404/not-found, forbidden/unauthorized, error boundary,
production build, rewrite, and encoded-slash paths.

* test: update unit tests for flat RSC payload on all paths

- Update renderElementToStream mock to extract the route element from
  the flat map before rendering to HTML (mirrors real SSR entry flow)
- Update entry template snapshots for the buildPageElements changes

* fix: wrap Flight thenable in Promise.resolve() before chaining .then()

createFromReadableStream() returns a React Flight thenable whose
.then() returns undefined instead of a new Promise. The browser
entry's normalizeAppElementsPromise chained .then() on this raw
thenable, producing undefined — which crashed use() during hydration
with "An unsupported type was passed to use(): undefined".

Wrapping in Promise.resolve() first converts the Flight thenable
into a real Promise, making .then() chains work correctly.

The same fix was already applied to the SSR entry in 5395efc but
was missed in the browser entry.

* fix: eliminate Promise from ElementsContext to fix React 19 hydration

React 19.2.4's use(Promise) during hydration triggers "async Client
Component" because native Promises lack React's internal .status
property (set only by Flight thenables). When use() encounters a
Promise without .status, it suspends — which React interprets as the
component being async, causing a fatal error.

Fix: store resolved AppElements directly in ElementsContext and
router state instead of Promise<AppElements>. The navigation async
flow (createPendingNavigationCommit) awaits the Promise before
dispatching, so React state never holds a Promise.

- ElementsContext: Promise<AppElements> → AppElements
- AppRouterState.elements: Promise<AppElements> → AppElements
- mergeElementsPromise → mergeElements (sync object spread)
- Slot: useContext only, no use(Promise)
- SSR entry: pass resolved elements to context
- dispatchBrowserTree: simplified, no async error handler

Also fix flaky instrumentation E2E test that read the last error
entry instead of finding by path.

* test: update slot and browser state tests for resolved ElementsContext

- Remove Promise wrappers from ElementsContext test values
- mergeElementsPromise → mergeElements (sync)
- Replace Suspense streaming test with direct render test
- Remove unused createDeferred helper and Suspense import
- Update browser state test assertions (no longer async)

* ci: retrigger

* fix: address code review findings (P1-P3)

P1a: mergeElements preserves previous slot content when the new payload
marks a parallel slot as unmatched. On soft navigation, unmatched slots
keep their previous subtree instead of triggering notFound().

P1b: renderNavigationPayload now receives navId and checks for
superseded navigations after its await. Stale payloads are discarded
instead of being dispatched into the React tree.

P2: The catch block in renderNavigationPayload only calls
commitClientNavigationState() when activateNavigationSnapshot() was
actually reached, preventing counter underflow.

P3: The no-default-export fallback in buildPageElements now derives
the root layout tree path from route.layoutTreePositions and
route.routeSegments instead of hardcoding "/".

* fix: avoid serializing app render dependency wrappers

* Fix flat payload dependency barriers

* Fix template-only route wrappers

* chore: trigger CI review

* fix: skip Slot wrapping for layout entries without a default export

When a layout entry exists but has no default export, the element is
never written to the flat map. The unconditional Slot wrapping would
return null (id not in elements), silently dropping the entire route
subtree below that layout level.

Guard the Slot wrapping with a check for the layout component. When
absent, pass layoutChildren through directly — preserving the
LayoutSegmentProvider and error/not-found boundaries while skipping
the Slot indirection.

* fix: restore merged app router entry behavior

* fix: address app router review regressions

* Fix app-page-request intercept tests

* Address PR 2c review follow-ups

* Refactor same-url payload commits

* Clarify PR 2c browser invariants

* Update App Router entry snapshots

* Tighten App Router review follow-ups

* Align App Router entry helper usage

* Tighten browser commit invariants

* fix(app-router): preserve scoped parallel slot identity

* test(app-router): align slot identity assertions

* fix(app-router): keep boundary root layout metadata unknown

* docs(app-router): refresh stale review comments
2026-04-10 07:49:18 +01:00
Steve Faulkner 209675e4cd refactor: extract app page request helpers (#633)
* refactor: extract app page request helpers

* test: fix app router integration assertions
2026-03-21 15:03:07 -05:00