* 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.
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>
* 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
The 25-line __ensureInstrumentation block in entries/app-rsc-entry.ts was
module-level state plus idempotent init logic embedded in a generated entry
template. This is the canonical 'imperative shell with mutable bookkeeping'
pattern — it never belongs in codegen.
Lift it to a new server/instrumentation-runtime.ts module exporting
ensureInstrumentationRegistered(instrumentationModule). The generated RSC
entry now only:
1. Imports the helper (conditional on instrumentationPath).
2. Calls await ensureInstrumentationRegistered(_instrumentation) in the
request handler.
All bookkeeping (initialized flag, shared promise, prerender skip) lives in
the typed helper, making it unit-testable and keeping the entry template thin.
New tests cover idempotency, concurrent racing, prerender skipping, and
graceful handling of modules without register/onRequestError.
Refs:
- Next.js instrumentation docs: https://nextjs.org/docs/app/building-your-application/optimizing/instrumentation
- Next.js source: https://github.com/vercel/next.js/blob/canary/packages/next/src/server/dev/next-dev-server.ts (instrumentation boot)
- Next.js source: https://github.com/vercel/next.js/blob/canary/packages/next/src/build/webpack/plugins/nextjs-require-cache-hot-relayer.ts (onRequestError global wiring)
App Router route discovery currently shares a single module with route caching and request-time matching. That makes discovery changes harder to review and increases the chance that graph construction work collides with generated entry changes.
The route graph construction types and discovery helpers now live in app-route-graph.ts, while app-router.ts remains the public cache and trie matching wrapper. A direct graph-builder test covers pages, route handlers, layouts, and inherited parallel slots at the new boundary.
* refactor(build): move route classification injection behind a typed planner
The Vite generateBundle hook currently owns stub detection, module-graph classification, replacement generation, and source-map invalidation. That makes generated-entry behavior harder to test without running the full plugin lifecycle.
The hook now adapts Rollup chunks and module info into a route classification injector. The injector plans the patch, validates drift cases, preserves Layer 1 priority over module-graph results, and keeps source-map invalidation explicit.
Tests cover scan-phase skips, stub drift errors, debug reason patching, seed-module absence, and the existing integration path.
* refactor(build): tighten classification stub validation
* fix(rsc): exclude client shims from dep optimization
App Router dev config currently excludes the vinext package but not its client shim subpaths. @vitejs/plugin-rsc tracks package client references by the original bare import source, so optimized vinext/shims/* modules can lose the matching export metadata and crash client-package proxy loading.
Add a shared optimizeDeps exclude set for vinext's known RSC client shims and merge it into the top-level, RSC, SSR, and client optimizer configs while preserving existing user and server-external excludes.
* Update rsc-client-shim-excludes.ts
Co-authored-by: James Anderson <james@eli.cx>
* Update rsc-client-shim-excludes.ts
Co-authored-by: James Anderson <james@eli.cx>
* Update build-optimization.test.ts
Co-authored-by: James Anderson <james@eli.cx>
---------
Co-authored-by: James Anderson <james@eli.cx>
* fix: next/image only fire onError and onLoad once per src per mount (#990)
Use ref-based dedup to prevent onLoad and onError from firing multiple
times for the same image src across React re-renders. Ported from the
upstream Next.js fix in vercel/next.js#93209.
- Destructure onError from props (previously fell through rest spread)
- Add lastLoadedSrcRef/lastErrorSrcRef to guard against duplicate fires
- Pass onError/onLoad to all 4 render paths (was missing from UnpicImage)
- Add SSR tests covering all render paths and verifying no DOM attribute leaks
The dedup prevents infinite re-render loops when user code calls setState
inside onError/onLoad, matching Next.js behavior.
* fix: add "use client" directive to next/image shim
useRef is a hook requiring client rendering context. Without "use client", RSC executes useRef() and throws "useRef is not a function", breaking create-next-app CI.
* fix: move resolveImageSource above handler closures for readability
Adding or removing any JavaScript or TypeScript file under app currently invalidates the App Router route cache and sends a full reload. That is wrong for colocated components and helpers because the App Router only treats specific file conventions and metadata files as route structure.
The watcher was using the broad page extension matcher for app files, which confused ordinary project files with route-affecting files. Add a dev route-file predicate aligned with vinext route and metadata discovery, then use it before invalidating the generated RSC entry and root params modules.
Tests cover route convention files, metadata files, private folders, sibling directory prefix safety, and colocated non-route files.
* fix: pumpReader now re-throws errors to prevent incomplete ISR cache data (#1002)
* test: add regression test for pumpReader error propagation (#1002)
App Router error boundaries treated the stored error value itself as the presence flag. That makes thrown values like undefined, null, 0, an empty string, or false indistinguishable from an empty boundary state, so the children can render again instead of the segment fallback.
The boundary state now stores either null or a wrapper containing the thrown value, and route error component typing admits unknown values to match React and Next.js behavior. The regression coverage checks both the state transition contract and a client component route that throws every falsy literal.
* refactor(metadata): share route pattern helpers
Metadata route handling repeated the same dynamic segment normalization and pattern matching in several places. That made the file-convention metadata path harder to review and kept generic routing behavior inside metadata-specific modules.
Move route pattern parsing, matching, and dynamic segment filling into a normal routing module. Reuse it from metadata route build data, file-based metadata, metadata route response matching, and app RSC intercept matching, then consolidate duplicated icon descriptor normalization in the metadata server and shim code.
Add focused route-pattern tests and keep the existing metadata and shim tests covering consumer behavior.
* fix(routing): preserve prototype-named route params
The shared route pattern matcher used a normal object for matched params, unlike the previous metadata and App RSC matchers. A route parameter named __proto__ could mutate the params object's prototype instead of being stored as an own value.
Restore a null-prototype params map and add regression coverage for single and catch-all prototype-named route params.
* fix(routing): preserve literal intercept target segments
* refactor(app-router): extract app page dispatch
App Router page requests now enter a shared dispatch module after generated route matching. The generated RSC entry still describes route modules, app-shape callbacks, and request-local values, but cache reads, static generation context, dynamic param checks, intercept responses, and lifecycle rendering move into normal typed code.
This keeps page orchestration out of codegen without changing the observable App Router behavior covered by the integration suite.
* chore: rerun ci
* fix: support enablePrerenderSourceMaps config with default true (#960)
Adds the `enablePrerenderSourceMaps` config option matching Next.js behavior.
Defaults to `true` so prerender stack traces resolve through sourcemaps to
original source files. Error results in prerender output now include full
stack traces when enabled. Node.js sourcemap support is enabled before the
prerender phase in both `cli.ts` (build) and `deploy.ts` (deploy).
Config parity: silently accepts the option; recognized as "supported" in
`vinext check` output.
* refactor: address review comments for enablePrerenderSourceMaps (#960)
- prerender.ts: simplify getErrorMessageWithStack and fix comment
(V8's Error.stack always prepends message, remove startsWith guard)
- cli.ts: add Error.stackTraceLimit = 50 after setSourceMapsEnabled
- deploy.ts: add Error.stackTraceLimit = 50 after setSourceMapsEnabled
- next-config.test.ts: move enablePrerenderSourceMaps tests to own describe
block and remove redundant explicit-true test
* fix(prerender): simplify getErrorMessageWithStack and add test coverage
Simplify the error stack handling in getErrorMessageWithStack by removing
the redundant nullish coalescing with empty string. The expression
`err.stack || err.message` handles all cases correctly.
Add test coverage proving that enablePrerenderSourceMaps: true (default)
includes stack traces in prerender errors. New test fixture page
`error-throw.tsx` throws during render to exercise the error path.
* fix(prerender): address review feedback - update snapshot, add config comment
- Regenerate entry-templates.test.ts.snap after adding error-throw fixture page
- Add comment clarifying enablePrerenderSourceMaps default in prerender test
* feat(config): add enablePrerenderSourceMaps config option
Adds support for the experimental.enablePrerenderSourceMaps config
option that Next.js 15+ introduced for debugging prerender issues.
Also includes merged changes from main:
- hashSalt: support experimental.outputHashSalt + NEXT_HASH_SALT env
- swcEnvOptions warning: warn about unsupported SWC polyfill option
All 75 next-config tests passing.
Closes#960
* fix: unstable_io() returns hanging promise during prerendering (#972)
During prerendering (static export, --prerender-all, TPR), unstable_io()
must return a hanging promise to suspend React past the IO boundary.
Previously it always returned a resolved promise, matching only the
browser/client implementation.
Changes:
- Add makeHangingPromise utility (never-resolving promise, rejects on abort)
- Define typed WorkUnitStore discriminated union in workUnitAsyncStorage
- unstable_io() branches on work unit store type per Next.js's io.ts
- Set prerender work unit store in RSC entry when VINEXT_PRERENDER=1
- Add tests for prerender, request, cache, and abort scenarios
* fix: add knip entries for prerender-work-unit and work-unit types (#972)
* fix(prerender): address PR review feedback for unstable_io work unit store
- AbortController now properly aborted in .finally() to prevent listener leaks
- Add optional route field to PrerenderStore for better error messages
- Pass pathname as route in app-rsc-entry handler
- Use actual route instead of hardcoded 'unknown' in unstable_io()
- Add explicit return in default branch of cache.ts for clarity
- Remove premature knip entry for make-hanging-promise.ts
- Replace V8-internal .status check with Promise.race pattern in tests
* fix(app-rsc-entry): place route option in correct location
Move { route: __pathname } to __runWithPrerenderWorkUnit() call
instead of incorrectly appending to _handleRequest closing brace.
Fixes parse error introduced in 035a54f.
Refs #979
* fix: address prerender hanging promise review feedback
- Suppress unhandled rejection when signal already aborted in makeHangingPromise
- Add lazy pathname extraction via getter function in runWithPrerenderWorkUnit
- Add listener array cleanup after abort in makeHangingPromise
- Add test for unhandled rejection suppression on early-return path
- Update entry template snapshots for lazy route getter
* fix: resolve merge conflicts with main for prerender work unit
- Take main's refactored app-rsc-entry.ts as base (extracted runtime primitives)
- Apply prerender-work-unit wrapping around handler function
- Update tests to match refactored code structure (delegated helpers)
- Update entry-templates snapshots for new generated code structure
* fix: resolve merge conflicts with main for test files
- Keep main's tests/app-router.test.ts (already has correct refactored tests)
- Update entry-templates snapshots to include prerender-work-unit generated code
* perf: fuse RSC stream tees reducing allocs from 3 to 2
ISR cache path teed the RSC stream twice (once for capture,
once for embed). Fuse into single tee: sideStream serves both
embed transform and raw byte accumulation via createRscEmbedTransform.
Tee 1 (capture) and Tee 2 (embed) now share one tee. Tee 3 (HTML
cache) remains separate — it operates on post-SSR HTML with a
conditional write decision.
Fixes#981
* fix: address PR review feedback for fused RSC stream tees (#981)
- Fix AppPageSsrHandler type to include sideStream and capturedRscDataRef
- Clear rawChunks after getRawBuffer() to reduce peak memory usage
- Remove misleading no-op null assignment in handleSsr else branch
- Guard await null in ISR cache write to prevent undefined in cache
* fix: repair CSP nonce handling and update snapshots for #981
- Fix renderAppPageHtmlStream to always pass ssrOptions (including scriptNonce)
to handleSsr, instead of conditionally when sideStream is defined. This fixes
CSP nonce regressions in dev mode.
- Update 6 snapshot occurrences in entry-templates.test.ts.snap to match the
updated __revalCapturedRscRef.value access pattern with null safety.
Fixes CI failures for the fuse RSC stream tees PR.
* fix: support experimental.outputHashSalt config and NEXT_HASH_SALT env var (#754)
Add support for Next.js experimental.outputHashSalt config option and
NEXT_HASH_SALT environment variable. When set, the salt is mixed into
content-addressed build output filenames via Rollup/Rolldown's
output.hashSalt option, producing different hashes without modifying
source code — useful for cache-busting after CDN poisoning.
Config resolution: concatenates config salt + env var salt (matching
Next.js behavior: config first, then env). When neither is set, no
salt is applied and behavior is unchanged.
* ci: re-trigger CI (Windows create-next-app flake, STATUS_STACK_BUFFER_OVERRUN)
* fix: use augmentChunkHash for outputHashSalt instead of output.hashSalt
The output.hashSalt option is webpack-specific and is silently ignored by
Rollup/Rolldown. Use the augmentChunkHash plugin hook instead, which is
supported by both bundlers and actually affects chunk content hashes.
Changes:
- Remove hashSalt parameter from getClientOutputConfigForVite()
- Add vinext:hash-salt plugin with augmentChunkHash hook
- Add experimental.outputHashSalt to CONFIG_SUPPORT map
* refactor: simplify hash-salt plugin comments
- Simplify comment about augmentChunkHash (just Rolldown, not Rollup)
- Add scope-gap note about CSS/static asset hashing not being salted
* fix: forward invalid dynamic usage errors on client-side navigations in dev (#958)
Record errors from throwIfInsideCacheScope() on the per-request
context so they survive user-code try/catch and can surface on
client-side client navigations. In dev, wrap the RSC response
body to check for recorded errors after stream consumption and
log them to the terminal.
Ported from Next.js: vercel/next.js@f5e54c0 (#93184)
* refactor: extract _recordInvalidDynamicUsageError helper in server.ts
Extract the repeated ALS lookup + error recording pattern into a
single reusable function _recordInvalidDynamicUsageError(). Replaces
4 inline try/catch blocks in _throwIfInsideCacheScope with calls to
the new helper.
Also adds a comment in app-page-render.ts explaining that HTML path
coverage is intentionally deferred (RSC-nav scope only).
No behavioral changes - same global keys, same error paths.
* fix: add SWR non-blocking guard tests for route handler ISR (#959)
Audit confirms vinext correctly uses ctx.waitUntil() for all background
cache-write and regeneration work — no equivalent of the Next.js
pendingWaitUntil / pipe-readable blocking pattern exists.
## Changes
- Add slow ISR route handler fixture (revalidate=1, 1s handler delay)
- Add response-time assertion to existing STALE test (< 500ms)
- Add new test verifying stale response completes fast despite slow
background regeneration
Ported from Next.js:
test/e2e/app-dir/use-cache-swr/use-cache-swr.test.ts
* docs: clarify porting attribution in SWR ISR tests
Adjust comments to note test pattern is adapted from Next.js
"use cache" SWR tests, not a direct translation. The mechanism
differs (export const revalidate vs "use cache").
Draft mode cookies currently use SameSite=Lax in production and clear with Max-Age=0. That diverges from Next.js and can prevent production preview flows from receiving the bypass cookie in cross-site contexts.
Centralize draft-mode cookie attributes in the headers shim, use SameSite=None and Secure outside development, and clear the bypass cookie with an expired date. Add focused compatibility coverage for production enable and disable attributes.
Optional catch-all routes currently materialize the missing segment list as an empty array. Next.js leaves the param key absent when the optional catch-all capture group does not match, so pages and app routes can observe a different params shape at the zero-segment path.
The route trie treated zero remaining segments as a captured value. The fix keeps the route match while returning an empty params object for that zero-segment branch, and applies the same normalization to standalone app RSC pattern matching.
Tests cover shared trie matching, app RSC matching, app route discovery, pages route discovery, and hyphenated optional catch-all params.
* refactor(app-router): extract RSC runtime primitives
The App Router RSC entry template currently owns low-level ISR keying, server-action body reads, and RSC error digest behavior. That makes generated code harder to inspect and leaves runtime contracts covered only through template snapshots.
Move those primitives into typed server modules and keep app-rsc-entry.ts focused on app-shape imports, route tables, and request-specific wiring. Add focused helper tests for slot normalization, streamed action body limits, production digest behavior, and generated-entry delegation.
* fix(app-router): address RSC primitive review feedback
* fix(app-router): return action-not-found for stale server actions
Unknown or stale Server Action ids currently fall into the generic action error path and return 500. That hides deployment skew from the client and misses the Next.js action-not-found response contract.
The action loader is an external runtime boundary, so the helper now treats loaded actions as unknown until narrowed, maps recognized missing-action failures to the 404 x-nextjs-action-not-found protocol, and keeps unrelated loader failures on the existing 500 reporter path.
Tests cover fetch actions, progressive form decode misses, missing exports, generic loader failures, and the App Router integration response.
* chore: rerun ci
* fix: accept experimental.swcEnvOptions config without erroring (#774)
Next.js added experimental.swcEnvOptions for SWC preset-env polyfill injection.
vinext uses Vite for transforms, so polyfill injection is not applicable, but
the config key should be accepted without errors. Add:
- CONFIG_SUPPORT entry in check.ts (unsupported with detail)
- Warning during config resolution in next-config.ts
- Tests for analyzeConfig detection and dot-notation matching
* refactor: align swcEnvOptions messaging and add test coverage
- Change runtime warning from "not yet supported" to "not applicable"
- Update CONFIG_SUPPORT detail for consistent messaging
- Add test coverage for swcEnvOptions warning in next-config.test.ts
* fix(check): update test assertion for swcEnvOptions message
The CONFIG_SUPPORT message for experimental.swcEnvOptions was updated
to indicate it's not applicable since vinext uses Vite instead of SWC.
This updates the corresponding test assertion to match the new message.
Fixes CI failure in tests/check.test.ts
* fix: add ForbiddenBoundary/UnauthorizedBoundary for HTTP access fallback recovery (#764)
Per-layout forbidden/unauthorized boundary files (forbidden.tsx, unauthorized.tsx)
are now discovered by the router and wired into the App Router component tree via
dedicated ForbiddenBoundary and UnauthorizedBoundary client components. Previously,
only NotFoundBoundary existed, causing 403/401 errors thrown during RSC rendering
(e.g. inside Suspense boundaries) to escape into the generic error shell — this
could produce mismatched Flight/HTML data and client-side 'Connection closed' failures.
Next.js upstream: vercel/next.js#92231https://github.com/vercel/next.js/pull/92231
* fix: address review feedback on ForbiddenBoundary/UnauthorizedBoundary (#945)
- Export ForbiddenBoundaryInner and UnauthorizedBoundaryInner for testing
- Strengthen tests to import and call getDerivedStateFromError directly
- Use exact equality instead of startsWith for status code digest matching
- Make forbiddenPaths and unauthorizedPaths non-optional on AppRoute (matching notFoundPaths)
- Fix inaccurate ported-from attribution in test header
* fix: remove duplicate forbidden/unauthorized path iteration and update comment
Removes dead code in app-rsc-entry.ts (lines 185-190) that duplicated the existing import registration loops.
Also updates the stale nesting order comment in app-page-route-wiring.tsx to include Unauthorized and Forbidden boundaries.
* fix: align NotFoundBoundary digest matching and tests with Forbidden/Unauthorized patterns
- Use exact === instead of startsWith in NotFoundBoundary digest check
- Add forbiddenPaths/unauthorizedPaths per-layout test fixtures
- Fix property ordering: singular before plural for all boundary types
* fix: resolve rebase conflicts — align manifest code with main refactor
- Use buildAppRscManifestCode for manifest generation (matching main)
- Remove redundant || [] guards for non-optional forbiddenPaths/unauthorizedPaths
- Fix test fixture indices to match new module import count
* fix: add null guards for notFoundPaths, forbiddenPaths, unauthorizedPaths in manifest
TypeError: route.notFoundPaths is not iterable when routes lack per-layout boundary files.
CI was failing on app-router.test.ts with the error. Added null guards in both registerRouteModules() and buildRouteEntries() to handle undefined arrays.
* fix: reorder route-level boundary nesting for merge with main
Move ErrorBoundary after NotFoundBoundary/ForbiddenBoundary/
UnauthorizedBoundary to match Next.js nesting order (Error >
Unauthorized > Forbidden > NotFound). Resolves merge conflict
with bc70e1c.
* fix: add tests verifying strings thrown in Server Components are not swallowed (#810)
Next.js had a bug where createReactServerErrorHandler returned early
for typeof thrownValue === 'string', swallowing the error before
logging. vinext's rscOnError already handles strings correctly (they
flow through the normal error path and get wrapped into Error objects).
Added:
- Fixture page that throws a string in a Server Component
- Integration test verifying the error boundary renders with the string message
- Runtime test confirming rscOnError returns a valid digest for string throws
- Static test confirming generated code has no early string-return path
Upstream: vercel/next.js@b9ca95c62d
* fix: address review feedback on string error handling tests (#950)
- Move prodRscOnError creation into beforeAll to avoid duplicating new Function construction
- Remove brittle positive assertions (instanceof Error, String(error)) from static test; keep only the negative anti-pattern guard
- Use textContentByTestId helper for integration test precision over loose html.toContain
* fix: support file:// URLs in cacheHandler config path (#728)
- Add cacheHandler and cacheMaxMemorySize to NextConfig type
- Add cacheHandler to ResolvedNextConfig with file:// URL handling
- Create resolveCacheHandlerPathToFilesystem() helper using fileURLToPath()
- Convert file:// URLs from import.meta.resolve() to filesystem paths
during config resolution to prevent broken path concatenation
Matches Next.js commit 9a0214a1 which added the same file:// URL handling
for ESM projects using import.meta.resolve() for the cache handler path.
* fix: drop unused export on resolveCacheHandlerPathToFilesystem
* fix: resolve cacheMaxMemorySize and add cacheHandler tests
- Add cacheMaxMemorySize to ResolvedNextConfig and resolve it from input config,
preventing silent config swallowing
- Add 7 tests for resolveNextConfig cacheHandler: file:// URL resolution,
absolute/relative path passthrough, null/undefined defaults
- Also add cacheMaxMemorySize resolution and default tests
* fix: add experimental.prefetchInlining to compatibility check (#860)
Next.js flipped experimental.prefetchInlining from false to true by default
in PR #92863. vinext does not implement per-segment prefetch inlining — it
uses unified RSC navigation payloads — so no behavioral change is needed.
This adds the config option to the compatibility scanner so vinext check
correctly reports it as supported.
* fix: mark experimental.prefetchInlining as partial, add test
- Change status from supported to partial (per review feedback —
option is recognized but underlying behavior is not implemented)
- Add test case in tests/check.test.ts matching existing
experimental.* config patterns