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
* 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>
* 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
* fix(app-rsc): remove orphaned url declaration from _handleRequest
The original const url = new URL(request.url) at the top of _handleRequest
was not removed when the normalization block was replaced with normalizeRscRequest.
The destructure const { url } = __norm on the next early-exit block redeclares
the same identifier, causing a PARSE_ERROR in Rolldown during production builds.
Remove the orphaned declaration. url now comes solely from the normalizeRscRequest
result.
* fix(test): update cleanPathname assertion for normalizeRscRequest destructuring
* refactor(app-rsc): extract normalizeMountedSlotsHeader into neutral module and add null-byte test
Moves normalizeMountedSlotsHeader from app-rsc-request-normalization.ts into server/app-mounted-slots-header.ts so it can be imported by app-elements.ts without creating a module boundary dependency from payload helpers into request-lifecycle code.
Updates - isr-cache.ts re-exports from the new module. app-elements.ts switches its import accordingly.
Adds a null-byte sanitisation test for X-Vinext-Interception-Context (previously untested security branch). Fixes the PR description to accurately describe the duplication story.
* ci: retrigger CI
* ci: retrigger CI 2
* refactor(app-rsc-entry): delegate pure helpers to typed server modules
The generated RSC entry template contained five inline implementations of
pure runtime helpers: post-middleware request context construction,
RSC error handler creation, request context cleanup, RSC stream preload
hint normalization, and hook warning suppression. These inline blocks
repeated runtime behavior across multiple call sites and could not be
unit-tested independently.
Move each helper to a dedicated typed module under server/ so the
generated entry imports and wires them instead of owning the logic:
- buildPostMwRequestContext → server/app-post-middleware-context.ts
- createRscOnErrorHandler → server/app-rsc-error-handler.ts
- __clearRequestContext + setNavigationContext → server/app-request-context.ts
- renderToReadableStream preload hints → server/rsc-stream-hints.ts (createRscRenderer)
- hook warning console.error patch → server/app-hook-warning-suppression.ts
Each helper module has focused unit tests. The entry template is 55 lines
shorter and now describes the app shape; the typed modules own behaviour.
* fix: remove redundant as cast and fix test module caching
app-hook-warning-suppression.ts contained an `as string` cast that was
redundant because the typeof check on the prior line already narrows the
type. The hook warning suppression tests in tests 2-4 were passing
vacuously because dynamic import() returned the cached module whose
console.error patch was no longer active after test 1 restored the
original. Use vi.resetModules() before each test to force fresh module
evaluation.
clearAppRequestContext now delegates to setAppNavigationContext(null)
instead of calling the raw navigation and root-params setters directly,
matching the DRY pattern from the original inline code.
* fix: resolve entry helper rebase conflicts
* ci: rerun checks (font-google flake)
* Update packages/vinext/src/entries/app-rsc-entry.ts
Co-authored-by: ask-bonk[bot] <249159057+ask-bonk[bot]@users.noreply.github.com>
---------
Co-authored-by: ask-bonk[bot] <249159057+ask-bonk[bot]@users.noreply.github.com>
* fix: add hydration-level onError replay in next/image via useLayoutEffect (#1007)
Port the upstream Next.js hydration replay pattern from vercel/next.js#93209:
use a useLayoutEffect + img.src = img.src to re-trigger the error trajectory
when an image fails before React hydration completes (e.g. during SSR streaming).
Changes:
- Add useNonWarningLayoutEffect helper (useEffect in SSR, useLayoutEffect in browser)
- Add mergedRef to capture the underlying img element for the layout effect
- Add useLayoutEffect that re-triggers img.src = img.src on initial mount when onError is provided
- Forward ref to UnpicImage elements (ref forwarding is supported)
- Add SSR rendering tests for onError, onLoad+onError, ref forwarding, and loader path
* fix: address PR review feedback for next/image onError hydration replay (#1007)
- Fix mergedRef callback instability: use ref object in useRef instead of
array dep in useCallback, matching Next.js upstream
- Add stable refs for onLoad/onError/onLoadingComplete via useRef +
useEffect sync pattern to prevent layout effect re-runs
- Fix layout effect deps to use stable ref objects instead of raw
handler values; drop 'loading' (not in Next.js upstream deps)
- Add img.complete check for onLoad replay when browser already loaded
the image before hydration-level onError replay
- Use React.RefObject instead of deprecated MutableRefObject
- Fix eslint-disable comment placement for no-self-assign
- Add SSR smoke test comments in shims.test.ts
* fix(next/image): address PR review — extract helper, clean deps, add comment
- Extract createSyntheticLoadEvent(img) helper for ~35→1 line in layout effect
- Remove onLoadRef/onErrorRef from layout effect dep array (stable ref objects)
- Add comment explaining useRef+useEffect sync timing gap
* fix: guard onLoad replay with naturalWidth > 0, clean deps (#1007)
- Check img.naturalWidth > 0 to prevent firing synthetic onLoad for
errored images (img.complete is true for both success and error per
the HTML spec)
- Remove src from layout effect dep array — dead dependency since
didInsertRef bails on all re-runs after mount
- Add comment noting createSyntheticLoadEvent requires browser context
- Fix test comment referencing non-existent image-component.test.ts
* fix: port Next.js useMergedRef hook for next/image ref merging
Replace the inline mergedRef callback (with [] deps) with a proper
useMergedRef hook ported verbatim from next/src/client/use-merged-ref.ts.
The inline version used useCallback with [] deps, which kept the callback
identity stable and prevented React from re-invoking it when the parent
ref changed on re-render. The ported hook uses [refA, refB] deps, which
correctly triggers React's ref lifecycle: old ref gets null (cleanup),
new ref gets the element.
Also adds React 19 cleanup ref support (ref callbacks that return cleanup
functions) and proper null-based cleanup on unmount.
The bare specifier is aliased to a user source file (or the empty-module
shim), neither of which should be pre-bundled. Without this exclude,
Vite's scanner treats it as an unknown npm dep on the first request,
optimizes it, and triggers a full reload.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
When an RSC payload contains an error reference for a slot (e.g. a server
component throws), createPendingNavigationCommit only awaits the top-level
elements promise, so the dispatch goes through and React renders. The slot
throws when use() unwraps the rejected reference. Without an error.tsx
boundary above NavigationCommitSignal, the entire root unmounts — and
because the URL update lives in NavigationCommitSignal's pre-paint layout
effect, the browser is left on the previous URL with a blank page and a
console-only error.
Track the in-flight navigation target in a module-level
pendingNavigationRecoveryHref, set it just before dispatchBrowserTree and
clear it once the commit effect updates history. Add an onUncaughtError
handler to hydrateRoot that reads the recovery href at fire time and
window.location.assign()s to it, so the user lands on the URL they
clicked and the server can render its actual error UI for that route.
Wire the handler in both dev and prod (previously only onCaughtError was
registered in dev).
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
A render error (e.g. a server route throws) unmounts BrowserRoot, which
clears setBrowserRouterState/browserRouterStateRef. The HMR rsc:update
handler used to bail silently in that state, leaving HMR effectively dead
until the user manually refreshed.
Track a sticky 'has ever committed' flag and trigger window.location.reload()
when the tree was up before but isn't now. The next HMR push reconciles via
the fresh load. The flag only goes back to false implicitly via a successful
remount path (it's set in markBrowserRouterStateReady), so we don't loop —
if the broken code prevents the next mount entirely, hasEverCommitted stays
true on the reloaded page only until the next mount succeeds.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(hmr): await router state readiness before rsc:update dispatch
The rsc:update handler called getBrowserRouterState() directly, which
throws when browserRouterStateRef is null. The ref is only published in
BrowserRoot's layout effect, so an HMR event that fires while the initial
RSC stream is still suspended produces a noisy "Browser router state is
not initialized" error in the console.
Mirror the pattern already used by __VINEXT_RSC_NAVIGATE__ and await
waitForBrowserRouterStateReady() before touching router state.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(hmr): re-check browserRouterStateRef after readiness await
waitForBrowserRouterStateReady() resolves once browserRouterStateHasCommitted
flips true, but BrowserRoot's layout-effect cleanup nulls the ref again on
unmount. A microtask gap between the await resolving and the synchronous
getBrowserRouterState() call can therefore still throw.
Re-check the ref after the await and bail silently if the tree isn't
currently mounted; the next HMR push or full reload will reconcile.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(hmr): re-check setter after createPendingNavigationCommit await
The pre-await ref check is insufficient: createPendingNavigationCommit
awaits the new RSC payload, and while suspended a broken prior render can
unmount BrowserRoot, clearing setBrowserRouterState. Reproduces by saving
a fix to a route that just threw before the broken HMR cycle finishes.
Re-check the setter post-await and bail silently — the next HMR push or
full reload reconciles.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* refactor(app-router): extract buildPageElements into typed helper module
The generated RSC entry template owned the central element-construction
path (buildPageElements) as an inline template function with ~110 lines
of behaviour. This made the code untestable directly and forced any change
to element-tree construction to be verified through snapshot diffs of the
generated output rather than through behavioural assertions.
The generated entry should describe the app shape (route manifests,
module references); runtime behaviour belongs in normal typed modules.
Extract buildPageElements into a new server/app-page-element-builder.ts
module with fully typed options (BuildPageElementsOptions), a route shape
(AppPageBuildRoute extending AppPageRouteWiringRoute), and intercept
types. The generated entry now delegates to this module with a 12-line
closure that passes the per-app module references (globalErrorModule,
rootNotFoundModule, etc.).
Next.js equivalents:
- create-component-tree.tsx -- builds the nested React element tree
- create-metadata.tsx -- resolves page head metadata including parallel
route interpolation
Behavioural changes: none. All existing app-router behaviour is preserved.
Tests added: 9 focused unit tests covering error payload construction,
interception context threading, search-params-triggered dynamic marking,
slot override wiring, and thenable-params proxy behaviour.
* chore: register app-page-element-builder in knip entry list
The module is imported by the generated RSC entry template as a string
literal, so knip cannot statically trace the dependency. Same pattern as
app-page-dispatch.ts, app-page-head.ts, and prerender-work-unit-setup.ts.
Currently the generated RSC entry defines three wrapper functions
(renderHTTPAccessFallbackPage, renderNotFoundPage, renderErrorBoundaryPage)
that reconstruct the same large options object on every call. Each wrapper
closes over app-level constants (font providers, root boundaries, metadata
routes, SSR loader, thenable-params helper, sanitizer) and also captures the
per-request Request object in its closure.
This inflates the dependency surface of the three functions: the generated
code must pass every app-level constant on every invocation, and the request
shadowing makes it impossible to see at a glance what is truly per-request
versus per-app.
Replace the three generated functions with a single factory
__createAppFallbackRenderer({ rootBoundaries, globalErrorModule, metadataRoutes,
ssrLoader, fontProviders, makeThenableParams, sanitizer, rscRenderer }) that
returns { renderHttpAccessFallback, renderNotFound, renderErrorBoundary }.
The generated entry constructs the factory once at module level and calls the
returned methods many times. Request is now passed at the call site, removing
it from the factory closure entirely.
The empty-middleware fallback ({ headers: null, status: null }) moves into
the typed factory module so generated code no longer needs to define it.
New module: packages/vinext/src/server/app-fallback-renderer.ts
Tests: tests/app-fallback-renderer.test.ts