Stale App Router RSC cache entries currently schedule background regeneration with only the clean pathname. That violates the cache-key invariant because RSC app-page entries include the mounted slot variant, so concurrent stale variants for one pathname can collapse into one pending regeneration.
HTML ISR cache writes also read late dynamic usage from the cache branch after the client branch can clear request context. When dynamic APIs are discovered while streaming, the cache writer can miss that signal and persist personalized output.
Use the actual app-page ISR cache key as the regeneration dedup key, and snapshot late dynamic usage immediately before request context cleanup so the HTML cache writer can still skip writes after stream teeing. Add focused regression coverage for slot-specific stale regeneration and pre-cleanup dynamic usage.
* fix(config): exclude React from ssr optimizeDeps when ssr.external: true
When users set `ssr.external: true` in vite.config.ts (the only practical
way to keep CJS-quirky packages like pg, protobufjs, keyv,
@launchdarkly/js-server-sdk-common, @opentelemetry/otlp-transformer
working in dev), `'use client'` components calling React.useContext from
external packages (nuqs, next-themes, launchdarkly-react, etc.) crashed
in dev with `Cannot read properties of null (reading 'useContext')`.
Root cause: @vitejs/plugin-rsc populates
environments.ssr.optimizeDeps.include with React entries via
crawlFrameworkPkgs. With external: true, the SSR env loads React via
Node's resolver, but Vite still pre-bundles a duplicate React copy into
deps_ssr/ because optimizeDeps.include overrides external: true.
react-dom-server.edge then sets the dispatcher on its bundled React
while externalized callers (vinext's runtime) see a different React,
leaving React.H null and crashing every hook call from a 'use client'
module SSR'd through it.
Fix: when userSsrExternal === true, add React entries to
environments.ssr.optimizeDeps.exclude so plugin-rsc's auto-includes
cannot pre-bundle a duplicate copy. The renderer and the runtime then
share one Node-loaded React.
The RSC env is unaffected (it still pre-bundles React with the
react-server condition), and the default mode (`noExternal: true`) is
unaffected since external: true gating the strip.
Closes#1103
* fix(config): also skip top-level ssr.noExternal: true when ssr.external: true
The previous fix only stripped React entries from
environments.ssr.optimizeDeps.exclude, but Vite uses top-level `ssr.*`
as defaults for `environments.ssr.*`. With vinext setting top-level
`ssr.noExternal: true` unconditionally (when not Cloudflare/Nitro),
that `true` leaked into `environments.ssr.resolve.noExternal` and
@vitejs/plugin-rsc's later expansion to an array (or the user's own
configResolved mirror) brought React back into the bundled list,
recreating the duplicate-React crash.
Two changes:
1. When `config.ssr.external === true`, vinext only sets the top-level
`ssr: { external: true }` block — no `noExternal: true` to leak into
the per-env config.
2. Defensive configResolved hook that strips React entries from
`environments.ssr.resolve.noExternal` if a later plugin (or
plugin-rsc itself) added them back. The strip is gated on
`external === true` so it only applies in the all-external mode.
Verified against a real-world App Router app (~80 deps including nuqs,
next-themes, launchdarkly-react, blocknote, opentelemetry, pg,
protobufjs, keyv, etc.): with ssr.external: true and these fixes
applied, 'use client' routes now render correctly in dev — the
`Cannot read properties of null (reading 'useContext')` /
`Invalid hook call` crashes are gone.
Refs #1103
App Router middleware currently receives internal Flight request headers, and external middleware rewrite proxy responses can return upstream x-middleware-* control headers when no middleware header context exists. That exposes internal protocol state at request and response boundaries.
The middleware entry now clones requests with the Next.js Flight header set removed before middleware execution or middleware-owned external rewrites. External middleware rewrite proxy responses are always sanitized with the existing middleware header scrubber before any middleware headers are merged.
Regression coverage asserts middleware cannot observe Flight headers and that upstream proxy x-middleware-* headers are stripped without context.
Visible router state currently has an approved commit path, but the raw reducer remains an exported mutation escape hatch and approval traces only explain low-level reasons. That makes it harder to audit whether a payload became visible, was discarded, or forced a hard navigation.
The violated invariant is that visibleCommitVersion should have one owner behind ApprovedVisibleCommit, with every approval decision carrying an operator-visible transaction outcome.
Move the reducer implementation behind applyApprovedVisibleCommit, keep visibleCommitVersion advancement in that boundary, and prepend transaction trace codes for visible commits, no-commit decisions, and hard navigations.
* fix(router): gate refresh and traverse outcomes
Implements #726-CORE-05/06A and #726-CORE-06B from issue #726.
Compare pending operation visible commit versions against the latest visible state before applying refresh, traverse, or same-URL navigation payloads. Return explicit navigation payload outcomes so stale and hard-navigation results settle without seeding visited-response cache entries.
Make router.prefetch entries expose a cache-seeded outcome only after the snapshot is ready and pending work is cleared, and keep the ambient next/navigation declarations aligned with the runtime cache shape.
* chore: retrigger ci
* feat(router): expose route graph manifest read model
The App Router graph now exposes root-boundary identity and a stable RouteManifest read model instead of leaving these facts implicit inside per-route arrays. Future #726 planner and compatibility work needs a semantic lookup surface for routes, layouts, pages, templates, slots, and root boundaries before it can stop reading meaning from transport keys.
This adds RootBoundaryId, graphVersion, StaticSegmentGraph maps, and fail-fast invariant checks for mismatched layout/template positions while preserving the existing routes array for current callers.
* chore: retrigger ci
Request-time dynamicParams=false validation swallowed thrown and rejected generateStaticParams failures. That made application errors look like either successful validation or a 404 when a later source excluded the params.
The validation helper now lets generateStaticParams failures reject, matching the Next.js static params builder contract. The dispatch caller no longer passes a logging callback whose only recovery path was to continue after the failure.
Regression tests cover thrown, rejected, and partially failing generateStaticParams sources.
Fetch server actions accepted every value returned by decodeReply and spread that array into action.apply. A hostile action payload could force an oversized argument list and hit the same stack-overflow class Next.js guards against.
Add the Next.js-compatible 1000 argument limit immediately before invoking the action, preserving the existing action error packaging path. Cover the regression with a focused fetch-action test that proves the action is not called for 1001 decoded arguments.
App RSC route matching trusted callers to pass already-normalized pathname parts. That let encoded static segments miss and caused interception source params to be dropped when source paths still contained URL-encoded parts.
The matcher now reuses the existing route pathname normalizer before splitting both direct and interception source paths. Regression coverage verifies encoded static segments, exactly-once dynamic param decoding, and merged interception params.
* fix(router): reject stale same-url server action commits
Same-URL server action payloads could commit visible router state after newer visible work had already won. URL equality and activeNavigationId alone did not distinguish two different visible worlds at the same href.
The lifecycle gate now compares a pending operation's started visibleCommitVersion against the current router state's visibleCommitVersion at approval time. Stale results keep returning their action value, but they no longer mutate visible state or trigger stale hard-navigation recovery.
Tests cover out-of-order same-URL server actions and the visible-version stale path taking priority over root-boundary hard navigation.
* fix(router): preserve server action initiation version
Server action POST handling captured the router state before fetch but the same-URL commit helper previously rebased the pending operation when the response was decoded. If a newer visible commit landed while the POST was in flight, the old action could still look current.
Thread the initiation state through same-URL action commit handling so startedVisibleCommitVersion reflects the world that submitted the action. Approval still compares against the live router state after payload decoding, so stale results keep their action value without mutating visible state.
Add a hostile race test where the action response is processed after a newer same-URL visible commit.
* fix(deploy): resolve wrangler .CMD shim on Windows (#1095)
`runWranglerDeploy` invoked the bare-name `node_modules/.bin/wrangler`
file, which on Windows is a Unix shebang script that CreateProcess()
cannot execute, surfacing as a misleading `spawnSync wrangler ENOENT`.
Prefer the `.CMD` shim on win32, fall back to the bare name on other
platforms (and as an error-message fallback when nothing is found).
Extracted bin resolution into an exported, platform-injected
`resolveWranglerBin` helper so the behavior is unit-testable without
mutating `process.platform`.
Fixes#1095
* Apply suggestion from @ask-bonk[bot]
Co-authored-by: ask-bonk[bot] <249159057+ask-bonk[bot]@users.noreply.github.com>
---------
Co-authored-by: James Anderson <james@eli.cx>
Co-authored-by: ask-bonk[bot] <249159057+ask-bonk[bot]@users.noreply.github.com>
* feat(server): add disabled cache proof model
Route-scoped cache work currently lacks a typed proof boundary for representing render observations, variants, and breaker fallback decisions. That makes future cache reuse work easy to wire before proof completeness is explicit.
The missing invariant is that cache proof data must be canonical, redacted, bounded, and incapable of authorizing runtime reuse while the model is disabled.
Add a v0 cache proof model with RenderObservation, CacheVariant, BoundaryOutcomeCompatibility, variant budgets, and breaker fallbacks. The disabled decision always returns canReuse false, and tests cover canonical redacted dimensions, budget breaker paths, negative request API proof completeness, exact boundary outcome matching, and the disabled reuse gate.
Refs #726
* fix(server): use distinct invalid cache budget code
Invalid cache proof budget fields currently report the route variant ceiling reason code, even when the failing field is unrelated to route cardinality.
That conflates malformed configuration with an actual per-route variant ceiling breach.
Add CP_INVALID_VARIANT_BUDGET for invalid budget fields while leaving CP_ROUTE_VARIANT_CEILING_EXCEEDED for real route variant count failures. Cover maxEncodedLength as the regression case.
Refs #726
* fix(server): harden disabled cache proof invariants
Disabled cache proof variants currently collapse null and empty-string output scope fields into the same encoded value. That makes a future empty-string field addition able to collide silently with absent values.
The matcher also leaves two defensive contracts implicit: breaker fallback detection depends on the generic kind field, and the private boundary matcher would treat unknown as a match if called directly.
Preserve null through JSON output encoding, use the fallback-specific code field for the internal breaker guard, and make unknown outcomes non-matching at the matcher level. Add coverage for null versus empty-string scope keys and for missing request API observations not counting as negative proof.
Refs #726
Layout-only App Router routes with parallel slot content were omitted from prerender because the collection loop treated route.pagePath as the only renderable UI entry. That silently skipped static export output for routes whose visible content comes from slot page or default modules.
Use a shared App Router render-entry helper for prerender and build-report classification. It keeps route handlers API-only, preserves normal children page priority, and falls back to parallel slot page/default modules for layout-only UI routes.
Covers the regression with prerender output assertions for /parallel-nested/home, /parallel-nested/home/nested, and /slot-collision, plus build-report classification coverage.
* refactor(app-router): gate browser state writes with approved visible commits
Browser router commits currently mutate visible state from raw pending navigation actions after separate disposition checks. That leaves lifecycle approval implicit at each writer, which makes later stale-operation and planner work easier to bypass by accident.
Introduce CommitDecision and ApprovedVisibleCommit as the proof object for visible browser commits. Route navigation, same-URL action, and HMR state writes through the approved commit path while preserving the legacy dispatch, skip, and hard-navigate dispositions for current callers.
Tests cover approved navigate, replace, traverse, stale no-commit, and hard-navigation decisions, alongside the existing browser lifecycle regression coverage.
* refactor(app-router): close visible commit approval escape hatch
* ci: rerun checks
* refactor(app-router): isolate visible commit approval authority
* refactor(app-router): remove legacy commit disposition mirror
Visible commit classification still exposed a translated legacy disposition beside the authoritative decision. That left same-URL server action handling able to read two equivalent fields and made the approval boundary less clear than it needs to be.
Use the commit decision directly, remove the translation helper, keep the old state-level disposition private, and assert the non-visible trace reason codes in the approval tests.
* refactor(app-router): fence AppElements wire-key construction
AppElements transport keys could still be constructed or recognized through scattered raw string prefixes outside the wire codec. That kept the flat payload format acting as an informal semantic API and left legacy compatibility behavior covered only by indirect route tests.
Make AppElementsWire the exported constructor and parser boundary for route, page, layout, template, slot, cache, metadata, and unmatched-slot wire values. Route graph semantic ID minting stays separate, while render wiring, layout classification, browser slot merging, and prefetch cache tests now consume the codec boundary.
Tests cover canonical key construction and parsing, legacy metadata read/write behavior, unmatched-slot marker compatibility, and a source-boundary check that rejects new raw AppElements wire-key construction outside the codec and route graph.
* refactor(app-router): address AppElements wire review
Make slot-key recognition a cheap structural predicate, document the absolute tree-path parser contract, and broaden the boundary coverage to catch raw wire-key concatenation.
Also route layout classification ids through AppElementsWire so raw layout-key construction stays fenced inside the codec boundary.
* feat(app-router): add artifact compatibility envelope
App Router payloads now carry a parsed compatibility envelope at the existing payload metadata boundary. Without this skeleton, future cache and skip work would have no typed place to record graph, deployment, schema, root-boundary, or render-epoch proof.
Add a small constructor/parser pair, attach a default unknown-proof envelope to outgoing record payloads, and reject malformed envelope metadata when reading payload metadata. Tests cover explicit envelopes, legacy missing envelopes, malformed envelopes, and non-mutating payload writes.
* test(app-router): cover future artifact schema rejection
The review correctly identified that the envelope parser had malformed-field coverage but no direct test for the version guard path. Add the future-schema-version case and document why the Wave01 skeleton emits separate schema versions and an unknown-proof envelope without yet splitting mismatch from corruption behavior.
* feat(app-router): attach artifact compatibility metadata
App Router payloads previously carried only the COMPAT-01 unknown envelope unless a caller manually supplied metadata. That left COMPAT-02/03 without concrete graph, deployment, root-boundary, and fallback behavior in the PR.
The render lifecycle now attaches compatibility metadata for record payloads from route/root/deployment facts, while renderEpoch remains unknown until an epoch owner exists. The compatibility evaluator returns explicit compatible, unknown, or incompatible decisions so unknown proof always falls back to render fresh instead of becoming reuse proof.
Tests cover render-boundary metadata attachment and unknown graph, deployment, root-boundary, and render-epoch decisions.
* fix(app-router): tolerate unused compatibility metadata parse failures
App Router metadata parsing crashed when __artifactCompatibility was malformed or from a future schema, even though current render and navigation callers do not consume that field yet.
The parser treated compatibility scaffolding like required route metadata. That made an unused proof capable of failing initial mount, SSR, and client navigation paths.
Default invalid compatibility metadata to the unknown proof for Wave01, document the planned hard-fail point for #726-COMPAT-04/05, and cover malformed, future-version, and non-object payloads.
* 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>
* test(app-router): lock navigation lifecycle settlement and root-layout hard navigation behaviour
Adds 13 unit tests covering two Wave 0 baseline areas from issue #726:
BASE-02 — Lock current lifecycle settlement behaviour (9 tests):
- Three concurrent navigations with reverse-order payload resolution
- Rapid sequential navigation: stale deferred payload does not overwrite
- Stale cross-root navigation is skipped instead of hard-navigating
- resolvePendingNavigationCommitDisposition exhausts skip/dispatch/hard-navigate
- resolveAndClassifyNavigationCommit classifies skip when IDs diverge
- Failed RSC payload settles pending router state cleanly
BASE-03 — Lock root-layout hard navigation behaviour (4 tests):
- renderNavigationPayload calls window.location.assign on root change
- Hard-navigate settles the pending router state before navigating away
- shouldHardNavigate negative/null path exhaustiveness
- shouldHardNavigate positive path with distinct roots
These tests close the App Router concurrency gap: the Pages Router has 10
race-condition tests in shims.test.ts but the App Router (which uses a
completely different lifecycle mechanism) had zero equivalent tests for
out-of-order payload resolution and stale commit rejection.
* test: remove hallucinatory comments from app-browser-entry tests
* test: remove duplicate shouldHardNavigate null-root assertion
* test: remove low-signal helper truth-table tests, fix hard-navigate ordering assertion
---------
Co-authored-by: James <james@eli.cx>
* fix: filter internal Next.js headers from inbound requests
Next.js strips internal headers (x-nextjs-data, x-matched-path,
x-now-route-matches, x-next-resume-state-length, and x-middleware-*)
via filterInternalHeaders() at the router-server entry point before
any handler or middleware sees the request. This prevents attackers
from forging these headers to influence routing or impersonate
internal data fetches.
vinext was not filtering any of these headers from inbound requests,
so all four reached middleware as user-controlled request headers.
Ported from Next.js INTERNAL_HEADERS list and filterInternalHeaders():
- https://github.com/vercel/next.js/blob/canary/packages/next/src/server/lib/server-ipc/utils.ts
- https://github.com/vercel/next.js/blob/canary/packages/next/src/server/lib/router-server.ts
Added filterInternalHeaders() to server/request-pipeline.ts and wired
it into all request entry points:
- App Router handler (app-rsc-handler.ts)
- CF Worker App Router entry (app-router-entry.ts)
- Node prod-server (nodeToWebRequest + Pages Router path)
- Dev server Pages Router middleware path (index.ts)
- Generated Pages Router worker entry (deploy.ts)
Filtering runs before middleware, config matching, and routing so no
subsystem sees forged internal headers. The x-middleware-* headers are
stripped from requests but NOT from middleware responses — the
middleware protocol (which reads x-middleware-* from Response headers,
not request headers) continues to work correctly.
* fix: use safe Request rebuild instead of mutating headers directly
Request.headers is immutable in Cloudflare Workers / Miniflare /
workerd environments. The previous in-place mutation via
headers.delete() threw 'Can't modify immutable headers', causing
every request to 500 in the cloudflare-dev E2E suite.
Changed filterInternalHeaders() to always return a new Headers
copy (never mutates the input). Call sites that use request.headers
now rebuild the Request with the filtered headers, matching the
existing pattern in applyMiddlewareRequestHeaders() at
config-matchers.ts:495-502.
* fix: clone requests when filtering headers
* fix: clone Requests safely across runtimes
* fix: avoid type assertions in Request cf access
* fix: move header filtering from handler body to entry boundary
handleAppRscRequest() is an internal handoff point, not a trust
boundary. Filtering here broke the x-vinext-mw-ctx handoff in hybrid
app+pages dev mode because cloned Request objects could drop
coordination state from the connect handler, causing middleware to
run twice.
Moved filtering to the top of createAppRscHandler(), the true App
Router request boundary, before headersContextFromRequest() and
requestContextFromRequest() capture any context. This also fixes the
security gap where forged headers were captured in the request context
before filtering ran.
* fix: preserve x-vinext-mw-ctx when filtering internal headers in app-rsc-handler
srvx's NodeRequestHeaders reads from rawHeaders for iteration but falls back
to req.headers for .get() / .has(). In the dev server we add x-vinext-mw-ctx
to req.headers after the Request is built, so it is visible to .get() but lost
when filterInternalHeaders iterates. Read it BEFORE iterating so
applyForwardedMiddlewareContext can skip middleware and avoid double execution.
Fixes CI failure on PR #1056.
* fix: copy cf in both clone paths, add cloneRequestWithHeaders tests
cloneRequestWithHeaders now copies the Workers cf property in both
the primary (try) path and the fallback path. Added 8 unit tests
locking down method, URL, redirect, signal, body, cf, and combined
metadata preservation to prevent regression to the broken
URL-constructor pattern.
* perf(pages): cache ISR misses from streamed render
Pages Router ISR cache misses currently render the page once for the live response and then render a second React tree to produce cache HTML. That doubles userland render, data reads, suspense work, and render-scoped side effects on every cold cache fill.
The cache writer now tees the already-rendered body stream, records the cache branch with streaming UTF-8 decoding, and writes the same shell and body that the client receives. Stream failures report through the Pages Router render error path and never persist partial HTML.
Tests cover the single-render cache fill, split UTF-8 chunks, failed stream writes, and the Pages Router fixture state contract.
* fix(pages): keep ISR cache error reporting non-fatal
* chore: rerun CI
Cold App Router SSR requests currently share Vite RSC's underlying module import promises, but each request can still iterate the full client-reference manifest and allocate its own Promise.all fan-out before the completed boolean flips. That keeps first-request correctness but wastes work during cold bursts.
The preload state only tracked completion, not in-flight work. Concurrent callers had no shared promise to await, and the preload logic lived inline in the SSR entry where the concurrency contract was hard to test.
Add a typed client-reference preloader that shares the global manifest preload promise, dedupes per-reference work for future scoped manifests, preserves non-fatal reference load reporting, and keeps handleSsr awaiting the preload before React consumes the Flight stream.
* feat(app-router): add navigation trace reason-code shell
Navigation commit classification can now expose compact reason-code traces for the existing stale, root-boundary, unknown-root, and dispatch outcomes. The plain disposition-only path remains allocation-light for the browser navigation hot path.
The old implicit string result made it hard for follow-up lifecycle work in #726 to attach structured operational context without inventing narrative logs at each call site. This adds a narrow trace contract and threads it through the same-URL classification helper while preserving current commit behavior.
Covered by app-browser-entry tests for stale commit skips, root-boundary hard navigation, unknown root-layout fallback, same-URL classification traces, and trace field ownership.
* fix(app-router): tighten navigation trace shell
The OPS-01 trace shell exposed an append helper before any source path needed multi-entry traces, typed reason-code values too loosely, and let the combined same-URL classifier allocate a trace that the controller never consumed.
Remove the unused append helper, derive reason-code unions from a literal key-to-code map, keep the classifier on the allocation-light disposition path, and add the missing normal dispatch trace coverage.
Next.js PARAMETER_PATTERN accepts any non-] characters inside brackets:
[repo:name], [variant.id], [user@domain]
vinext was restricting param names to [\w-]+, silently treating segments
with dots, colons, or @ as literal URLs. This fix aligns the regexes with
Next.js:
- app-route-graph.ts: ^\[([^\]]+)\]$ (was [\w-]+)
- pages-router.ts: same
- route-validation.ts: patternToNextFormat now backtracks over +/* suffixes
- shims/router.ts: extractRouteParamNames now accepts dotted/colon names
Also corrects a misleading comment in routePrecedence: dynamic routes CAN
score negative, but the trie matcher (route-trie.ts) always checks static
children before dynamic children at each node, so purely-static routes still
win at request time.
Next.js reference:
https://github.com/vercel/next.js/blob/canary/packages/next/src/shared/lib/router/utils/get-dynamic-param.ts#L175Closes#7
* refactor(app-router): introduce AppElementsWire boundary
App Router payload ownership currently leaks through raw helper calls for route ids, cache keys, metadata writes, and decode paths. That makes the flat AppElements transport act like router semantics instead of a narrow wire boundary, which violates the #726-WIRE-01 ownership target.
Introduce AppElementsWire as the single codec facade for AppElements wire encoding, decoding, metadata creation, and outgoing payload shaping. Route the current production encode/decode/write call sites through that facade while keeping the legacy helper names re-exported only where existing tests cover compatibility.
* chore(app-router): address AppElementsWire review
App Router route graph entries previously left route, page, layout, template, and slot identity to later render and wire layers. That kept topology meaning coupled to reconstructed payload keys instead of a graph-owned fact.
Mint stable semantic IDs during graph construction, preserve them in generated RSC route metadata, and cover route-group, dynamic-segment, slot-owner, and filesystem-root stability cases.
Browser router state changed without a versioned visible commit baseline or a typed operation record, leaving later stale-result gates without a stable comparison point.
Add pending and committed OperationRecord metadata, thread explicit operation lanes through browser navigation, refresh, traverse, same-URL server actions, and HMR, and advance visibleCommitVersion through the reducer path that accepts a new visible router-state commit.
Also harden the prefetch-cache unit test to wait for async router.prefetch setup rather than relying on a fixed scheduler tick.
* fix(shims): deep-merge nested metadata and add OG/Twitter inheritance
mergeMetadataEntries did a flat top-level key replacement, so setting
openGraph: { title: "Override" } in a child layout erased all root
OpenGraph fields like siteName and images. Next.js resolves these
per-subkey; we now shallow-merge the nested objects for openGraph,
twitter, icons, alternates, robots, and other.
Setting only openGraph.title without twitter.title produced no Twitter
Card tags because vinext skipped Next.js's postProcessMetadata step.
We now auto-fill twitter:title from og:title (or metadata:title),
twitter:description from og:description (or metadata:description),
and twitter:images from og:images, and default twitter:card to
summary_large_image when images are present.
Ported from Next.js:
- https://github.com/vercel/next.js/blob/canary/packages/next/src/lib/metadata/resolve-metadata.ts
- https://github.com/vercel/next.js/blob/canary/packages/next/src/lib/metadata/resolvers/resolve-opengraph.ts
* fixup! fix(shims): deep-merge nested metadata and add OG/Twitter inheritance
Extract postProcessMetadata from mergeMetadataEntries to avoid
polluting intermediate merge results during layout accumulation.
Run it once at the end of resolveAppPageHead after file-based
metadata has been applied, matching Next.js's accumulator pattern.
Also fix two issues caught by Copilot review and CI:
1. Twitter auto-fill now works even when openGraph is absent —
metadata.title and metadata.description serve as fallback sources.
2. Remove all as assertions from production code; use typed locals
and direct index access via Metadata's string index signature.
3. Update app-page-head.test.ts expectations to match post-processed
output and add missing regression test for no-openGraph case.
* chore: address review feedback — clone input, ?? for title, missing test
- postProcessMetadata now shallow-clones its input to prevent accidental
mutation of the caller's object.
- resolveStringTitle uses ?? instead of || to avoid treating empty
string as falsy (semantically correct but practically no difference).
- Added comment explaining why verification, appleWebApp, appLinks,
and formatDetection are excluded from DEEP_MERGE_KEYS.
- Added test: child can clear a parent openGraph subkey by setting it
to undefined, verifying twitter auto-fill skips cleared images.
* fix(shims): match Next metadata segment replacement
Nested metadata objects were being merged across segments, which kept stale parent OpenGraph, Twitter, alternates, icons, and robots fields when a child segment defined the same key.
Next only merges custom other metadata across segments. Replace the broad nested merge with an explicit other merge, keep OG-to-Twitter post-processing gated by openGraph, and update metadata tests to lock in the compatibility contract.
* fix(routing): discover nested parallel slot sub-routes from layout-only parents
Nested parallel routes failed when the parent segment had a layout but no
page.tsx. discoverSlotSubRoutes skipped any parent route without pagePath,
so slots like @parallelB/nested/page.tsx under home/layout.tsx (but no
home/page.tsx) were never discovered.
Next.js explicitly tests this shape in parallel-routes-and-interception:
app/parallel-nested/home/layout.tsx + @parallelB/default.tsx +
@parallelB/nested/page.tsx creates a valid /parallel-nested/home/nested
route.
The fix makes three targeted changes to discoverSlotSubRoutes:
1. Remove the hard pagePath requirement. Layout-only routes with parallel
slots can still own nested slot sub-routes.
2. Compute parentPageDir from the innermost layout when pagePath is null,
so slot ownership checks still work for layout-only routes.
3. Relax the children default.tsx requirement, but only for layout-only
parent routes. When a parent HAS a children page, default.tsx is still
required as a fallback for the synthetic sub-route. Layout-only parents
do not need this fallback because the children slot was never occupied.
4. Add structural-conflict detection before creating synthetic routes. This
prevents layout-only root routes from generating synthetic routes that
collide with existing page routes differing only by param name (e.g.
/shop/:id vs /shop/:name), which validateRoutePatterns rejects.
Tests ported from the Next.js parallel-routes-and-interception suite cover
both route-discovery and end-to-end rendering.
Refs: https://github.com/vercel/next.js/blob/canary/test/e2e/app-dir/parallel-routes-and-interception/parallel-routes-and-interception.test.ts
* fix(routing): correct patternsStructurallyEquivalent tree-node comparison
The initial implementation of patternsStructurallyEquivalent used a naive
dynamic-segment check (startsWith(':')) that produced two categories of
false positives:
1. Literal vs dynamic segments (e.g. 'about' vs ':id') were considered
equivalent, incorrectly suppressing valid synthetic routes.
2. Single dynamic vs catch-all segments (e.g. ':id' vs ':slug+') were
considered equivalent, even though they map to different validator tree
nodes ([] vs [...]).
Replace the ad-hoc boolean logic with segmentTreeNodeType, which classifies
each segment by its Next.js validator tree node (literal, dynamic, catchAll,
optionalCatchAll). Two patterns are structurally equivalent only when every
segment maps to the same tree node type.
Also add a clarifying comment to the parentPageDir computation so the next
developer understands why the innermost layout proxies the route directory
for layout-only routes.
* fix(routing): scan synthetic routes in structural conflict check
Copilot review identified that patternsStructurallyEquivalent only scanned
the original routes array, missing conflicts between synthetic routes created
earlier in the same loop. Fixed by scanning routesByPattern.values() which
includes both original and synthetic routes.
Also adds a TODO comment documenting the known prerender limitation for
layout-only synthetic routes (skipped at build/prerender.ts:849). These
routes work in dev/SSR but won't be statically exported — this is
pre-existing behaviour for all layout-only routes.
* test(routing): cover structural conflict skip for synthetic parallel routes
Adds a unit test proving that discoverSlotSubRoutes skips synthetic routes
when an existing page route has the same structural shape but a different
param name (e.g. /shop/:id page + @feed/[name]/page.tsx slot).
Without this coverage the structural-conflict safeguard is easy to regress.
* fix(routing): guard discoverSlotSubRoutes to layout-only UI routes
Review identified that removing the old !parentRoute.pagePath guard also
let route-handler-only AppRoutes enter discoverSlotSubRoutes. Route
handlers have pagePath: null but are not layout-only UI routes. A shape
like app/api/route.ts + app/@feed/foo/page.tsx could make the /api route
handler scan the ancestor slot and materialise a synthetic UI route under
/api/foo.
Add an explicit isLayoutOnlyUiRoute check:
!parentRoute.pagePath && !parentRoute.routePath && parentRoute.layouts.length > 0
Only page-bearing routes or layout-only UI routes can own nested parallel-
slot sub-routes. Route handlers and layout-less routes are skipped.
Also adds a regression test: a route.ts handler with an ancestor parallel
slot must not emit synthetic routes under the handler pattern.
* fix(routing): scan synthetic routes in structural conflict check + regression test
The structural-conflict guard had regressed back to scanning the original
routes array, missing conflicts against synthetic routes created earlier in
the same discoverSlotSubRoutes pass. Fixed by scanning
Array.from(routesByPattern.values()) instead.
Also adds a regression test proving that two slot sub-pages with different
param names under the same parent do not both materialise (e.g. @a/[id] and
@b/[name] under /shop — only one of /shop/:id or /shop/:name is emitted).
* 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>
Route handlers used a local Object.assign(Promise.resolve(...), params)
helper that did not protect any well-known properties from shadowing.
Page params used a Proxy but only guarded then/catch/finally, missing
React's Promise status field and other well-known properties.
Next.js explicitly reserves a well-known property set (then, catch,
finally, status, toString, hasOwnProperty, etc.) so that params with
those names do not break Promise behaviour or React introspection.
This change:
- Expands the shared makeThenableParams shim to protect the full
well-known property set, matching Next.js reflect-utils.ts.
- Replaces the weak route-handler local helper with the shared shim.
- Adds unit tests for status and Object.prototype method shadowing.
- Adds App Router integration tests for pages and route handlers with
params named then, catch, finally, and status.
Ported from Next.js:
- https://github.com/vercel/next.js/blob/canary/packages/next/src/shared/lib/utils/reflect-utils.ts
- https://github.com/vercel/next.js/blob/canary/test/e2e/app-dir/cache-components/cache-components.params.test.ts
* fix(routing): decode matched params with decodeURIComponent
Vinext's segment-safe normalization preserves encoded delimiters (%2F) for
correct route matching, but the captured param values were left undecoded.
This caused dynamic params to return "a%2Fb" instead of "a/b", diverging
from Next.js which decodes at the route-matcher level.
Add decodeURIComponent pass in trieMatch and matchRoutePattern — the two
route matching entry points — so all downstream consumers (pages, route
handlers, metadata, useParams) receive decoded param values.
Mirrors Next.js: packages/next/src/shared/lib/router/utils/route-matcher.ts L25-27
* test(app-rsc-route-matching): update param decode assertion to match new behavior
* 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.
Adds the `other` field to `RobotsRule` type and emits non-standard per-user-agent
directives (e.g. Seznam Request-Rate, Yandex Clean-param) after Crawl-delay and before
the blank line separating user-agent blocks. Array values expand into repeated lines;
null/undefined entries are skipped.
When validateAppPageDynamicParams iterates multiple generateStaticParams
sources, a throw in one source previously short-circuited the try-catch
wrapping the whole loop, skipping remaining sources. Move the try-catch
inside the loop so each source is independently guarded.
* refactor(app-rsc): introduce app-rsc-request-normalization module
The RSC entry handler inlined all URL parsing, pathname decoding, basePath
checking, RSC detection, and header sanitization directly in its
_handleRequest template. That made the logic impossible to unit-test, hard to
audit for security, and duplicated normalizeMountedSlotsHeader in three places.
Create server/app-rsc-request-normalization.ts to own the full normalization
pipeline as a single, typed function (normalizeRscRequest). The function
returns a discriminated Response | NormalizedRscRequest, encoding early exits
(400/404) in the type rather than as throw-then-catch pairs.
The ordering within the function is security-critical and documented step by
step:
1. Parse URL
2. Protocol-relative guard (on raw pathname, before normalizePath collapses //)
3. Strict segment-wise percent-decode (malformed encoding -> 400)
4. normalizePath (collapse //, resolve . and .. segments)
5. basePath check + strip (missing prefix -> 404; /__vinext/ bypasses)
6/7. RSC detection and cleanPathname
8. Sanitize X-Vinext-Interception-Context (strip null bytes)
9. Normalize x-vinext-mounted-slots (dedup, sort)
Consolidate normalizeMountedSlotsHeader: the function existed independently in
isr-cache.ts and as a private copy in app-elements.ts. Both now import from
the new module. isr-cache.ts re-exports it for callers already importing from
there.
43 new behavior tests cover the security-sensitive paths: protocol-relative
variants (literal, backslash, %5C, %2F), malformed percent sequences, basePath
enforcement, %2F preservation, RSC detection by suffix and Accept header,
interception context sanitization, and mounted-slots normalization.
* refactor(app-rsc): wire normalizeRscRequest into the entry and callsites
Replace the open-coded normalization block in the generated _handleRequest
function with a single normalizeRscRequest call. The entry now delegates
all security-sensitive preprocessing to the typed module rather than
repeating it in a template string.
Remove the local normalizeMountedSlotsHeader implementation from isr-cache.ts
(was a standalone export) and remove the private copy from app-elements.ts.
Both now import from app-rsc-request-normalization.ts. isr-cache.ts re-exports
the function so existing callers that import from there are unaffected.
Generated entry changes:
- Add import for normalizeRscRequest from the new module
- Remove guardProtocolRelativeUrl and stripBasePath from requestPipeline import
- Remove normalizePathnameForRouteMatchStrict from routingUtils import
- Remove normalizeMountedSlotsHeader from isrCache import
- Replace ~20-line normalization block with 4-line call + destructure
* refactor(app-rsc): move request lifecycle into typed handler
The generated App Router RSC entry still owned request normalization, config routing, middleware handoff, public-file serving, action dispatch, fallback routing, and response finalization. That made lifecycle behavior hard to test directly and forced generator assertions to reach into runtime sequencing.
The violated invariant was that codegen should describe the app shape while normal modules should implement behavior. This extracts the request lifecycle into createAppRscHandler, leaves the generated entry responsible for route-specific manifests and dispatch closures, and adds behavior tests for redirects, rewrites, public files, actions, route handlers, and fallback cleanup.
* refactor(app-rsc): introduce app-rsc-request-normalization module
The RSC entry handler inlined all URL parsing, pathname decoding, basePath
checking, RSC detection, and header sanitization directly in its
_handleRequest template. That made the logic impossible to unit-test, hard to
audit for security, and duplicated normalizeMountedSlotsHeader in three places.
Create server/app-rsc-request-normalization.ts to own the full normalization
pipeline as a single, typed function (normalizeRscRequest). The function
returns a discriminated Response | NormalizedRscRequest, encoding early exits
(400/404) in the type rather than as throw-then-catch pairs.
The ordering within the function is security-critical and documented step by
step:
1. Parse URL
2. Protocol-relative guard (on raw pathname, before normalizePath collapses //)
3. Strict segment-wise percent-decode (malformed encoding -> 400)
4. normalizePath (collapse //, resolve . and .. segments)
5. basePath check + strip (missing prefix -> 404; /__vinext/ bypasses)
6/7. RSC detection and cleanPathname
8. Sanitize X-Vinext-Interception-Context (strip null bytes)
9. Normalize x-vinext-mounted-slots (dedup, sort)
Consolidate normalizeMountedSlotsHeader: the function existed independently in
isr-cache.ts and as a private copy in app-elements.ts. Both now import from
the new module. isr-cache.ts re-exports it for callers already importing from
there.
43 new behavior tests cover the security-sensitive paths: protocol-relative
variants (literal, backslash, %5C, %2F), malformed percent sequences, basePath
enforcement, %2F preservation, RSC detection by suffix and Accept header,
interception context sanitization, and mounted-slots normalization.
* refactor(app-rsc): extract response finalisation into typed helper
Config header application runs inline in the generated RSC entry handler()
as a template string with no tests. Every response variant (page, route
handler, server action, not-found) goes through handler(), but the shared
finalisation logic cannot be tested or reused — a classic rot factory.
Create server/app-rsc-response-finalizer.ts that owns:
- Skip 3xx redirect responses. Response.redirect() produces immutable Headers
that throw on write; Next.js also deliberately excludes config headers from
redirects.
- Normalize the request pathname (segment-wise percent-decode + collapse)
before matching config header source patterns.
- Strip basePath at a segment boundary before matching. The old inline code
used pathname.startsWith(basePath) which incorrectly matched /app2/page when
basePath is /app; stripBasePath() enforces the separator boundary.
- Apply matching config headers via applyConfigHeadersToResponse, preserving
middleware-set header precedence.
The generated entry now calls __finalizeAppRscResponse(response, request, {...})
in place of ~15 lines of template logic, and drops the now-unused
applyConfigHeadersToResponse and normalizePathnameForRouteMatch imports from
the generated code.
Tests cover: header applied to 200, empty configHeaders no-op, source
pattern miss, immutable 307 no throw, mutable 308 header skipped, basePath
strip, basePath segment-boundary (regression for startsWith bug), nested
basePath, has-condition match, has-condition miss.
Follow-up to #1039. Two related polish items in the inherited-slot
mirror runtime:
1. Bug under `basePath`: `buildSlotOverrides` was parsing `request.url`
to derive the URL parts to match against `slot.slotPatternParts`. The
original request URL still carries the configured `basePath`, but
`slotPatternParts` is built from app-relative segments only — so for
every basePath-configured app, the pattern match silently failed and
slot params fell back to the route's matched params. Use the already
normalized `routePath` parameter (basePath stripped, RSC suffix
removed) instead. Added a unit test that exercises the regression.
2. Memoize `findSlotSubPages` per build via a `WeakMap` keyed by the
matcher. Inherited slots get scanned once per descendant route, so
without memoization a route N segments deep paid O(N) full subtree
walks for every shared ancestor slot. Cache lifetime tracks the
matcher's, so a long-lived dev server doesn't accumulate state across
builds.
Also dedupes a redundant `convertSegmentsToRouteParts` call inside
`findMirroredSlotPage` (both tiers needed the same conversion).
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Surfaces three error sources that previously only reached the console:
React render errors caught by an error.tsx boundary (onCaughtError),
render errors with no boundary above them (onUncaughtError), and plain
script errors / unhandled promise rejections via window listeners.
The overlay renders into a separate createRoot mounted on a detached div
appended to body, so it survives an unmount of the main hydrateRoot tree
— necessary because most errors we want to surface take that tree down.
Includes a /dev-overlay-test fixture and Playwright spec covering all
three error sources, dismiss, and prev/next pagination across multiple
errors.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(routing): mirror inherited parallel slots to descendant sub-pages
discoverInheritedParallelSlots was hard-coding pagePath to null for any
ancestor slot, so inherited slots only ever rendered default.tsx — even
when an obvious mirror like @breadcrumbs/about/page.tsx existed for the
matching route. Track each ancestor's segment index, then look up a
mirror via either a literal filesystem match or, when route groups appear
on either side, by URL-parts comparison through convertSegmentsToRouteParts.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(routing): extract slot-specific params for inherited mirrors
When an inherited parallel slot's mirrored sub-page declares dynamic
markers under different names than the route's (e.g. slot uses
@breadcrumbs/distinct/[name] for a route at distinct/[id]), the runtime
was passing the route's matchedParams through unchanged — so the slot
page received {id} instead of {name} and rendered with an empty value.
Three pieces:
* `findMirroredSlotPage` now also accepts pattern-compatible matches and
returns the slot's URL parts and param names. Specificity scoring
picks the most-specific compatible sub-page (literal > dynamic >
catch-all).
* Each ParallelSlot carries optional `slotPatternParts` and
`slotParamNames`, serialized through the manifest.
* `buildPageElements` extracts a per-request `slotOverrides[key].params`
by matching the request URL against `slotPatternParts` whenever a
slot's param names aren't a subset of the route's. `pageModule` on
`AppPageSlotOverride` becomes optional so a params-only override
doesn't have to swap the slot's page.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(routing): address bonk review on inherited parallel slot mirror
* Swap inverted scoring for required vs optional catch-alls — `:name+`
(matches ≥1 segments) is more constrained than `:name*` (≥0), so it
now scores higher, preventing a less-specific optional catch-all from
shadowing a required one when both compete for the same route.
* Tighten findMirroredSlotPage docstring: the implementation collapses
exact URL-parts equality and pattern compatibility into one scored
loop, where literals naturally win. Rewrite the comment to reflect
what the code actually does.
* Document patternsCompatible's conservative rejection of literal slot
segments paired with dynamic route segments (and the consequence that
a literal slot sub-page never matches a catch-all route).
* Note in buildSlotOverrides why an empty slotParamNames also skips the
override (no dynamic markers means nothing to extract).
* Add unit tests for two cases that were only covered indirectly:
graph-level slotPatternParts/slotParamNames when route and slot use
different param names, and a slot owned at an intermediate ancestor
rather than appDir.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Previously, vinext dev --port (no value) silently returned NaN,
vinext dev --hostname returned undefined, and --port --hostname 0.0.0.0
consumed --hostname as the port value.
Now the parser throws clear errors when a value-taking flag is missing
its value or when the next argument looks like another flag. Extracted
parseArgs to cli-args.ts for testability.
The build pipeline already rewrites `next/font/google` calls to embed
self-hosted @font-face CSS pointing at `/<assetsDir>/_vinext_fonts/...`,
but the call-site rewrite was gated behind `isBuild`. Dev requests for
those URLs 404'd because nothing copies the cached `.vinext/fonts/`
files into the dev server's served paths, and once `_selfHostedCSS` is
present the shim no longer emits the fonts.googleapis.com `<link>` —
so dev showed unstyled glyphs with no CDN fallback.
Drop the build-only gate and add a `configureServer` middleware that
serves the cached files directly under the same URL prefix the
@font-face CSS embeds. Reuse the prod `CONTENT_TYPES` map so dev and
prod hand out identical MIME types.
Regression spec navigates to a new `/font-google-test` route in
app-basic that uses Geist + Geist_Mono, asserts no CDN URL leaks into
the HTML, and fetches a preload URL to verify the dev middleware
returns a real font/woff2 body.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>