Commit Graph

10 Commits

Author SHA1 Message Date
Steve Faulkner 826ae4cc7c perf(app-router): skip speculative page probes for HTML renders (#2226) 2026-06-22 08:53:17 -05:00
Nathan Nguyen f5bee1a26f fix(app-router): preserve unstable_catchError boundaries (#1906)
* fix(app-router): preserve unstable_catchError boundaries

* test(e2e): add Next-parity test for shouldRerenderAppPageWithGlobalError (#1906)

Add a test proving that for uncaught post-shell RSC errors (with no
client boundary), the server preserves the original HTML stream and the
client renders global-error. This is the Next.js behavior that justifies
keeping shouldRerenderAppPageWithGlobalError unconditional at false.

The server cannot detect userland client boundaries like
unstable_catchError from the route manifest, so the only safe way to
get a global-error rerender is to make a targeted error-boundary test.

The test uses a loading.tsx boundary so the error is truly post-shell,
not caught by the pre-shell error-recovery catch block.

* fix(error): restore sentinel wrapper for falsy thrown values in unstable_catchError

Restores the _CatchErrorState sentinel wrapper ({ thrownValue: unknown }) so
falsy thrown values (null, undefined, 0, "") are routed to the fallback
instead of being treated as no error. Also restores ErrorInfo.error as unknown.

Adds back unit tests for falsy values, class-component lifecycle, and
unstable_retry behavior parity with the component-rendered fallback shape.

Refs: #1906

* fix(error): align ambient ErrorInfo.error type with runtime implementation

* refactor(app-router): remove shouldRerenderAppPageWithGlobalError dead code

The function always returned false and its _options parameter and
ShouldRerenderAppPageWithGlobalErrorOptions type were vestigial.
The call site at app-page-render.ts:892-904 was therefore never taken.

Remove the function, type, import, call site, and the now-redundant
unit test, leaving the shell-level recovery path in
renderAppPageHtmlStreamWithRecovery as the sole error recovery seam.

Addresses ask-bonk review comment on #1906.

* fix(test): restore typed globalAny view and document Fallback as any intent

Addresses two non-blocking review findings from ask-bonk on PR #1906:

- Restored the typed `globalThis as unknown as { window?: unknown }`
  pattern for window stubs in the unstable_catchError test section,
  replacing the `(globalThis as any).window` pattern that was a type-safety
  regression from the prior rewrite.
- Added an inline comment explaining why `Fallback as any` is necessary
  in the class-component lifecycle test: the test probes the inner
  _CatchError class directly, where the fallback function is used as a
  React component type (receiving a single `{ props, errorInfo }` object)
  rather than the public API signature `(props, errorInfo)`.

* chore: trigger CI

* fix(test): rename forwardedProps to props in unstable-catchError test

Addresses ask-bonk review finding on PR #1906: the test was seeding
the inner _CatchError class with the stale key name forwardedProps,
but the production wrapper now passes props (and isPagesRouter).
Since this test only exercises the App Router unstable_retry path
(which reads neither key), behavior was unaffected, but renaming
aligns the test with the current boundary shape for future readers.

* refactor(server): remove dead routeHasLocalBoundary field

Removing shouldRerenderAppPageWithGlobalError (commit abb416b)
deleted its only reader of routeHasLocalBoundary. The field is still
declared in the options type and populated on every render, but
nothing consumes it anymore. Removes:

- routeHasLocalBoundary from AppPageRenderOptions type
- The computation in app-page-dispatch.ts
- Two test stubs in app-page-render.test.ts

Addresses ask-bonk review finding on PR #1906.
2026-06-11 23:21:52 +01:00
Nathan Nguyen 1a480016f6 fix(app-router): track searchParams access for static bailout (#1788)
* fix(app-router): track searchParams access for static bailout

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

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

* chore: rerun ci

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Addresses review feedback on #1788.

---------

Co-authored-by: James <james@eli.cx>
2026-06-06 23:47:47 +01:00
Nathan Nguyen a35e43cef3 feat(skip): add layout safety observation foundations (#1672)
* feat(skip): add layout safety observation foundations

Introduce the per-layout observation primitives that later slices need
to decide whether retained static layouts can be reused. This slice
adds the machinery only; nothing reads the observations yet, so
behavior is unchanged.

- app-layout-param-observation: per-layout completeness, param keys,
  structural scope, finite revalidate, request API, cacheLife, cache
  tag, cacheable fetch, dynamic fetch, and unstable_cache observation
  tracker.
- app-page-probe: bounded React server subtree probe that walks
  layout-returned children including memo, forwardRef, and React.lazy
  wrappers, enforces depth and node limits, and refuses single-use
  iterable children.
- shims: thenable params observer for sync, await, destructure, and
  enumeration. Cache, fetch, and unified request context record
  unstable_cache, cacheable fetch, and request-scoped observation
  state.
- app-page-params: structural segment-param scope so empty optional
  catch-all targets are not treated as param-free downstream.

No skip transport behavior. Later slices wire the planner, manifest,
and encoder onto these observations.

* fix(skip): address review feedback on observation foundations

- Add currentFetchSoftTags reset in runWithIsolatedProbeDependencies to prevent soft tag leak from parent scope (matches runWithFetchCache isolation)
- Replace unnecessary JSON.stringify in cacheLife check with direct null comparison
- Add comment documenting intentional omission of markProbeComplete on probe errors (keeps completeness as unknown, safe fallback to render-and-send)

* fix(skip): record cacheable-fetch observation synchronously before first await

The patched fetch deferred observation and tag collection past
await buildFetchCacheKey(). If a layout probe started a cacheable
fetch and returned synchronously without awaiting it, the probe
snapshot would see cacheableFetchCount: 0 -- a false proof that
could let the skip planner reuse a stale retained layout.

Move observation and tag recording before the first await in the
cacheable branch. If key generation later fails and the fetch falls
back to dynamic, recording both is conservative (false unsafe costs
performance, not correctness).

Add an integration test that exercises the real patched fetch shim
without mocks to verify synchronous observation capture.

* fix(test): remove polluting fetch restore and catch background promise

Do not restore globalThis.fetch in test cleanup. ensureFetchPatch
sets a global _PATCH_KEY guard; restoring fetch without clearing
that marker makes later calls skip reinstallation silently.

Catch the background fetch promise inside the probe so the patched
fetch continuation (cache lookup, network fetch) cannot produce an
unhandled rejection after the synchronous assertions complete.

* chore: empty commit to trigger CI

* chore: empty commit to trigger CI
2026-05-31 11:39:47 +01:00
James Anderson 5d5fe32a65 fix(app-router): forward searchParams correctly in probePage() (#1238)
The RSC entry's probePage() body read a non-existent key off
collectAppPageSearchParams's return value, so the page component
received undefined for searchParams. Any page that did
`await searchParams` during probing threw TypeError, the probe
swallowed it as a non-special error, and redirect()/notFound()
calls were dropped from the probe path. The visible render path
was unaffected because app-page-element-builder reads the correct
key independently.

Per AGENTS.md, the generated entry should be thin codegen glue.
Move the probePage() body into a typed probeAppPage() helper in
server/app-page-probe.ts and have the entry delegate to it, so the
search-params wiring is unit-testable in isolation. Add focused
tests covering thenable params, empty-query handling, the null
page-component case, and redirect-throw propagation.

Fixes https://github.com/cloudflare/vinext/issues/1235
2026-05-15 15:10:36 +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 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
Nathan Nguyen 301b8cb8c8 fix: pass thenable params and searchParams to probePage() (#763)
probePage() passed raw null-prototype params instead of thenable params,
causing TypeError for any page using the Next.js 15+ async params pattern
(await params). The probe also omitted searchParams entirely. Both issues
caused the probe to silently fail, defeating its purpose of early notFound()
and redirect() detection. The layout probe already used the correct thenable
params — the page probe was the only inconsistent path.
2026-04-03 12:11:36 +01:00
James Anderson 1ff166eb92 chore: turn on more lint rules (#714)
* chore: turn on more lint rules

* fmt

* add todos
2026-03-29 20:40:57 +01:00
Steve Faulkner 42fa020ce7 refactor: extract app page probe runtime (#635)
* refactor: extract app page probe runtime

* fix: handle SSR special errors in app page render
2026-03-21 18:44:03 -05:00