Commit Graph

17 Commits

Author SHA1 Message Date
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 d6b9d9a1d1 fix(app-router): return HTTP 200 when notFound() is thrown from generateMetadata (#1864)
* fix(app-router): return HTTP 200 when notFound() is thrown from generateMetadata

When generateMetadata() calls notFound(), Next.js treats the error as a
suspended metadata result — the not-found UI is rendered client-side through
the React NotFoundBoundary while the HTTP response stays 200. Previously,
vinext was returning the boundary response with the original 404 status from
renderAppPageHttpAccessFallback.

The fix overrides the rendered fallback response status from 404 to 200 when
specialError.fromMetadata is true for http-access-fallback errors, mirroring
the existing redirect-from-metadata path which also returns 200.

Fixes the failing e2e test:
  test/e2e/app-dir/metadata-navigation: "should support notFound in generateMetadata"

* fix(app-router): gate metadata notFound 200 override on serveStreamingMetadata !== false

Bonk finding: the 200 status override for notFound()/forbidden()/unauthorized()
thrown from generateMetadata() was applied unconditionally, diverging from
Next.js for html-limited bots (serveStreamingMetadata === false). In that path
metadata blocks render synchronously and Next.js sets the real HTTP error status
(app-render.tsx ~2845).

Gate both branches of the http-access-fallback path (with fallback page and
without) on `options.serveStreamingMetadata !== false`, symmetric with the
existing redirect-from-metadata path which already carries the same gate.

Adds unit tests:
- fromMetadata:true + serveStreamingMetadata:false + fallback → 404
- fromMetadata:true + serveStreamingMetadata:false + no fallback → 404
2026-06-09 14:59:07 +01:00
Nathan Nguyen bc31644649 fix(app-router): match streaming metadata error responses (#1794)
* fix(app-router): match streaming metadata error responses

Vinext currently serializes metadata-originated redirects into a Flight payload for document requests. That response is not an HTML page, so direct browser visits cannot follow the streamed redirect and html-limited bots receive 200 instead of the blocking redirect.

The dispatcher also renders HTTP access fallback head metadata from layouts only, so not-found boundary metadata is omitted when generateMetadata() throws notFound().

Use Next.js's split response model: RSC requests keep the Flight redirect digest, streaming-capable document requests receive the refresh meta tag HTML, and html-limited bots fall through to the HTTP redirect. Thread the selected HTTP access boundary module into fallback rendering so boundary metadata is resolved exactly once.

* test(app-router): cover metadata RSC redirects

The unit CI failure came from a strict dispatch test receiving optional fallback fields with undefined or null values. Those fields are only meaningful when a boundary module is actually selected.

Build fallback option objects without absent boundary fields, preserving the existing call shape for ordinary page-level HTTP access fallbacks.

Add focused coverage for fromMetadata redirects on RSC requests so metadata document refresh handling cannot accidentally consume the client navigation Flight path.

* docs(app-router): update metadata redirect transport docs and drop dead boundary fallback

Address review feedback on the metadata streaming error split:
- Update the stale APP_PAGE_METADATA_ERROR_MARKER docblock to describe the
  new three-way transport split (flight digest / HTML refresh meta tag / 307).
- Note the intentional minimal-stub HTML divergence from Next.js's
  full-document insertion in buildMetadataRedirectHtmlResponse.
- Drop the redundant resolveAppPageHttpAccessBoundaryComponent fallback in
  renderAppPageHttpAccessFallback (boundaryModule already covers both paths).

* docs(app-router): fix stale metadata redirect docs in head resolver and test reference

Address remaining bonk re-review nits:
- Update resolveModuleMetadata docblock in app-page-head.ts to describe the
  three-way transport split (it still claimed redirects ride in the flight
  payload even for document SSR).
- Point the APP_PAGE_METADATA_ERROR_MARKER Next.js test reference at
  metadata-streaming, which is where the behavioural contracts are ported from.

---------

Co-authored-by: James <james@eli.cx>
2026-06-06 23:41:15 +00: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
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
James Anderson fd086c4475 fix(app-router): apply x-edge-runtime header on all App Router response paths (#1603)
Extends #1565: the merged change gated the marker on the resolved segment
config, but only covered the two `buildAppPage*Response` builders. Five
other response construction paths bypassed those helpers and skipped the
header for edge-runtime routes:

- `app-page-cache.ts` — ISR HIT/STALE responses
- `app-server-action-execution.ts` — server action RSC responses
  (redirect, no-revalidate, re-render)
- `app-page-boundary.ts` — boundary RSC responses
- `app-page-execution.ts` — 200 + flight-payload redirect responses
- `app-page-stream.ts` — HTML stream responses used by the boundary path

Centralizes the gating in a single `applyEdgeRuntimeHeader` helper so
every site shares one source of truth for header name + condition, and
threads `isEdgeRuntime` through the fallback renderer + dispatch options
so each construction site receives it.

Adds positive + negative coverage for each newly-covered path.
2026-05-26 11:28:33 +01:00
James Anderson fb7a2f795b fix(rsc): redirect returns flight payload (200) for RSC navigations (#1390)
* fix(rsc): redirect returns flight payload (200) for RSC navigations

When a server component calls `redirect()` and the client makes an RSC
navigation request (`.rsc` URL), encode the redirect as a React error
chunk inside the flight stream and return HTTP 200 instead of a raw 307.
The client's `RedirectErrorBoundary` decodes the `NEXT_REDIRECT;...`
digest and performs the navigation — matching Next.js's behavior in
`app-render.tsx generateDynamicFlightRenderResult`.

`generateMetadata()` redirects now always return 200 with a flight
payload (both RSC and SSR document requests), because metadata is
suspended/streamed in Next.js — its redirects ride inside the flight
body, never the HTTP status line. Page-component redirects on SSR
document requests still emit a 307, unchanged.

Closes #1347

* fix(rsc): preserve soft-nav and useTransition pending across flight redirect

The previous commit returned a 200 + flight payload for RSC `redirect()`
responses but left vinext's client-side navigation loop without a way to
detect the redirect — the 200 response fell through to React's render,
threw at decode time, and hit the catch path's `window.location.href` hard
navigation. That tore down the page mid-transition and clobbered
`useTransition`'s isPending state.

Add an out-of-band header (`X-Vinext-Rsc-Redirect`) that carries the
redirect target alongside the flight body. The browser-entry loop now
detects the header right after the existing `resolveRscRedirectLifecycleHop`
(307-following) check, drains the body, and `continue`s the loop with the
new href. The same `pendingRouterState` spans both hops, so the outer
transition stays pending continuously — restoring pre-1347 behavior.

Also stamp the build-time `X-Vinext-Rsc-Compatibility-Id` header on the
flight-redirect response. Without it, `resolveRscCompatibilityNavigationDecision`
treats the response as cross-build and hard-navigates before the redirect
header is read.

Export `MAX_RSC_REDIRECT_DEPTH` from `app-browser-rsc-redirect` so the new
header path can reuse the same hop-limit ceiling.
2026-05-21 15:30:23 +01:00
Nathan Nguyen 496a0247af fix(app-router): emit streamed redirect and not-found meta tags (#1261)
* fix(app-router): emit streamed redirect and not-found meta tags

Streaming App Router SSR currently returns a 200 once the shell commits, but redirect() or notFound() discovered inside a later Suspense boundary never emits the browser-visible meta tag that Next.js relies on. That leaves the control-flow digest only in the RSC payload until client hydration handles it.

The SSR onError path now captures digest-bearing render errors and a request-local flusher injects noindex or refresh meta tags through the existing server-inserted HTML stream hook. The app page pipeline also forwards basePath into SSR so streamed redirect meta tags match early redirect responses.

* fix(app-router): avoid double-prefixing basePath root redirects

Streamed redirect meta tags checked basePath against the full redirect location string. Already-prefixed root URLs with a query or hash, such as /docs?from=checkout, failed the pathname-shaped basePath check and were prefixed a second time.

Add a shared pathname-only basePath prefix helper and use it from both early redirect responses and streamed redirect metadata. The streamed path now splits query/hash before checking the pathname.
2026-05-16 19:07:27 +01:00
James Anderson ed11b3a576 refactor(server): dedupe TextDecoder stream consumption (#1083)
* refactor(server): dedupe TextDecoder stream consumption

Extract the repeated `new TextDecoder()` + `ReadableStream` chunk-loop
into shared helpers in `utils/text-stream.ts`:

- `readStreamAsText(stream)` for the consume-to-single-string pattern
  (used in `app-page-cache.ts` ISR write and `pages-page-response.ts`
  ISR cache write).
- `readStreamAsTextWithLimit(stream, maxBytes, onLimitExceeded)` for
  the size-limited pattern (used in `app-server-action-execution.ts`
  and `pages-node-compat.ts`). Each caller passes its own throwing
  callback so error types (`Error` vs `PagesBodyParseError`) and
  status codes are preserved.

Sites with additional load-bearing logic intentionally left inline:
- `rsc-stream-hints.ts` (line-buffered TransformStream with carry)
- `app-ssr-stream.ts` (raw-byte accumulator + flight hint fix; HTML
  flush transform with `fixPreloadAs`)
- `shims/fetch-cache.ts` (size-limited cache-key body canonicalisation
  with mixed string/Uint8Array stream and tee handling)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* refactor(server): address review feedback from #1083

- Dedupe `readAppPageTextStream` in `app-page-execution.ts` (called
  once from `app-page-dispatch.ts`); replace with `readStreamAsText`
  and remove the now-unused export. The deduped helper additionally
  releases the reader lock in `finally`, which the inline copy did
  not — a robustness improvement on a wholly-consumed stream.
- Update `tests/app-page-execution.test.ts` to use `readStreamAsText`.
- Mirror the `try/finally { reader.releaseLock() }` pattern in
  `readStreamAsTextWithLimit` for consistency with `readStreamAsText`.
  The happy path implicitly releases when the stream completes; the
  over-limit path cancels which also releases. The explicit release
  prevents lock leaks if a future caller catches the limit error and
  reuses the reader handle.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 20:24:09 +01:00
James Anderson 7b41e950bd fix(app-page): align redirect()/notFound() handling under loading.tsx with Next.js (#1057)
* fix(app-page-probe): always await page promise so redirect()/notFound() under loading.tsx return 307/404

The probe used to fire-and-forget the page promise when a route-level
loading.tsx was present (`awaitAsyncResult: !hasLoadingBoundary`). For
async pages that throw `redirect()` or `notFound()`, that swallowed the
rejection during probe; the RSC stream then re-ran the page under the
route Suspense boundary, where React converted the throw into a
"Switched to client rendering" error serialized into a 200 HTML body
instead of a clean 307/404.

Awaiting the page in the probe surfaces the special error early, parity
with the no-loading-boundary path. The cost is that the outer page
function runs serially with the RSC render rather than in parallel — in
practice most async work in `loading.tsx`-shielded routes happens in
nested Suspense children, not in the outer page function body.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* refactor: capture special-error digests post-render instead of awaiting probe

Replaces the previous "always await page in probe" approach with a
post-render swap that mirrors Next.js's mechanism in app-render.tsx:
the page render's onError captures NEXT_REDIRECT / NEXT_HTTP_ERROR_FALLBACK
digests; after the HTML stream drains, the lifecycle inspects the
tracker and swaps a 307/404 in place of the streamed "Switched to
client rendering" body.

This avoids the 2× page invocation cost of awaiting in the probe (the
previous fix called the page once during probe and again during RSC
render, serially). Now the page runs once, inside the RSC render, and
the route-level Suspense boundary (loading.tsx) traps the throw as
designed. The buffered stream still contains the same final HTML for
non-redirecting pages.

Trade-off matches Next.js: loading.tsx fallback visibility is sacrificed
for routes that redirect (you cannot both flush the loading state and
reverse course to a 307). Non-redirecting routes with loading.tsx now
buffer the full response rather than streaming progressively — same
trade-off Next.js makes with its prerender-based pipeline.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* Revert "refactor: capture special-error digests post-render instead of awaiting probe"

This reverts commit 179b20f88033f3731677a91faeb022389247223e.

* refactor: align with Next.js — skip page probe + post-shell race for special errors

Replaces the always-await fix with a Next.js-aligned pipeline:

- Skip the page probe entirely when a route-level loading.tsx is present.
  The page now runs once, inside the RSC render, instead of twice serially.
- Capture NEXT_REDIRECT / NEXT_HTTP_ERROR_FALLBACK digests in the
  rscErrorTracker via React's onError callback (previously dropped).
- After the SSR shell is ready but before bytes are flushed, race the
  captured digest against a 50 ms swap window. A digest that fires within
  the window — sync throws and short-async like ~10 ms auth checks — is
  converted to a 307/404. Late rejections fall through to the streamed
  body, matching Next.js's "until-first-byte-is-flushed" semantic.

Trade-offs vs. the previous always-await fix:
- Page invoked once instead of twice (no more 2× latency for async outer
  page functions).
- TTFB for loading.tsx routes goes from ~outer-page-time to ~50 ms.
- loading.tsx fallback is visible immediately after the swap window
  closes — the OpenNext-compat E2E that broke under the buffer-everything
  approach passes.

Slow async redirects (>50 ms) still leak as serialized stream errors.
Next.js handles that case by injecting <meta http-equiv="refresh">
mid-stream; doing the same in vinext is a separate, larger change.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* refactor: drop 50ms swap-window — deterministic post-shell digest check

Removes the timer-race hack and replaces it with the same shape Next.js
uses (`app-render.tsx:4293`): after the SSR shell promise resolves,
inspect the rscErrorTracker for a captured NEXT_REDIRECT/NEXT_HTTP_ERROR_FALLBACK
digest and swap the response to a 307/404 if one is present.

Why this works without a timer: the SSR pipeline naturally runs many
microtask awaits between the page Promise rejecting (in onError) and
the lifecycle reading the tracker. Throws that resolve in microtasks
during shell rendering are deterministically captured. Throws that
require macrotask boundaries (real I/O, setTimeout) are NOT caught and
fall through to the streamed body — exactly the same trade-off Next.js
makes (the digest survives in the Flight payload for the client router
to consume).

Updates the regression fixture to use the deterministic case (sync
throw inside an async page) instead of the pre-fix repro's setTimeout
trick. The setTimeout case can't be deterministically caught without
either a timer (rejected as a hack) or React-internals patches.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* test: regression for permanentRedirect() under loading.tsx returning 308

Adds a fixture and integration test mirroring the redirect()-with-
loading.tsx case but using permanentRedirect (308). Confirms the
post-shell digest swap honors the statusCode field from the NEXT_REDIRECT
digest rather than coercing to the 307 default — closes a flagged
gap from the Next.js parity audit.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* test: regression for forbidden()/unauthorized() under loading.tsx

Adds fixtures and integration tests verifying the post-shell digest
swap correctly preserves the status code from NEXT_HTTP_ERROR_FALLBACK
digests:
  - forbidden() under loading.tsx → 403 with root forbidden.tsx body
  - unauthorized() under loading.tsx → 401 with root unauthorized.tsx body

Confirms resolveAppPageSpecialError reads `parseInt(parts[1], 10)` from
the digest rather than coercing to 404 — closes the parity-audit's
"forbidden/unauthorized status coercion" gap (which turned out to
already be handled correctly; this just locks it in).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(app-page): apply basePath to redirect Location header

Mirrors Next.js's `addPathPrefix(getURLFromRedirectError(err), basePath)`
in app-render.tsx — `redirect("/about")` from a page mounted under
basePath "/blog" now produces `Location: /blog/about` instead of the
raw "/about".

- Adds `basePath` to BuildAppPageSpecialErrorResponseOptions and a small
  helper that resolves the redirect target against the request URL and
  prefixes app-internal absolute paths.
- External redirects (different origin) and targets that already start
  with the basePath are passed through unchanged.
- Threads the entry-generated `__basePath` through dispatch and into
  both the page and layout special-error response builders.
- Closes a parity gap flagged in the Next.js audit; covered by 5 new
  unit assertions exercising internal / external / already-prefixed /
  unconfigured / root-target cases.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(app-page): preserve cookies().set() on redirect responses

Mirrors Next.js's `appendMutableCookies(headers, requestStore.mutableCookies)`
in app-render.tsx. Auth flows that do
  cookies().set("session", "...");
  redirect("/dashboard");
must preserve the Set-Cookie on the 307, otherwise the redirected
request lands without the just-issued session and the user bounces
back to login.

- Adds an optional `getAndClearPendingCookies` to BuildAppPageSpecialErrorResponseOptions
  that, when set, drains pending Set-Cookie values accumulated during
  the render and appends them to the redirect response.
- Wires `getAndClearPendingCookies` from `vinext/shims/headers` into both
  the page and layout special-error response builders in dispatch.
- Only applied to redirect responses to match Next.js — http-access-fallback
  responses leave cookies to the rendered boundary.
- Closes a parity gap from the audit; covered by a new unit test
  exercising redirect-with-cookies, redirect-without-cookies, and the
  http-access-fallback-cookies-not-bled cases.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* test: regression for notFound() under loading.tsx

Adds a fixture and integration test covering the most common
loading-boundary special-error case in real apps: a dynamic detail
page with a loading state that calls notFound() when the record is
missing.

Distinct from the forbidden()/unauthorized() coverage — notFound()
throws the bare "NEXT_NOT_FOUND" digest (not "NEXT_HTTP_ERROR_FALLBACK;404"),
which takes a separate branch in resolveAppPageSpecialError. This locks
in that the post-shell digest swap handles both branches.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 16:46:55 +00: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
Divanshu Chauhan (divkix) d45c5905f3 perf: fuse RSC stream tees reducing allocs from 3 to 2 (#984)
* perf: fuse RSC stream tees reducing allocs from 3 to 2

ISR cache path teed the RSC stream twice (once for capture,
once for embed). Fuse into single tee: sideStream serves both
embed transform and raw byte accumulation via createRscEmbedTransform.

Tee 1 (capture) and Tee 2 (embed) now share one tee. Tee 3 (HTML
cache) remains separate — it operates on post-SSR HTML with a
conditional write decision.

Fixes #981

* fix: address PR review feedback for fused RSC stream tees (#981)

- Fix AppPageSsrHandler type to include sideStream and capturedRscDataRef
- Clear rawChunks after getRawBuffer() to reduce peak memory usage
- Remove misleading no-op null assignment in handleSsr else branch
- Guard await null in ISR cache write to prevent undefined in cache

* fix: repair CSP nonce handling and update snapshots for #981

- Fix renderAppPageHtmlStream to always pass ssrOptions (including scriptNonce)
  to handleSsr, instead of conditionally when sideStream is defined. This fixes
  CSP nonce regressions in dev mode.
- Update 6 snapshot occurrences in entry-templates.test.ts.snap to match the
  updated __revalCapturedRscRef.value access pattern with null safety.

Fixes CI failures for the fuse RSC stream tees PR.
2026-05-01 08:49:58 +01:00
Nathan Nguyen 67a929bf25 Preserve middleware headers on app boundary responses (#908)
* Preserve middleware headers on app boundary responses

* test: stabilize app router redirect e2e
2026-04-26 17:51:02 +01:00
Nathan Nguyen 34ffb948c8 chore(debug): classification reasons sidecar behind VINEXT_DEBUG_CLASSIFICATION [6/6] (#843)
* chore(debug): add classification reasons sidecar behind VINEXT_DEBUG_CLASSIFICATION

Operators tracing why a layout was flagged static or dynamic can opt in
with VINEXT_DEBUG_CLASSIFICATION=1 at build time. When active, the
plugin patches a second stub (__VINEXT_CLASS_REASONS) with a dispatch
table that returns per-layout ClassificationReason structures. The
runtime probe loop emits a debug line per layout when
debugClassification is wired, and the hot path remains allocation-free
when debug is off because the stub returns null.

The reasons machinery (types, report.ts tagged results, and
buildReasonsReplacement) already shipped in the previous PR; this PR
just enables the emission path and wires the debug consumer into the
runtime probe.

* test(debug): cover runtime-probe error path and Layer 2 reasons dispatch

The runtime-probe catch branch in `probeAppPageLayouts` emits
`{ layer: "runtime-probe", outcome: "dynamic", error: "..." }` when the
isolated dynamic scope throws. That emission had no coverage, so a
regression that stopped including the `error` field, or that skipped the
debug callback entirely on the error path, would not have tripped any
test.

The reasons sidecar integration test also only asserted the Layer 1
(`segment-config`) reason on the nested `force-dynamic` layout. Layer 2
(`module-graph`) on the pure-JSX root layout was exercised in the build
but never read, so a regression that stopped serializing the `result`
field or the `module-graph` discriminator for Layer 2 would slip through.

Extends the existing debug-on describe block to read reasons for layout
index 0 on `/force-dyn` and assert the `module-graph` + `result: "static"`
pair, keeping the structural `ReasonShape` so the test still survives
the #863 codegen reshape.

Also removes a pre-existing `as` on the narrowed Map return in favour of
a proper `in`-narrowing type predicate.
2026-04-21 10:29:10 +01:00
Nathan Nguyen 9791a63381 feat(app-router): emit per-layout flags in the RSC payload [2/6] (#839)
* feat(app-router): emit per-layout flags in the RSC payload

Wire runtime layout classification through renderAppPageLifecycle and
attach the resulting flags as __layoutFlags in the outgoing RSC payload
via buildOutgoingAppPayload. Payload-shape helpers (withLayoutFlags,
isAppElementsRecord, buildOutgoingAppPayload, AppOutgoingElements) live
alongside the existing readAppElementsMetadata so the write and read
boundaries sit next to each other.

* fix(app-elements): widen isAppElementsRecord narrowing and drop redundant clone

- isAppElementsRecord narrowed to Record<string, ReactNode>, but the
  outgoing payload legitimately carries heterogeneous values (ReactNode
  for the rendered tree plus LayoutFlags under __layoutFlags). Widen the
  predicate to Readonly<Record<string, unknown>> so the return type
  matches what the runtime check actually proves.

- buildOutgoingAppPayload spread input.element into a new object before
  passing it to withLayoutFlags, which immediately spreads again. Drop
  the outer copy; withLayoutFlags already guarantees immutability.

* fix(app-router): clear leaked dynamic usage on layout probe errors
2026-04-15 09:16:06 +01:00
Nathan Nguyen 18237955fa feat: static/dynamic layout detection for skip-header optimization (#767)
* feat: add classifyLayoutSegmentConfig for layout segment config detection

Reads `export const dynamic` and `export const revalidate` from layout
source files to classify them as static or dynamic. Unlike page
classification, positive revalidate values return null (ISR is a page
concept), deferring to module graph analysis for layout skip decisions.

* feat: add module graph layout classification (Layer 2)

BFS traversal of each layout's dependency tree via Vite's module graph.
If no transitive dynamic shim import (headers, cache, server) is found,
the layout is provably static. Otherwise it needs a runtime probe.

classifyAllRouteLayouts combines Layer 1 (segment config, from prior
commit) with Layer 2 (module graph), deduplicating shared layouts.

* feat: per-layout dynamic detection in probe phase (Layer 3)

Extends probeAppPageLayouts to return per-layout flags ("s"/"d")
alongside the existing Response. Three paths per layout:

- Build-time classified: pass flag through, still probe for errors
- Needs probe: run with isolated dynamic scope, detect usage
- No classification: original behavior (backward compat)

probeAppPageBeforeRender propagates layoutFlags through the result.
renderAppPageLifecycle updated to destructure the new return type.

* feat: add __layoutFlags payload metadata and thread through router state

Adds APP_LAYOUT_FLAGS_KEY to the RSC payload metadata, carrying
per-layout static/dynamic flags ("s"/"d"). readAppElementsMetadata
now parses layoutFlags with a type predicate guard.

AppRouterState and AppRouterAction carry layoutFlags. Navigate merges
flags (preserving previously-seen layouts), replace replaces them.
All dispatchBrowserTree call sites updated to pass layoutFlags.

* refactor: group classification options into single LayoutClassificationOptions type

The three optional fields (buildTimeClassifications, getLayoutId,
runWithIsolatedDynamicScope) had an all-or-nothing invariant enforced
only at runtime. Grouping them into a single optional `classification`
object makes the constraint type-safe — you either provide the full
classification context or nothing.

Also deduplicates the LayoutFlags type: canonical definition lives in
app-elements.ts, re-exported from app-page-execution.ts.

* fix: default to dynamic flag when layout probe throws non-special error

When runWithIsolatedDynamicScope throws and the error is non-special
(onLayoutError returns null), the layout was silently omitted from
layoutFlags. Now conservatively defaults to "d" — if probing failed,
the layout cannot be proven static.

* refactor: replace parallel arrays with LayoutEntry struct, tighten names

- Introduce LayoutEntry (moduleId, treePosition, segmentConfig) in
  layout-classification.ts, collapsing the three parallel arrays
  (layouts/layoutTreePositions/layoutSegmentConfigs) in
  RouteForClassification into a single struct. Switches the inner loop
  to for...of, removing the ?? 0 fallback that masked index mismatches.
- Update all five classifyAllRouteLayouts test fixtures to the struct shape.
- Add a JSDoc on LayoutFlags clarifying that "s" = static, "d" = dynamic.
- Tighten the buildTimeClassifications comment to say "keyed by layout index".

https://claude.ai/code/session_01MDJzCNao3dX9tmmVtSqsw5

* style: fix prettier formatting

https://claude.ai/code/session_01MDJzCNao3dX9tmmVtSqsw5

* Revert "style: fix prettier formatting"

This reverts commit 8e09f70b80.

* Reapply "style: fix prettier formatting"

This reverts commit 1faf2cd081.

* style: apply oxfmt formatting

https://claude.ai/code/session_01MDJzCNao3dX9tmmVtSqsw5

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-04-13 20:11:47 +01:00
Steve Faulkner c0878d7056 refactor: extract app page execution helpers (#630) 2026-03-21 13:32:02 -05:00