* fix(dev): backstop uncaughtException for socket disconnects pipe() misses
PR #911 attached an error listener to every connection socket, which
fixes the direct res.socket case. But Node's pipe() machinery has a
separate re-emission path: when a Readable source errors and the
destination has no error listener, pipe() emits the error on the
destination — and that destination is not always the connection
socket. Two real call sites trigger this:
- fromWeb(fetch().body).pipe(res) in proxyExternalRewriteNode, where
the source is the upstream fetch body, not the inbound socket
- streaming surfaces inside @vitejs/plugin-rsc that own their own
pipe topology
Outbound sockets created by fetch() also never fire 'connection' on
server.httpServer, so they sit completely outside the existing guard.
Add a process-level uncaughtException handler scoped to the dev server
that drops only peer-disconnect codes (ECONNRESET, EPIPE,
ECONNABORTED) and re-throws everything else on nextTick to preserve
Node's default crash behavior for real errors. Removed on httpServer
'close' so it doesn't leak across server restarts.
Refs #905.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix: address bonk review on uncaughtException backstop
Three issues from PR #913 review:
1. (Blocker) The process.nextTick(() => throw err) re-throw created an
infinite loop, not a crash: the listener is still installed when the
nextTick callback fires, so it catches the re-thrown error and
schedules another, forever — silently swallowing genuine bugs.
Replace with a synchronous throw inside the handler, which Node
treats as fatal and produces the same observable as no listener
(stack to stderr, non-zero exit).
2. Skip installation entirely in middleware mode (httpServer is null).
The embedding host owns process-level handlers and there's no
reliable teardown hook to remove ours, so installation would leak.
3. Mirror the same filter onto unhandledRejection — peer-disconnect
errors from outbound fetch() and pipe re-emissions can surface
through either channel depending on the async path.
Verified locally: sync throw inside the handler crashes Node with the
original stack and a non-zero exit code; the previous nextTick pattern
loops indefinitely on the same input.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(app-router): flatten Error.cause chain into dev-server error output
Vite's dev-server "Internal server error:" formatter
(buildErrorMessage in vite-plus-core) builds output from err.message
and err.stack only, silently dropping err.cause. A wrapper like
`new Error("Failed query", { cause: pgError })` would surface in the
dev console as just "Failed query" — the actual postgres ECONNREFUSED,
role-missing, or workerd socket error in .cause was invisible.
This commit adds flattenErrorCauses(err), called at the App Router RSC
handler boundary before any error escapes to Vite. It walks .cause
(depth-capped, cycle-safe) and embeds each cause's message into
err.message and stack frames into err.stack as `at` lines (so they
survive Vite's cleanStack /^\s*at/ filter). Marked idempotent via a
non-enumerable symbol so Node's util.inspect output is unaffected and
prod logs that already format causes correctly are not duplicated.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix: address bonk review feedback
1. Gate flatten on NODE_ENV !== "production" at the call site. Without
this, prod-server.ts:1106's `console.error("[vinext] Server error:", e)`
would render the cause twice — once in the synthesized message line,
once in util.inspect's native [cause]: block. Vite build-time-replaces
NODE_ENV, so the prod bundle compiles the catch branch out entirely.
2. Replace the marker-enumerability test with one that actually invokes
util.inspect and asserts the marker symbol is invisible to it.
3. Wrap the three mutating operations (defineProperty, .message =,
.stack =) in try/catch so frozen / non-extensible errors are left
untouched rather than masking the user's real failure with a
TypeError from inside the helper.
4. Switch FLATTENED_MARKER from Symbol.for(...) to module-private
Symbol(...) — there's no cross-module coordination needed.
Add a regression test for the frozen-error case.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Node throws on unhandled 'error' events on sockets. When the browser
drops a connection mid-response (HMR triggers a reload while an RSC
stream is still flushing), the next res.write surfaces ECONNRESET on
res.socket with no listener attached and takes down `bun dev`.
Attach a no-op error listener on every connection — same guard Vite's
HMR server and Next.js install for the same reason. Closes#905.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(app-router): block javascript: URLs in router.push/replace/prefetch
router.push("javascript:alert(1)") currently runs the script. _appRouter.push,
.replace, and .prefetch in shims/navigation.ts forward the href straight to
navigateClientSide, where isExternalUrl matches the javascript: scheme and
falls through to window.location.assign, which executes the URL. router-derived
hrefs (URL params, user content, query state) become an XSS vector.
Next.js blocks dangerous URI schemes at the router boundary and throws
"Next.js has blocked a javascript: URL as a security precaution." See
packages/next/src/client/components/app-router-instance.ts:343,400,440,458 in
the canary tree. Vinext's url-safety.isDangerousScheme already covers
javascript:/data:/vbscript: with the same obfuscation handling Next.js uses,
and Link and Form already gate on it; the App Router programmatic surface was
the remaining gap.
Add assertSafeNavigationHref at the top of push/replace/prefetch, before the
isServer short-circuit so SSR callers fail loudly too. Throw an Error with
the verbatim Next.js message so consumer error tooling can match across both
frameworks.
Test: tests/router-javascript-urls.test.ts ports the assertions from
.nextjs-ref/test/e2e/app-dir/javascript-urls/javascript-urls.test.ts down to
unit scope, covering javascript:/data:/vbscript:, uppercase, embedded tabs,
leading whitespace, and a safe-URL no-throw guard for each method.
* test(router): split ported vs vinext-only dangerous-scheme cases
The header said "Ported from Next.js" but data: and vbscript: are vinext
extensions, not in the upstream e2e suite. Add a Coverage split note that
attributes each scheme: javascript: + obfuscation variants come from
test/e2e/app-dir/javascript-urls/javascript-urls.test.ts; data: and vbscript:
ride on top via shims/url-safety.isDangerousScheme.
* fix app router dangerous redirect gaps
* test address dangerous URL review nits
useSelectedLayoutSegment(parallelRoutesKey) returned the first segment for every route key. That matches children routes, but it misreports nested named parallel routes such as a slot path with members/profile by returning members instead of the active leaf profile.
The root cause was treating the array returned by useSelectedLayoutSegments() as having the same reduction rule for children and named slots. Match Next.js by keeping first-segment behaviour for children and using the final segment for non-children parallel route keys.
Adds a nested @team fixture route that mirrors Next.js parallel-routes-use-selected-layout-segment coverage.
* vendor Google Fonts metadata from Next.js
Adds packages/vinext/src/build/google-fonts/font-data.json (1911 entries,
388 KB pretty-printed) plus a typed re-export wrapper. The metadata
enumerates each Google Font's available weights, styles, variable axes, and
preloadable subsets, and is the source of truth a faithful next/font/google
port needs to encode the right URL for variable fonts and reject invalid
options at build time.
Today vinext hardcodes :wght@100..900 in both the shim runtime URL builder
and the build-time plugin, which produces HTTP 400 from Google for fonts
whose wght axis is narrower (Sen 400..800, Anton single 400). Without
metadata there is no way to emit the correct axis range, validate weights
against the font's real options, or auto-disable preload for fonts with no
preloadable subsets.
The JSON itself is consumed only at build time and during dev. Production
Workers run the self-hosted CSS injected by the build plugin and never read
this file. The pretty-printed shape matches the upstream layout so future
refreshes from canary remain reviewable.
This commit only vendors the data and types. URL pipeline and validator
land in the next two commits.
Refs #885.
* port Google Fonts URL pipeline from Next.js
Adds three pure helpers under packages/vinext/src/build/google-fonts:
sortFontsVariantValues (numeric variant comparator), buildGoogleFontsUrl
(URL assembler), and getFontAxes (metadata-driven axis resolver). Each is a
direct port of the matching module in vercel/next.js packages/font/src and
has no defaults of its own. Defaults live in the validator (next commit).
This is the layer that translates a validated options object into the URL
shape Google Fonts CDN actually accepts. Sorting matters because Google
rejects out-of-order variant lists with HTTP 400. The metadata-driven axis
resolver is what lets a Sen({}) call produce :wght@400..800 instead of the
broken :wght@100..900 default.
No consumer wired up yet. The shim and plugin still call the old hardcoded
URL builders; the integration lands in PR 2.
Tests: 21 new tests across the three modules. Each helper is exercised in
isolation against the vendored metadata, including the regression case
from #885 (variable Sen resolves to wght 400..800) and the italic-only
branch that vinext currently drops.
Refs #885.
* port Google Fonts options validator from Next.js
Adds validateGoogleFontOptions, the parse-once boundary for next/font/google
calls. Mirrors Next's validate-google-font-function-call.ts, dropping the
SWC-coupled (functionName, fontFunctionArgument) signature in favour of
(fontFamily, options). Returns ValidatedGoogleFontOptions, a type whose
existence proves the input was rejected if Google would have rejected it.
Behavior matches Next.js: throws on unknown family, unknown weight, mixing
'variable' with explicit weights, axes on non-variable fonts, axes without
weight 'variable', invalid display, unknown subsets, preload with no
subsets. Defaults missing weight to ['variable'] for variable fonts, and
auto-disables preload for fonts with no preloadable subsets.
This closes the loop on the URL pipeline added in the previous commit.
Together with getFontAxes and buildGoogleFontsUrl, vinext now has a
metadata-driven path from caller options to a valid Google Fonts URL.
The shim and plugin still build URLs the old way; PR 2 wires this through.
Tests: 16 new tests covering every throw branch and every default branch,
plus an index.ts barrel that the future plugin import will use. The
"auto-disable preload" test pins on Playwrite AR Guides, which has empty
subsets in upstream metadata; if a future canary refresh adds a subset to
that family the test will need a different fixture.
Refs #885.
* fold attribution into font-metadata header, drop unused exports
Drop NOTICE.md and the index.ts barrel, demote four type aliases from
exports to module-internal. Knip flagged the index file as unused (no
consumer until PR 2) and the type aliases as unused exports for the same
reason; YAGNI says wait for the consumer rather than ship dead public API.
Vercel's MIT copyright on font-data.json is preserved in the
font-metadata.ts header instead of in a sibling NOTICE.md, matching the
in-tree pattern Next.js uses for vendored third-party files.
Tests: vp run knip exits 0, 37 google-fonts tests still pass.
* address PR 892 review: document subsets parity, harden preload-disable test
`validateGoogleFontOptions` rejects `subsets: []` with `preload: true`
where Next.js silently accepts it (Next uses a truthy check on
`subsets`, vinext defaults to `[]` and checks `length`). The behavior
is intentional (an empty subsets array with preload enabled is always
a caller mistake), but undocumented divergence from a faithful port
invites a future "fix back to upstream" PR. Annotate the divergence
at the throw site.
Auto-disable-preload test pins on `Playwrite AR Guides` having an
empty subsets array in the bundled metadata. If a metadata refresh
ever adds a subset to that family, the family-specific test silently
flips into a false positive. Add an invariant test asserting the
bundled metadata still contains at least one family with no
preloadable subsets, so loss of branch coverage fails CI loud.
Bots throw "max_tokens not supported, use max_completion_tokens" on
1.4.6 with gpt-5.x reasoning models. opencode 1.4.6 predates the
cf-ai-gateway plugin's chat.params hook (PR #22864, landed in 1.4.7),
which strips maxOutputTokens for OpenAI reasoning models so the
@ai-sdk/openai-compatible default no longer leaks the rejected field.
The earlier ProviderInitError on 1.14.x was a separate regression in
opencode's npm install path: the major bump introduced loadOptions(dir)
via @npmcli/config inside Npm.add, which fails on CI runners and bubbles
up as InstallFailedError, then gets re-wrapped at provider.ts:1522 as
ProviderInitError with the original cause swallowed by the session
error path. Other Cloudflare repos (workers-sdk, workerd, kumo) hit the
same regression on anthropic and pinned 1.4.6 / 1.2.27 to dodge it.
1.4.11 is the last 1.4.x release: it has the chat.params max_tokens
patch and predates the loadOptions install regression. Same
ai-gateway-provider 3.1.2, same @ai-sdk pins as 1.4.6, so the install
path stays the known-good arborist.reify call shape that works for
workers-sdk + anthropic on 1.4.6.
Verified locally with opencode 1.14.25 + cf-ai-gateway/openai/gpt-5.4 +
fake creds: empty-cache short-circuit and loadOptions both reproduce
the silent ProviderInitError. 1.4.11 lacks both code paths.
Every bonk and bigbonk run since opencode 1.14.25 throws
UnknownError: ProviderInitError during cf-ai-gateway SDK init,
regardless of model. Verified across gpt-5.5 (#898), gpt-5.4 (#900),
and the throw fires before any per-model code path runs.
workers-sdk hit the same regression and pinned opencode_version to
1.4.6 with an inline comment naming ProviderInitError as the cause.
Several other Cloudflare repos pin 1.2.27 / 1.4.6 for the same
reason. vinext is the only Cloudflare repo running OAI through
cf-ai-gateway, so the OAI-specific path stayed unobserved until #898
re-tried it on 1.14.25.
Pinning to 1.4.6 here reuses workers-sdk's known-good version. The
gpt-5.4 model from #900 stays; gpt-5.4 predates 1.4.6's bundled
snapshot by over a month, so registry resolution is fine.
bonk and bigbonk currently throw ProviderInitError on every invocation
after the gpt-5.5 switch in #898. The lookup-side regression that #899
patched was masked by the opencode bump (1.14.22 -> 1.14.25) but a
second failure now surfaces during SDK init for cloudflare-ai-gateway.
opencode swallows the underlying cause, and the gpt-5.5 path through
ai-gateway-provider has no working precedent in this repo, so iterating
on it would block PR review indefinitely.
Roll the model back to gpt-5.4, which has a known-good path through
opencode + ai-gateway-provider + Cloudflare AI Gateway, and remove the
opencode.json workaround introduced in #899. Reasoning effort stays at
"xhigh" per James's preference; opencode will fall back to its default
effort if gpt-5.4 doesn't accept it.
Once gpt-5.5 SDK init is fixed upstream (opencode + ai-gateway-provider
+ models.dev cloudflare-ai-gateway entry), we can roll forward again.
* ci(bonk): switch bonk workflow to GPT-5.5 xhigh variant
Updates the bonk workflow to use cloudflare-ai-gateway/openai/gpt-5.5
with reasoning effort variant xhigh, replacing the previous
claude-opus-4-7 model.
https://claude.ai/code/session_012n6RFuqfQGQY7AaayqLcaG
* ci(bonk): extend GPT-5.5 xhigh switch to bigbonk, agent defs, and docs
- bigbonk workflow: cloudflare-ai-gateway/openai/gpt-5.5 with variant xhigh (was claude-opus-4-7 + max)
- viguy and reviewer OpenCode agent definitions: openai/gpt-5.5 (was anthropic/claude-opus-4-7)
- CONTRIBUTING.md: update recommended setup and BigBonk description to GPT-5.5 xhigh
The Next.js tracker workflow and agent are intentionally left on Claude
Opus 4.7.
https://claude.ai/code/session_012n6RFuqfQGQY7AaayqLcaG
* ci(opencode): register openai/gpt-5.5 under cloudflare-ai-gateway
bonk and bigbonk currently throw ProviderModelNotFoundError when
invoked because opencode's bundled models snapshot predates GPT-5.5
(released 2026-04-23) and the cloudflare-ai-gateway entry on
models.dev does not yet list it. opencode resolves
provider.models[modelID] before any network call, so the lookup miss
fires synchronously and the workflow exits non-zero before reaching
the gateway.
opencode merges user-supplied provider.<id>.models into the resolved
registry dict, so registering the entry locally unblocks workflow
invocations without waiting on either an upstream registry merge or
an opencode release that rebundles the snapshot.
Pricing, limits, modalities, and reasoning capability mirror the
direct openai/gpt-5.5 entry from models.dev.
---------
Co-authored-by: Claude <noreply@anthropic.com>
* ci(bonk): switch bonk workflow to GPT-5.5 xhigh variant
Updates the bonk workflow to use cloudflare-ai-gateway/openai/gpt-5.5
with reasoning effort variant xhigh, replacing the previous
claude-opus-4-7 model.
https://claude.ai/code/session_012n6RFuqfQGQY7AaayqLcaG
* ci(bonk): extend GPT-5.5 xhigh switch to bigbonk, agent defs, and docs
- bigbonk workflow: cloudflare-ai-gateway/openai/gpt-5.5 with variant xhigh (was claude-opus-4-7 + max)
- viguy and reviewer OpenCode agent definitions: openai/gpt-5.5 (was anthropic/claude-opus-4-7)
- CONTRIBUTING.md: update recommended setup and BigBonk description to GPT-5.5 xhigh
The Next.js tracker workflow and agent are intentionally left on Claude
Opus 4.7.
https://claude.ai/code/session_012n6RFuqfQGQY7AaayqLcaG
---------
Co-authored-by: Claude <noreply@anthropic.com>
Six places in the request pipeline inlined a variation of:
pathname.replaceAll("\\", "/").startsWith("//")
to reject protocol-relative paths before they reach the trailing-slash
redirect emitter (which would otherwise echo them into a Location header).
The check handles literal delimiters but not their percent-encoded forms —
`/%5Cevil.com/` survives the guard, then `normalizePathnameForRouteMatchStrict`
re-encodes the decoded backslash back to `%5C` (preserving it as part of a
single path segment), so the 308 trailing-slash redirect ends up with
`Location: /%5Cevil.com`. Browsers percent-decode Location headers and
WHATWG URL treats backslash as forward slash, so the browser resolves that
to a protocol-relative host.
Factor the leading-segment shape check into a new `isOpenRedirectShaped`
helper in `server/request-pipeline.ts` that recognises literal (`//`, `/\\`)
and percent-encoded (`%5C`, `%2F`) variants, and swap each call site over
to it. Also add a defense-in-depth check in `normalizeTrailingSlash` so a
future caller that skips the upstream guard still can't emit a bad Location.
Updated call sites:
- server/request-pipeline.ts — guardProtocolRelativeUrl + normalizeTrailingSlash
- server/prod-server.ts — App Router and Pages Router Node handlers
- server/app-router-entry.ts — default Cloudflare Worker entry
- server/app-ssr-entry.ts — SSR environment entry
- index.ts — Pages Router Vite dev middleware
- deploy.ts — generated Pages Router Cloudflare Worker (inlined, kept in sync)
Regression tests in tests/request-pipeline.test.ts cover the encoded
variants plus the defense-in-depth path. Updates the pinned string
assertion in tests/deploy.test.ts that was checking for the old inlined
pattern.
* fix(app-router): hard-navigate to browser URL on non-ok RSC fetch
Client-side RSC navigation fetch passes non-ok responses (404, 500, etc.)
directly to createFromFetch. The HTML error body is parsed as an RSC
stream, producing a cryptic "Connection closed" error, and the outer
catch hard-navigates to the same URL that just failed — risking a
reload loop and surfacing an opaque diagnostic.
Non-ok responses are not RSC payloads and must not reach createFromFetch.
Next.js handles this identically in fetch-server-response.ts:211 with a
hard ("MPA") navigation to the browser-facing URL stripped of flight
markers.
Add a !navResponse.ok guard after the stale-navigation check in the
navigation loop: hard-navigate to currentHref (the browser URL without
.rsc suffix). The existing finally block handles
settlePendingBrowserRouterState and clearPendingPathname on this return
path, so no additional bookkeeping is required.
Add a !rscResponse.ok guard in readInitialRscStream so the initial
hydration fallback path reloads instead of attempting RSC parsing on an
HTML error body. Return a never-resolving ReadableStream so the caller
does not proceed into createFromReadableStream before the reload takes
effect.
E2E tests verify: 404 RSC nav hard-navs to the non-.rsc URL; 500
(simulated via page.route interception) hard-navs without logging an
RSC parse error; the final URL never contains .rsc.
* fix(app-router): break reload loop when initial RSC fetch is persistently non-ok
readInitialRscStream reloads on a non-ok RSC response so the server can
render the correct error page as HTML. That assumes the next HTTP
response will differ, which is not guaranteed: if the RSC endpoint is
persistently broken while the HTML document is served successfully
(mismatched server handler, upstream gating, intercepted requests in
tests), the reload fetches the same HTML, the post-hydration RSC fetch
gets the same non-ok, and the page reloads forever.
Guard the reload with a sessionStorage key scoped to the current path.
The first non-ok response sets the key and reloads. The second non-ok
response for the same path clears the key and throws, letting the outer
bootstrap catch surface the error instead of triggering another reload.
A successful RSC response clears the key so a later unrelated failure
on the same path still gets one reload attempt.
Tighten the Playwright test that covers the 500 hard-nav path: after
the hard navigation settles, wait and assert both URL stability and a
bounded intercept hit count. A runaway reload loop would fail the hit
count assertion.
* fix(app-router): harden initial RSC recovery and cover non-RSC content-types
Five review follow-ups to the non-ok RSC fetch guard.
sessionStorage.getItem / setItem / removeItem can throw SecurityError in
strict-mode iframes, storage-disabled browsers, and some Safari private-
browsing configurations. The unguarded calls in readInitialRscStream
crashed hydration in exactly those environments — worse than the
original bug. Wrap every access in readReloadFlag / writeReloadFlag /
clearReloadFlag helpers that swallow the exception and fall through to
the "no prior attempt" branch.
Replace the throw after a looped reload with console.error plus a
never-resolving ReadableStream. main() is invoked as `void main()` with
no outer catch, so a throw here surfaced as an unhandled promise
rejection and fired window.onerror reporting hooks instead of leaving
the SSR'd DOM visible as the comment promised. Returning a stream that
never produces data halts hydration cleanly; the server-rendered HTML
stays on screen, client components simply never hydrate.
Clear the reload flag when readInitialRscStream enters the embedded-RSC
branch. The embed branch runs when the server successfully rendered the
page, so any prior flag is stale — without this clear, the flag could
persist across a hard-reload of the same path and skip the one legitimate
recovery attempt the user should get.
Cover the non-RSC content-type case on both the initial-hydration fetch
and the client-side navigation fetch. Next.js's equivalent guard
(fetch-server-response.ts:211) is `!isFlightResponse || !res.ok || !res.body`;
the previous commit only covered `!res.ok`. A proxy or CDN that returns
200 with a rewritten Content-Type (`text/html` instead of
`text/x-component`) would still reach createFromFetch and throw the
exact stream-parse error these guards exist to prevent. Check for a
Content-Type starting with `text/x-component` before parsing.
Tighten the Playwright hit-count assertion from <= 3 to <= 2. With the
guard in place the expected sequence is exactly two hits (the triggering
client RSC nav fetch plus one post-reload fetch that aborts); any extra
hit signals an unnecessary reload worth catching.
* test(app-router): narrow RSC error filter to stream-parse diagnostics
The test's console-error filter matched any message containing "RSC
navigation error", which also captures an unrelated pre-existing
hydration race: when page.evaluate calls __VINEXT_RSC_NAVIGATE__
before the AppRouter useLayoutEffect has committed, getBrowserRouterState
throws "Browser router state is not initialized". That race has nothing
to do with the stream-parse bug this PR fixes — the outer catch still
hard-navigates to the target URL correctly — but the broad filter
treated it as a failure, producing a flaky test that only passed on
retry (seen on commit 60719015: failed first attempt, passed retry #1).
My earlier commit's tighter hit-count assertion did not mask the
flakiness; it just happened to hit the race on more retries.
Replace the string list with an isRscStreamParseError helper that
matches only the concrete diagnostics createFromFetch emits when handed
an HTML body (Connection closed, createFromFetch / createFromReadableStream
stack frames, Failed to parse RSC, Unexpected token). The pre-fix path
produces exactly these messages, so the test still proves the fix; the
hydration race no longer false-positives.
* fix(app-router): abort hydration bootstrap when recovery cannot restore RSC
Address the half-hydrated-state side effect of the previous commit's
recovery path. When readInitialRscStream hits a persistent failure, the
returned never-resolving stream let main() continue past the await and
still assign window.__VINEXT_RSC_ROOT__, window.__VINEXT_HYDRATED_AT,
and window.__VINEXT_RSC_NAVIGATE__. External probes (test helpers,
analytics, Sentry hydration hooks) saw a hydrated page, user clicks on
Links triggered __VINEXT_RSC_NAVIGATE__, and the resulting fetch rendered
into a root that was suspended on a never-resolving initial-elements
promise, so every navigation also hung. None of those globals should
exist when hydration was deliberately aborted.
Change readInitialRscStream's return type to ReadableStream | null and
have recoverFromBadInitialRscResponse return null on the abort path
(second attempt on the same path). main() now early-returns when the
stream is null, leaving only the server-rendered HTML visible and no
hydration globals for external code to observe.
Add a console.warn on the first attempt before window.location.reload().
One-shot (the sessionStorage flag keeps it from repeating) but makes
production reload behavior traceable rather than a mystery.
Pin the invariant that makes the hard-nav destination correct after a
server-side redirect hop: the inline redirect branch keeps currentHref
in sync with history across hops, so window.location.href = currentHref
on a !ok / !isFlight response is safe. Comment flags this for future
refactors.
Tighten the test's RSC stream-parse filter to require an RSC-context
co-marker for generic strings ("Connection closed", "Unexpected token"),
so a benign third-party JSON.parse diagnostic cannot false-positive.
Rewrite the hit-count comment to match the assertion (<= 2 is the real
bound; hydration timing can make the prefetch race yield 1 hit).
* fix(app-router): tighten RSC error hygiene around hydration and unload
- recoverFromBadInitialRscResponse now returns null from both branches so
main() aborts the hydration bootstrap in the reload-pending case too. The
never-resolving ReadableStream sentinel would let main() proceed past
readInitialRscStream and register __VINEXT_RSC_ROOT__ / __VINEXT_RSC_NAVIGATE__
during the brief window before the reload takes effect, briefly exposing
a half-hydrated surface to external probes.
- Track isPageUnloading via a pagehide listener and suppress the
"[vinext] RSC navigation error" diagnostic when the catch fires because
a hard-nav or anchor click aborted an in-flight RSC fetch. Mirrors the
Next.js isPageUnloading pattern — the page is already going away, so the
log is just noise.
* test(app-router): tighten RSC fetch error suite
- Replace the fixed 1500ms stability wait with waitForLoadState("networkidle").
Tracking actual request activity avoids flaky wall-clock behavior in CI; a
reload loop keeps the network busy so the wait times out and still surfaces
the regression.
- Drop the third test. Its "URL does not contain .rsc" assertion is strictly
weaker than the first test's exact URL match on the non-.rsc destination.
* fix(app-router): reset isPageUnloading on pageshow for bfcache restore
Without a pageshow handler, a bfcache-restored document resumes with
isPageUnloading stuck at true, causing the navigation catch to silently
swallow every subsequent RSC error for the lifetime of that tab. Pair
the listeners to match Next.js' fetch-server-response.ts.
Also tighten the RSC fetch-errors spec with a >= 1 lower bound on the
.rsc hit count so a future regression that skips the client nav fetch
entirely still fails the test instead of trivially satisfying <= 2.
* refactor(app-router): unify recoveryFromBadInitialRscResponse return and fix test listener ordering
Collapse the two branches of recoverFromBadInitialRscResponse onto a
single return null at the end of the function, so the "always returns
null" invariant is structural rather than duplicated. A future refactor
that wanted to return a sentinel from one branch would have to move the
return, which is a visible edit — not a silent divergence.
Register the Playwright console listener before page.goto in both
rsc-fetch-errors specs so errors during initial page hydration are
captured, matching the invariant the tests rely on elsewhere.
* refactor(app-router): hard-nav to response URL and isolate hydration bootstrap
On an RSC nav that hits a non-ok/wrong-content-type response, prefer the
post-redirect response URL (navResponseUrl ?? navResponse.url) over the
original currentHref so a redirect-to-error chain lands the user directly
on the failing destination instead of bouncing off the redirect source
and re-following the same 3xx. Matches Next.js'
doMpaNavigation(responseUrl.toString()). Falls back to currentHref when
no response URL is available.
Extract the post-null-check body of main() into a synchronous
bootstrapHydration helper. The null-branch structurally cannot reach
any __VINEXT_RSC_* assignment now — a future refactor that interposes
async work between the stream read and the globals is a visible change
to the helper or its caller, not a silent invariant break.
Tighten the /about.rsc intercept glob to an anchored regex so a
hypothetical future self-prefetch from /about cannot inflate
aboutRscHits past the <= 2 bound. Note the dual-guard coverage
(status 500 + text/html) on the fulfill body so maintainers don't drop
either half.
* fix(app-router): add !body parity guard and preserve trailingSlash on hard-nav
Add the missing `!rscResponse.body` branch to readInitialRscStream so
the initial-hydration path guards on all three conditions Next.js checks
in fetch-server-response.ts (`!ok || !isFlightResponse || !body`), not
just the first two. A 200 with valid text/x-component but a null body
(e.g. 204-shaped responses, edge workers that return headers without
piping) now triggers the reload/abort recovery instead of parsing an
empty stream and throwing downstream.
Preserve the trailing slash when stripping .rsc from the hard-nav
target. toRscUrl normalizes `/foo/` to `/foo.rsc` before the fetch, so
the response URL loses the slash; stripping `.rsc` gave `/foo` and
sites with trailingSlash:true incurred an extra 308 to the canonical
`/foo/` form. Now restored when currentHref had one.
* feat(app-router): preserve hash on hard-nav and cover redirect-chain path
Preserve the fragment from the user's clicked href on the hard-nav
target — a .rsc response URL never carries a fragment, so dropping it
would silently strip /foo#section down to /foo. Small ergonomic win
over Next.js' doMpaNavigation, which has the same gap.
Add an e2e test for the redirect-chain hard-nav path (/redirect-src →
307 → /about.rsc → 500). Verifies the address bar lands on /about (the
post-redirect URL) rather than /redirect-src (the original request),
exercising the navResponseUrl ?? navResponse.url branch the previous
round introduced. Also pin the embedded-RSC assumption in the existing
500-route test by snapshotting the .rsc hit count before and after
networkidle and asserting no additional hits — so a future change that
makes the embed path conditional surfaces as a count change rather
than a networkidle timeout.
* chore(app-router): drop dead !body throw and tighten redirect-chain test
Remove the unreachable `if (!rscResponse.body) throw new Error(...)` at
the bottom of readInitialRscStream. The new !body guard added earlier
in the function returns via recoverFromBadInitialRscResponse before
this branch is reached, so the throw was stale belt-and-suspenders.
Tighten the redirect-chain e2e test:
- Capture every main-frame navigation URL via framenavigated and assert
/redirect-src never appears, so a regression that dropped the
navResponseUrl ?? navResponse.url branch is caught even though the
final URL would still converge to /about via the server's 307 replay.
- Pin the Playwright redirect-intercept assumption — the test relies on
Playwright re-entering the route table on the 307 follow-up so both
handlers fire in sequence; a future mocker migration that follows
redirects transparently would silently skip the /about.rsc intercept.
* fix(app-router): abort hydration when sessionStorage cannot persist reload guard
If sessionStorage is denied (strict-mode iframe, enterprise-locked
browser policy), the reload-loop guard's setItem silently no-ops and
every subsequent getItem returns null — turning the recovery path into
an infinite reload loop on a persistently broken .rsc endpoint. Verify
the write by reading it back and, if it didn't persist, abort hydration
immediately so the user sees the server-rendered HTML instead.
Hoist `new URL(currentHref, window.location.origin)` to a single
`origUrl` binding in the hard-nav target builder — the same href was
parsed twice for trailing-slash detection and hash extraction.
---------
Co-authored-by: Claude <noreply@anthropic.com>
* chore: upgrade workflows from claude-opus-4-6 to claude-opus-4-7
Update model references in bonk, bigbonk, and nextjs-tracker workflows
to use the latest Opus 4.7 model via the Cloudflare AI Gateway.
https://claude.ai/code/session_018pEfYehfR4YDA1Ekmu1vn3
* chore: upgrade opencode agents from claude-opus-4-6 to claude-opus-4-7
Update model references in reviewer, viguy, and nextjs-tracker agent
configs to use the latest Opus 4.7 model.
https://claude.ai/code/session_018pEfYehfR4YDA1Ekmu1vn3
---------
Co-authored-by: Claude <noreply@anthropic.com>
* Render intercept route layouts inside App Router slots
Intercepted App Router pages currently render without any layout.tsx files that live inside the intercepting route tree. This is wrong because Next.js applies those layouts before the slot layout, and nested intercept routes lose required wrappers when a modal intercept matches.
The routing pipeline assumed intercepts only needed a page module. That drops the intercept layout chain during discovery and never gives the slot override renderer enough information to rebuild the wrapper tree.
Collect intercept layout paths during route discovery, serialize them through the generated RSC entry, and apply the loaded layout modules around the intercepted page before the slot layout. Add regression coverage for discovery, code generation, and slot render order.
* Avoid repeated intercept layout scans and slot param wrapping
Intercept layout discovery rescanned the same directories for every intercepted page, and slot rendering rebuilt thenable params multiple times inside one render path. That adds avoidable work and makes the intercept wrapper path harder to read.
The implementation assumed these repeated lookups and allocations were harmless. That leaves the recursive router walk doing duplicate filesystem checks and gives slot wrappers different params wrapper identities in the same tree.
Thread the discovered intercept layout chain through the recursion, compute slot thenable params once per slot render, and keep the generated entry snapshots in sync with the added intercept layout plumbing.
* Clarify intercept layout plumbing and cover nested layout chains
The intercept layout plumbing still used mixed names in generated code, and the reverse layout wrapper loop took extra decoding to read. That makes the fix harder to review and maintain even though the behavior is correct.
The generated entry treated intercept-local layouts as a generic `layouts` field while the rest of the pipeline called them `interceptLayouts`, and the slot wrapper loop used a less direct reverse-index pattern. The tests also only covered a single intercept-local layout level.
Rename the generated intercept field to `interceptLayouts`, switch the reverse wrapper loop to the clearer classic form, and add regression coverage for nested intercept layout chains. Update the generated-entry snapshots to match.
* test(app-router): add E2E coverage for isPending across RSC redirect
router.push() to a page that calls redirect() in its server component
produced isPending flashing false mid-redirect. This test exposes that
by installing a MutationObserver before click, recording every text
value of #pending-state, and asserting no idle entry appears between
the first pending transition and the destination commit.
Ported from Next.js:
test/e2e/app-dir/navigation/navigation.test.ts#L482
New fixtures:
- /nextjs-compat/router-push-pending-redirect -- server component that calls redirect()
- /nextjs-compat/router-push-pending-destination -- final destination with #redirect-destination
- Push-redirect button added to existing PendingClient
* fix(app-router): keep isPending true across RSC-level redirects
When router.push() targeted a page whose server component called
redirect(), isPending flashed false mid-redirect. The fetch returned
a 307, navigateRsc detected the response URL differing from the
requested URL, settled the deferred pendingRouterState to prevent
promise orphaning, then recursed with programmaticTransition=false.
The settle fired before the recursive call so React resolved the
transition and isPending dropped to false during the hop.
Root cause: the recursive re-entry model required settling the
deferred promise before handing off, because the recursive call
had no way to inherit the existing promise. This was explicitly
documented as a tradeoff in the bandage comment at the settle site.
Convert the recursive redirect follow into an inline while loop.
The loop variables (currentHref, currentHistoryMode,
currentPrevNextUrl, redirectCount) are updated on each redirect
hop and the loop continues without re-entering navigateRsc. A
single pendingRouterState is created before the loop and lives
until the finally block, so isPending stays true across all
redirect hops and drops exactly once when the final destination
commits.
The scattered settlePendingBrowserRouterState() calls inside the
try body (early-return stale-id guards) are removed. The finally
owns the single settlement site, and the function is already inside
React.startTransition from the programmatic push, so the correct
transition priority is maintained throughout.
Closes#869.
* fix(app-router): track pending router state for programmatic navigations
`router.push()` / `router.replace()` / `router.refresh()` dispatched the
new tree only after the RSC payload arrived, so `useTransition().isPending`
dropped to false the moment the user callback returned and Suspense
boundaries visibly committed their fallbacks during the fetch window.
Wrapping the programmatic call in `useTransition` did not help because
the state update React needed to latch onto was not scheduled until the
navigation was already committed. Issue #639 describes the downstream
symptom: double-flashing Suspense fallbacks during client navigation.
Root cause: the browser router state was a `useReducer` that was only
dispatched at commit time. React had no in-flight promise to wait on,
so transition priority could not keep the previous UI on screen.
Switch the browser router state to `useState<State | Promise<State>>`
and, when a programmatic navigation starts, publish a deferred promise
into the state slot synchronously. `BrowserRoot` unwraps the slot via
`use()`, so any render during the RSC fetch suspends with transition
priority and the previous UI stays visible. The deferred promise is
resolved with the reducer output when the payload dispatches, or settled
with the current committed state on supersession / error / internal RSC
redirect, so `useTransition().isPending` latches true for the full
navigation window and drops exactly once at commit. `router.push` /
`replace` / `refresh` now also wrap their synchronous entry in
`React.startTransition`, matching Next.js's `app-router-instance.ts`.
Covered by `tests/e2e/app-router/nextjs-compat/router-push-pending.spec.ts`:
same-route search-param push inside `useTransition` must keep the
pending flag true until the server component commits its new filter.
Known limitation (tracked as a follow-up): the internal RSC-redirect
path still recurses through `__VINEXT_RSC_NAVIGATE__` with
`programmaticTransition=false`, so the outer pending must be settled
before the recursion. `useTransition().isPending` flashes false mid-redirect
until the recursive call finishes. Fixing this requires converting the
recursive redirect into an inline loop inside `navigateRsc` so a single
deferred promise can span all hops, the way Next.js's action queue does.
* address review feedback on programmatic router pending state
Replace duck-type "then" in value check with instanceof Promise in
isRouterStatePromise — the promises are always created locally so
instanceof is correct and avoids misclassification if AppRouterState
ever gains a then property.
Add a comment in dispatchBrowserTree explaining why the pendingRouterState
early return safely ignores the useTransitionMode branch — the outer
React.startTransition in router.push/replace/refresh already provides
transition priority, so no additional wrap is needed.
Remove dead typeof React.startTransition === "function" guards in
push/replace/refresh — vinext requires React 19+, where startTransition
is always present. Inline the transition call directly.
Bump SlowFilterResult delay from 200ms to 1_000ms so the isPending
assertion has a wider window and is less susceptible to CI timing races.
* fix app router sibling interception
* update app router entry snapshots
* preserve chained intercepted navigations
* address intercepted route review feedback
* guard probePage() against undefined PageComponent for slot-only routes
probePage() calls PageComponent(...) unconditionally. When a layout-owned
slot-only route is hit, route.page is null so PageComponent is undefined,
causing probePage() to throw TypeError on every request. probeAppPageComponent
catches it and returns null, so it does not crash, but it fires an avoidable
exception on every request to a slot-only URL and can confuse error monitoring.
probeLayoutAt() already uses the same guard pattern: if (!LayoutComp) return null.
Apply the same guard to probePage().
* test: cover revalidate = 0 route handler never-cache semantics
Route handlers with `export const revalidate = 0` currently emit no
Cache-Control header at all. Downstream gates treat the 0 value as
equivalent to "no revalidate configured" and skip the header write,
which lets CDNs and browsers apply heuristic caching to a response the
author explicitly opted out of.
The existing policy test asserted the buggy collapse (`toBeNull()` for
revalidate = 0), protecting the behaviour instead of verifying it.
Rewrite the policy test to assert the preserved 0 and add coverage for
the read/write/header gates for revalidate = 0. Add a response-level
test asserting the exact Cache-Control string Next.js emits for
revalidate = 0 (private, no-cache, no-store, max-age=0, must-revalidate).
These tests fail against the current implementation and will pass once
the policy helper preserves 0 and the header builder branches on 0.
* fix: emit no-store Cache-Control for revalidate = 0 route handlers
A route handler exporting `export const revalidate = 0` currently
sends no Cache-Control header at all. Downstream CDNs and browsers
fall back to heuristic caching, so a response the author explicitly
opted out of caching can still be cached and replayed.
getAppRouteHandlerRevalidateSeconds filtered on `> 0`, collapsing the
"never cache" signal (0) and the "no revalidate configured" signal
(null) into the same null return. The three gates downstream
(shouldReadAppRouteHandlerCache, shouldApplyAppRouteHandlerRevalidateHeader,
shouldWriteAppRouteHandlerCache) all short-circuit when revalidateSeconds
is null, so the header was never applied and no cache entry was written.
That second part was accidentally correct; the first part left the
response vulnerable.
Preserve 0 as a distinct value, using Number.isFinite to keep NaN and
Infinity out of the number branch along with negatives. Keep the ISR
read and write gates on `> 0` so a never-cache handler never serves or
persists ISR entries. Keep the header gate on `!== null` so 0 still
triggers a Cache-Control write, and branch buildRouteHandlerCacheControl
on 0 to emit Next.js's canonical no-store string (matches
packages/next/src/server/lib/cache-control.ts).
* fix(shims): narrow runWith* return type when callback is async
Add Promise<T> overloads to runWithRequestContext, runWithHeadersContext,
and runWithNavigationContext so async callbacks don't widen the return
type to T | Promise<T>. This clears 4 typescript-eslint(await-thenable)
warnings where Promise.all received the wider union.
* fix(shims): narrow remaining runWith* return types when callback is async
Extend the Promise<T> overload pattern from PR #864 to the other eight
exported runWith* helpers in the shims directory for consistency, so any
future call site passing an async callback gets the narrower Promise<T>
return type instead of the T | Promise<T> union.
Addresses bonk review on #864.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: James <james@eli.cx>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* 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.
* feat(build): wire build-time layout classification into the generated RSC entry
Introduce a Rollup generateBundle hook that patches the __VINEXT_CLASS
stub in the generated RSC entry with a real dispatch table built from
Layer 1 segment-config analysis and Layer 2 module-graph classification.
The runtime probe loop in app-page-execution.ts consults this table and
skips the dynamic-isolation probe for layouts we proved static or
dynamic at build time.
Add route-classification-manifest.ts as the codegen glue between the
classifier and the entry template, and flow buildTimeClassifications
through renderAppPageLifecycle so the runtime probe can honor the
build-time decision. Fail loudly if generateBundle sees __VINEXT_CLASS
referenced without the recognized stub body, so generator and plugin
cannot silently drift.
* refactor(build): address Copilot review comments on PR 842
Three targeted cleanups from code review:
- Replace inline canonicalize() with tryRealpathSync(p) ?? p, which is
already imported and does the same thing (realpathSync.native + fallback)
- Memoize readFileSync calls in collectRouteClassificationManifest so
shared layouts (especially the root layout) are read from disk only once
per build rather than once per route
- Cache classifyLayoutByModuleGraph results by canonicalized module ID in
generateBundle so shared layouts are BFS-traversed only once per build
rather than once per (routeIdx, layoutIdx) pair
* refactor: address review comments on build classification PR
- Use layer2.keys() instead of destructuring with unused _value
- Add comment to stub regex explaining intentional semicolon tolerance
- Add comment to __buildTimeClassifications clarifying module-load evaluation
- Add coupling comment to integration test regex explaining non-greedy assumption
* test: update entry-templates snapshots for module-load comment
* refactor: address second round of review comments
- Expand stubRe comment to note __VINEXT_CLASS name-retention assumption
- Null out rscClassificationManifest after generateBundle consumes it
- Fix buildGenerateBundleReplacement docstring to reflect type-level guarantee
* refactor: address third round of review comments
- Remove routeIdx from generated route objects (dead runtime property,
only needed as codegen loop variable for __VINEXT_CLASS(N) call)
- Null target.map after patching target.code in generateBundle
- Add Infinity-before-JSON.stringify comment in serializeReasonExpression
- Fix integration test stubRe to tolerate optional semicolon like the plugin
- Update extractRouteIndexByPattern to anchor on __VINEXT_CLASS(N) instead
of the removed routeIdx property
- Update snapshots
* refactor: address subagent review findings
- Mark classifyAllRouteLayouts as @internal test-only (production code
calls classifyLayoutByModuleGraph directly via the manifest pipeline)
- Document unreachable runtime-probe/no-classifier arms in
serializeReasonExpression with explanation of why narrowing is deferred
- Add manifest lifecycle invariant comment to load hook explaining the
1:1 correspondence between manifest layout indices and codegen routes
- Add test for layer1/layer1Reasons mismatch guard in mergeLayersForRoute
* refactor: hoist build-time classification helpers and rename layer1 binding
`canonicalize` and `dynamicShimPaths` were constructed inside the RSC
generateBundle hook, so they were rebuilt on every invocation even though
shimsDir is fixed for the plugin's lifetime and tryRealpathSync is
deterministic against the package's own shim files. The cost is small
because generateBundle runs once per build, but the hook body grew long
enough that hoisting the closure and the shim-path Set up next to the
shimsDir definition makes the bundle hook read more like the request-time
behaviour it is wiring up.
Separately, mergeLayersForRoute destructured Layer 1 entries as
[layoutIdx, value] and then wrote { kind: value, reason }. Since the
binding is always the Layer1Class kind, renaming it to `kind` lets the
merge use object-shorthand and removes the value-vs-kind shadowing.
No behaviour change; same realpath calls, same merge result, same
generated dispatch table. Targeted unit and integration suites still pass.
* Preserve module-graph reasons in classification manifest
* Share module-graph static reason type
* 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.
Rolldown cannot process native `.node` binary files. When a project uses
Tailwind CSS v4, the `@tailwindcss/oxide` package contains platform-specific
native bindings (`tailwindcss-oxide.darwin-arm64.node`) that cause Rolldown
to fail with `UNLOADABLE_DEPENDENCY` during dependency optimization.
Added `@tailwindcss/oxide` to the default `optimizeDeps.exclude` list
alongside `vinext` and `@vercel/og`.
Error reproduced with:
- Vite 8.0.8 + Rolldown 1.0.0-rc.15
- Any project with @tailwindcss/vite or @tailwindcss/postcss installed
- Tested against next-shadcn-dashboard-starter (Next.js 16, Tailwind v4)
Co-authored-by: Davin-fe-web3 <Davin-fe@gate.me>
* 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
Reshape buildPageElements to accept a single pageRequest object bundling
opts, searchParams, isRscRequest, request, and mountedSlotsHeader. Lift
the mounted-slots header read to the handler scope so every call site
shares one source of truth and a future refactor cannot silently drift
one read path out of the other.
The built package may execute error-boundary.tsx before the plugin's
resolveId hook is active, so the public next/navigation alias is not
available. Import the local shim directly instead.
* 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>