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 HTTP error response builders
Extract notFoundResponse / badRequestResponse / internalServerErrorResponse /
methodNotAllowedResponse into a shared http-error-responses.ts helper,
alongside the existing forbiddenResponse (moved from request-pipeline.ts) and
payloadTooLargeResponse (moved from app-server-action-execution.ts). Sites
with custom bodies (e.g. "404 - API route not found", "Image not found",
"missing pattern", "Page has no default export") and template-string-embedded
sites in deploy.ts / pages-server-entry.ts / dev-origin-check.ts are left
inline. Follow-up to #1058.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* ci: retrigger workflows
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- routing: extract a shared generic matchRouteWithTrie<R> in
route-matching.ts and use it from pages-router.ts (matchRoute) and
app-router.ts (matchAppRoute). Both now share the same query-strip,
trailing-slash, normalize, split, and trie-cache lookup preamble.
Pure refactor; no behavior change. Follow-up to #1058.
Note: the planned migration of pages applyGsspHeaders to the shared
mergeMiddlewareResponseHeaders helper was deferred — applyGsspHeaders
reads Node-style OutgoingHttpHeaders (Record<string, string | number |
boolean | string[]>) and joins multi-value non-cookie arrays with ", "
before set(), neither of which the shared Headers-based helper does.
Collapsing without preserving those semantics risks behavioral drift,
so it'll ship as a separate PR.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* refactor(utils): dedupe trailing-slash and basePath normalization
Add removeTrailingSlash to base-path.ts and migrate ad-hoc replace
patterns + ad-hoc basePath checks to the centralized helpers.
Follow-up to #1058.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(utils): use char-code loop in removeTrailingSlash
Replace the /\/+$/ regex with a manual trailing-slash count to avoid a
CodeQL polynomial-regex-on-uncontrolled-data alert. Same behaviour, no
backtracking.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* ci: retrigger workflows
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* 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>
- Remove `toRscUrl` from shims/navigation.ts (zero internal callers;
all sites use createRscRequestUrl).
- Remove matching declaration from shims/next-shims.d.ts.
- Update stale comment in server/app-browser-entry.ts that referenced
toRscUrl by name.
Note: the originally-scoped `requestAsyncStorage` legacy alias in
shims/internal/work-unit-async-storage.ts was kept. It is the named
export that resolves through the
`next/dist/client/components/request-async-storage.external` import-path
mapping (see packages/vinext/src/index.ts and check.ts), and is asserted
by tests/shims.test.ts. The shim's JSDoc documents `@sentry/nextjs`
runtime-resolves request context via this name, so removing it would
break that ecosystem path.
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.
Extract the three internal regex compilation caches in
config-matchers.ts to a single getCachedRegex helper, and unify the
substituteDestinationParams + sanitizeDestination chain used by
redirects and rewrites. Follow-up to #1058.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* refactor(routing): dedupe findFileWithExtensions helper
Extract the matcher.dottedExtensions.some(existsSync) one-liner used
by both prerender.ts and dev-server.ts into routing/file-matcher.ts.
Follow-up to #1058.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(build): drop stale ValidFileMatcher type import
The type is no longer used after delegating to the shared
findFileWithExtensions helper.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Extract the `Symbol.for(...) + globalThis ??= new AsyncLocalStorage()`
pattern from headers.ts, cache.ts, cache-runtime.ts, fetch-cache.ts,
request-context.ts, unified-request-context.ts, and the four *-state.ts
shims into a single helper at shims/internal/als-registry.ts.
Cross-bundle singleton property is preserved: the helper still routes
through `Symbol.for(key)` (global symbol registry) and `globalThis[sym]
??= ...`, so multiple instances of the helper module — and multiple
instances of each call site — all converge on the same ALS for a given
key. The helper holds no module-local reference of its own.
Follow-up to #1058.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- Delete unused extractLocale / detectLocaleFromHeaders /
parseCookieLocaleFromHeader (the canonical implementations live in
server/pages-i18n.ts and resolvePagesI18nRequest is what the entry
actually calls).
- Extract the repeated `.replace(/\\/g, "/")` path-separator
normalization across the entry files into a shared helper.
Follow-up to #1058 and #1069.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Extract the "Payload Too Large" 413 response and NEXT_REDIRECT /
NEXT_NOT_FOUND / NEXT_HTTP_ERROR_FALLBACK digest parsing into shared
helpers. Follow-up to #1058.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Extract the repeated status-order comparator and the parallel
page/layout/loading/error file-type predicates in check.ts into
shared helpers. Follow-up to #1058.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Extract the repeated `try { JSON.parse(readFileSync(...)) } catch`
pattern from standalone.ts and server-manifest.ts into a shared
readJsonFile helper. Follow-up to #1058.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Extract the repeated `pathname.replace(/\.rsc$/, "")` and equivalent
slice-based suffix removal into a single `stripRscSuffix` helper.
Follow-up to #1058.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Extract the repeated try/catch file-read + base64 encode pattern
in og-assets.ts into a shared helper. Follow-up to #1058.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* 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.
* refactor(shims): dedupe cookie value serialization
Extract the shared encodeURIComponent-based value encoding from
headers.ts and server.ts ResponseCookies into a single helper.
Follow-up to #1049.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(shims): drop unused export of SerializeSetCookieOptions
knip flagged this as an unused export — the type is only consumed
within cookie-serialize.ts itself.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Extract the repeated `new Response("Forbidden", { status: 403, ... })`
pattern from dev-origin-check.ts and request-pipeline.ts into a single
shared helper. Follow-up to #1049.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Remove the dead `parseCookieLocaleFromHeader` helper from the Pages SSR
entry template. It duplicated the regex + decodeURIComponent pattern
already exported from `pages-i18n.ts` (used by `resolvePagesI18nRequest`)
and had no callers in the generated server entry — locale resolution
goes through `resolvePagesI18nRequest` which handles the cookie itself.
Follow-up to #1049.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Extract the repeated try/catch JSON.parse(decodeURIComponent(...))
block into a single helper. Follow-up to #1049.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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
Extract the identical `decodeParam`/`decodeParams` and
`decodePatternParam`/`decodePatternParams` helpers from `route-trie.ts`
and `route-pattern.ts` into a single `decodeMatchedParams` in
`routing/utils.ts`. Addresses review feedback on #1049.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* 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