* fix(build): throw error if there is `_next` folder inside the public folder
The `_next` folder under the public folder conflicts
with the internal `_next` route, which is not expected
* fix(build): harden public asset conflict validation
---------
Co-authored-by: James <james@eli.cx>
* fix(fetch-cache): honor RequestInit in request dedupe
Request inputs with per-call overrides were deduped using the base Request even though fetch executes the overridden headers and options. Distinct authenticated requests could therefore share one response and persist it under the wrong cache key.
Normalize the effective GET or HEAD request before deriving the dedupe key so request-scoped reuse and persistent cache storage stay partitioned by the options the network sees.
* fix(fetch-cache): preserve effective request semantics
* fix(fetch-cache): key inherited request options
* fix(fetch-cache): serialize bodies with effective headers
* fix(fetch-cache): normalize default request options
* fix(fetch-cache): key normalized request body metadata
* fix(fetch-cache): distinguish normalized body variants
* fix(fetch-cache): hash body bytes without loss
* fix(fetch-cache): bound body key fallback work
* test(fetch-cache): lock effective auth bypass
---------
Co-authored-by: James <james@eli.cx>
The App Router payload previously serialized partial render observations and slash-prefixed source page paths into document HTML. Server-only cache tags could leak into the client bootstrap, while crawlers could interpret source page metadata as URLs.\n\nKeep complete render observations at cache finalization, transport source pages as validated segments, and retain legacy source strings only for reading cached payloads during rolling deployments. Next-compatible browser source-page diagnostics remain reconstructible from the segment form.\n\nAdd codec, renderer, fallback, and production HTML regression coverage for the wire contract and the absence of server metadata from documents.
Co-authored-by: James <james@eli.cx>
App Router hoisting manually serializes beforeInteractive Script props, bypassing React DOM filtering for string-valued event handlers. Request-influenced on* props could therefore become executable inline attributes in the server response.\n\nReject event-handler names case-insensitively at the raw HTML emission boundary while preserving legitimate attributes such as data-onload. The regression test exercises the actual Script capture and hoisted render path.
Co-authored-by: James <james@eli.cx>
* fix(app-router): preserve consumed prefetch during cache handoff
Keep the buffered snapshot discoverable while navigation publishes the committed visited response. This prevents a remounted prefetch-enabled Link from observing a false cache miss and starting a duplicate RSC request.
* test(app-router): verify prefetch handoff delay
* test(app-router): latch prefetch handoff delay
* test(app-router): hold cache latch through remount
---------
Co-authored-by: James <james@eli.cx>
* fix(app-router): stop flooring dynamic prefetch stale times
Next keeps its two client stale-time dimensions on separate rules. The
cacheLife/router bound goes through `getStaleTimeMs`, which floors at 30s
(segment-cache/cache.ts). The dynamic bound goes through
`computeDynamicStaleAt` (segment-cache/bfcache.ts), which applies no floor
at all, so `staleTimes.dynamic: 0` means a dynamic payload is never reused
across a navigation.
`resolvePrefetchedRscResponseExpiresAt` floored the *combined* value, which
`resolveRscResponseStaleTimeSeconds` had already min-combined. Since
`serverStaleTimeSeconds` floors the cacheLife half before the min, the outer
floor's only live effect was raising a dynamic bound the resolver's own
comment says it must never raise.
Every dynamic render reports its bound: `app-page-render.ts` defaults
`dynamicStaleTimeSeconds` to `experimental.staleTimes.dynamic` (0), emitted
as a header when the render is known dynamic up front and in the completion
footer when it turns dynamic mid-stream. Flooring that to 30s let a
credentialed RSC payload be replayed for 30s after a logout, role change, or
permission revocation, with no server round-trip. The consumed expiry then
propagated into the visited response cache, extending the window past the
navigation.
Floor only the unsignalled fallback, mirroring `STATIC_STALETIME_MS =
getStaleTimeMs(config)`. A signalled bound is now authoritative.
This also removes the `minimumTtlMs` plumbing, a partial workaround that
zeroed the floor for routes with a dynamic *pattern* segment. It keyed on
the wrong axis — a statically-patterned route rendering dynamically
(`/dashboard` reading `cookies()`) never matched — and the correct fix
subsumes it. The one test that relied on it now drives the same assertion
through the header a real dynamic render sends.
* fix(app-router): scope the dynamic bound to automatic prefetches
CI caught that the previous commit applied the dynamic stale-time bound to
`prefetch={true}` as well, breaking segment-cache-metadata's rewrite reuse.
Next splits reuse by prefetch kind, not only by stale-time dimension. In
`getPrefetchEntryCacheStatus` a `full` prefetch stays reusable up to
STATIC_STALETIME_MS even for dynamic content; only `auto` degrades past
DYNAMIC_STALETIME_MS. The segment-cache/bfcache.ts comment states it
directly — dynamic prefetches "use STATIC_STALETIME_MS instead of
DYNAMIC_STALETIME_MS" — and the upstream metadata test says so in prose:
"Because the link is prefetched with prefetch={true}, we should be able to
prefetch the title, even though it's dynamic."
So `prefetch={true}` is an explicit opt-in to holding dynamic content for the
static window. Carry that on the policy as `honorDynamicStaleTime`: true for
`resolveAutoAppRoutePrefetch`, false for `resolveFullAppRoutePrefetch`. A
full prefetch resolves its expiry from cacheLife alone, still floored at 30s
per `getStaleTimeMs`; an automatic one additionally honors the dynamic bound.
This is the axis the removed `minimumTtlMs` was groping for — it keyed on
route-pattern dynamism, which is neither the prefetch kind nor the render
kind.
* fix(app-router): keep the prefetch floor for explicit full prefetches
CI showed the previous commit went too far the other way: dropping the
dynamic bound entirely for `prefetch={true}` stretched those windows from 30s
to the full 300s static TTL, and client-cache's parallel-route reuse tests
started re-issuing full prefetches.
Scope the change to the floor rather than to which bounds apply. An automatic
prefetch takes a dynamic render's bound verbatim — including below the 30s
prefetch floor — so a `0` expires immediately, which is the vulnerability.
`prefetch={true}` still min-combines both bounds but keeps Next's ≥30s
prefetch floor, reproducing the previously-green behavior for full prefetches
exactly.
That confines the behavioral change to automatic prefetches, which is where
the finding lives: default `<Link>` prefetching of a dynamically rendered
route.
* ci: retrigger client-cache e2e (suspected flake)
* chore: drop e2e fixture node_modules symlinks committed by mistake
* chore: retrigger CI
---------
Co-authored-by: James <james@eli.cx>
* fix(app-router): validate external RSC rewrites before proxying
Out-of-basePath RSC requests claimed by basePath:false rewrites could reach external destinations before their missing or stale _rsc token was canonicalized. This bypassed cache-busting validation in every rewrite phase.
Require every external rewrite call to validate a claimed request before proxy I/O. Regression coverage exercises GET and HEAD in beforeFiles, afterFiles, and fallback and verifies the upstream is never contacted for invalid tokens.
* fix(app-router): validate middleware external RSC rewrites
* fix(app-router): preserve external RSC proxy state
* fix(app-router): preserve canonical RSC redirects
* fix(app-router): restore external Flight headers
---------
Co-authored-by: James <james@eli.cx>
* fix(app-router): let concrete Pages routes win middleware rewrites
A Pages data request that middleware rewrote returned a synthetic empty
JSON body whenever any App route matched the rewrite target, including a
dynamic or catch-all match. Every other App-vs-Pages ownership decision in
this handler treats a dynamic App match as non-owning, so a concrete Pages
route at the same pathname should render instead. Skipping that arbitration
meant getServerSideProps never ran for the rewrite target, and the client
router, which reuses the middleware probe response when the rewrite target
resolves to a Pages route, accepted the empty body as successful page data.
Redirect and notFound markers the Pages route would have returned were
therefore absent during client-side navigation.
Restrict the shortcut to App matches that own the target outright, so
dynamic matches fall through to the existing static and dynamic Pages
fallback arbitration.
That fallthrough reaches the tail Pages data response, which built its
headers from the not-found response alone and dropped headers the
middleware set on the way. Merge the middleware response headers there so
a rewrite landing on a genuinely App-owned dynamic route still carries its
cookies.
* fix(app-router): preserve middleware headers on Pages fallbacks
* fix(app-router): use Pages response merge semantics
* test(app-router): cover rewritten Pages data ownership
---------
Co-authored-by: James <james@eli.cx>
* fix(app-router): keep mounted-slot RSC MISS responses no-store
finalizeAppPageRscCacheResponse derived "should I rewrite the client
headers?" from the return value of scheduleAppPageRscCacheWrite. Those are
independent decisions, and #2497 made them disagree: mounted-slot variants
now correctly skip the persistent write (their RSC key is slot-blind), but
the early return took the pending-dynamic finalization with it.
The result is that a fresh ISR-eligible RSC MISS carrying
X-Vinext-Mounted-Slots leaves the origin with its initial
`s-maxage=..., stale-while-revalidate` instead of being rewritten to
`no-store, must-revalidate`. That header is what stops a shared cache from
storing a stream that may still reach cookies()/headers() below a Suspense
boundary after the cache policy was chosen, so a personalized payload can be
stored and replayed for the URL/variant. Apps with named parallel routes send
the header on essentially every client navigation; apps without slots never
enter the path.
Gate the header rewrite on preserveClientResponseHeaders alone, which is
already `cacheState !== "MISS"` at the only production call site. This
restores the client-facing behavior that shipped before #2497 while keeping
its cache-write change, and matches finalizeAppPageHtmlCacheResponse, which
never coupled the two. Doing it structurally rather than adding a
mountedSlotsHeader term means the next early return added to
scheduleAppPageRscCacheWrite cannot silently reintroduce this.
* fix(app-router): keep mounted slots out of edge caches
* fix(cache): clear mounted-slot CDN overrides
* docs(cache): explain mounted-slot no-store scope
* fix(cache): clear pending CDN overrides
* docs(cache): clarify pending header policy
* test(cache): cover dynamic mounted-slot headers
* test(cache): cover pending HTML CDN overrides
---------
Co-authored-by: James <james@eli.cx>
* fix(app-router): authorize the interception source route before rendering it
Interception renders the source route's tree for the request, so one request
reaches two routes: the requested target and the claimed interception source.
Middleware runs once, for the target's cleanPathname, before the source is
known. The source pathname arrives in the `x-vinext-interception-context`
client header, so a crafted RSC request naming a middleware-guarded route
under the intercepting route causes that route to render and returns its
payload, while the guard never sees the path it protects. Applications using
middleware path checks as their authorization boundary lose it for any route
reachable as an interception source.
Run middleware for the claimed source pathname before anything renders from
it, and return its response when it denies. The check is skipped when the
source resolves to the route already matched for this request, which is also
the case where interception does not fire, so ordinary requests and requests
without an interception context keep their single middleware run.
Only a returned response counts as a denial. A rewritten or normalized
pathname means middleware admitted the source and merely routes it elsewhere,
which is a routing concern rather than an authorization one.
This boundary has no upstream counterpart because the situation does not arise
upstream: the generated interception rewrite targets the intercepting route
and the client keeps the segments it already holds, so Next.js never renders
the source route for this request and has no second route to authorize. It is
vinext rendering the source tree that creates the extra boundary.
* fix(app-router): preserve action bodies during source authorization
Source-route middleware authorization rebuilt its request from the Server Action request directly. That transferred the body stream and left action dispatch unable to read it.
Clone the body branch before changing the source URL so middleware and the action retain independent readable streams.
* fix(app-router): isolate interception source authorization
* fix(app-router): fail closed on source request mutations
* fix(app-router): harden interception source authorization
* fix(app-router): align interception source identity
* fix(app-router): close interception authorization gaps
* fix(app-router): preserve matcher and body ownership
* fix(app-router): align interception query authorization
---------
Co-authored-by: James <james@eli.cx>
* fix(actions): run middleware for server action redirect targets
A server action that throws `redirect()` renders the target page inline
and returns its Flight payload with the action response, instead of
making the client re-request the target. Middleware had only run for the
action's own path, so the target's middleware never saw the request: an
action reachable on a public path could redirect to a middleware-gated
page and return that page's server-rendered payload, which the browser
client decodes and commits. Next.js has no such hole because the client
re-requests the target through the full pipeline.
Run the target's middleware against the synthetic GET before rendering
it, after the redirect render's headers context is installed so
`NextResponse.next({ request: { headers } })` overrides reach the page.
Middleware response headers merge into the action response.
Only a clean pass-through is rendered inline. A block, redirect, rewrite,
or status override diverts to the header-only 303 that already exists for
non-App-route targets, which the client re-requests through the full
request pipeline. Apps without middleware, and targets no matcher
matches, are unaffected.
* fix(actions): match redirect targets with request route identity
Follow-up to the target-middleware fix, addressing three ways the target
could still be evaluated as something other than the request the client
would have made.
`matchRoute` decodes pathname segments, so an encoded alias like
`/adm%69n` resolved to the `/admin` page while middleware and a real
navigation both saw `/adm%69n` — inline-rendering a route neither
reached. Match redirect targets with request route identity instead.
`cloneActionRedirectHeaders` carried `x-vinext-mw-ctx` onto the target's
request. In hybrid app+pages dev that header holds the Pages handler's
middleware result for the *action* path, which
`applyForwardedMiddlewareContext` replays in place of executing
middleware for the target. Strip it, which also keeps the internal header
out of the redirect render's `headers()`.
Middleware object matchers support `has`/`missing` header predicates, so
a matcher gated on `Accept` took a different branch against the render
request, which drops `Accept` with the action transport headers. Give
middleware a request that keeps it.
* docs(actions): note the redirect-target middleware divert trade-off
* fix(actions): run target middleware before importing redirect-target modules
A middleware-blocked redirect target still executed its route modules'
top-level code: the target was hydrated (dynamically imported) to decide
renderability before the middleware probe ran, so an unauthorized action
redirect could trigger module side effects, or turn a module-eval throw
into a 500 instead of the header-only fallback. Renderability is now
decided from the manifest's lazy thunks (__loadPage/__loadRouteHandler),
and hydration happens only after middleware passes the target through.
The probe leaked two more behaviors a real navigation would not produce:
- Pass-through middleware response headers merged verbatim onto the 303
action wrapper, so a middleware-set Location gave the wrapper genuine
HTTP-redirect semantics and fetch followed it before the action client
could read x-action-redirect. Middleware header merges onto the wrapper
now strip Location.
- The synthetic target requests were built with bare new Request(), which
drops the Workers cf property, so target middleware keying off
request.cf (geo checks) failed open. Both requests now re-attach cf via
the metadata helper the request clone utilities already used.
* fix(actions): carry middleware cookie mutations onto the redirect target
When action-path middleware rotated or deleted an authentication cookie,
the synthetic redirect-target request was still built from the original
inbound Cookie header, so target middleware evaluated a credential the
response was simultaneously revoking and could render the protected page
inline. The middleware's Set-Cookie mutations now feed the same
request-cookie rebuild as the action's own cookies().set() calls, in
browser order (middleware first, action wins for the same name).
Also aligns two more target-request details with the real pipeline:
- The internal _rsc transport param is stripped from the redirect target
before matching, middleware, and render, as app-rsc-handler does for
navigations, so matchers cannot branch on a query no navigation
carries. The client-facing x-action-redirect header keeps the verbatim
URL; a diverted re-request still goes through real validation.
- The middleware header merge onto the action wrapper now restores all
protocol headers (Content-Type, x-action-redirect and friends) rather
than only stripping Location, so a pass-through middleware can neither
replace the wrapper's destination nor flip the client into treating
the Flight body as non-RSC.
* fix(actions): scope redirect-target cookies and framing to browser behavior
Applying every pending Set-Cookie to the redirect target's Cookie header
ignored the cookie's Path attribute, so a mutation scoped to another
path (admin=1; Path=/account) reached a target like /admin that a real
browser navigation would never send it to, and target middleware could
authorize on it. Mutations now apply only when their Path, or the RFC
6265 default path derived from the action URL, path-matches the target.
Cookies from cookies().set() and draftMode() always carry Path=/ and are
unaffected.
Also adds Content-Length to the wrapper's protected headers: a
pass-through middleware value would misframe the freshly generated
Flight stream and let adapters truncate or reject the action response.
Drops the fixture node_modules symlink an e2e run left staged; on
Windows checkouts with core.symlinks=false it materializes as a plain
file that defeats the Playwright server's link-creation guard. The
gitignore rule loses its trailing slash so it covers symlinks and stops
these from getting staged again.
* fix(actions): use redirect target CSP nonce
* docs(actions): clarify redirect cookie projection
* fix(actions): forward middleware headers to redirect targets
* fix(actions): preserve redirect response framing
* fix(actions): preserve middleware request overrides
* fix(actions): run redirect targets through full request pipeline
* fix(actions): match redirect target header semantics
* fix(actions): mirror Next cookie forwarding
* fix(actions): preserve forwarded cookie ordering
* fix(actions): avoid stale forwarded cookies
---------
Co-authored-by: Nathan Nguyen <146415969+NathanDrake2406@users.noreply.github.com>
* fix(middleware): preserve safe origins for double-slash redirects
Same-origin absolute middleware redirects with a pathname beginning in two slashes were relativized into protocol-relative targets. Browsers could then navigate to the first path segment as an external host instead of remaining on the application origin.
Keep open-redirect-shaped paths absolute and retain that invariant while applying trailing-slash policy. Boundary tests cover both HTTP Location and data-request x-nextjs-redirect responses.
* fix(router): retain origin for middleware data redirects
Pages Router converted same-origin absolute data redirects back to app paths before history navigation. For a double-slash pathname, that exposed a protocol-relative target and made replaceState fail as a cross-origin mutation.
Keep the original safe target when origin or basePath removal would expose an authority-shaped app path. The navigation regression test covers history state, the follow-up HTML fetch, and render completion.
---------
Co-authored-by: James <james@eli.cx>
* fix(server): prevent bot user-agent regex backtracking
Long non-matching User-Agent values made the default Google crawler suffix branch retry a greedy token scan from every character, causing quadratic request-time CPU work.
Anchor that branch at token boundaries so the broad *-Google matching contract remains intact while each input segment is scanned once. Cover known suffix crawlers and the long non-match path at the public Pages detector boundary.
* ci: retry flaky client-cache e2e
---------
Co-authored-by: James <james@eli.cx>
* fix(server): evaluate lazy route modules outside the request context
Page and route-handler modules moved from eager RSC-entry evaluation to
lazy `() => import()` thunks resolved on the first request that matches the
route. A dynamic import propagates AsyncLocalStorage into the imported
module's top-level evaluation, and `ensureAppRouteModulesLoaded` is awaited
inside `runWithRequestContext`, so module-scope `headers()`/`cookies()`
bound to whichever request happened to reach the route first. ESM evaluates
a module once per isolate and the namespace is cached on the route with
`__loaded = true`, so that first visitor's session data was then reused for
every later request.
Invoke every lazy thunk through a new `runOutsideRequestContext`
(`AsyncLocalStorage.exit()`, which workerd implements) so module scope sees
no request. This restores the eager-evaluation contract the lazy loader
replaced and matches Next.js, which loads components in `base-server` before
`app-render` enters the request store.
* fix(server): load global-not-found outside the request context
`resolveGlobalNotFoundModule` imports the user's `app/global-not-found.tsx`
on the first route-miss 404 and caches the promise for the worker's
lifetime. That import runs inside the 404-triggering request's context, so
module-scope `headers()`/`cookies()` in global-not-found.tsx bound to that
first visitor and were then served to everyone after them — the same
mechanism as the lazy route-module thunks, on a sibling call path.
Route it through `runOutsideRequestContext` too.
* fix(server): exit every request-scoped ALS before module evaluation
Exiting only the unified request context was not enough. The Cloudflare
entry (`app-router-entry.ts:173`) enters the standalone execution-context
ALS *outside* it, and prerendering enters the work-unit store *inside* it,
so both stayed visible to module scope. Worse, `after()` takes its
`getRequestExecutionContext()` fallback precisely when the unified store is
absent — so the partial exit enabled that path instead of closing it,
letting top-level `after()` attach to the first request's `waitUntil`.
`runOutsideRequestScopes` now exits every ALS handed out by
`getOrCreateAls`, which is future-proof by construction: new shims are
enrolled as they are created, with no enumeration to keep in sync.
`workUnitAsyncStorage` registers itself via `registerAlsForScopeExit`
because it must stay module-local (Sentry resolves it by specifier), and
registering from that side keeps `als-registry` importing nothing but
`node:async_hooks` — it stays evaluable in client bundles where that
resolves to a constructor-less stub.
Reported by Codex on #2740.
* fix(server): isolate intercept page and not-found module loads
The intercepting-route page and not-found modules were imported inline at
their call sites — `resolveAppPageInterceptState` in app-page-request.ts and
the generated `probePage` path — so they missed the isolation every other
route module gets. They are user modules cached on the intercept for the
isolate's lifetime, so module-scope `cookies()`/`headers()` could still
capture the first visitor to an intercepted route.
Move both onto the shared loader as `loadAppInterceptPage` /
`loadAppInterceptNotFound`, beside the `loadAppInterceptLayouts` that already
lives there. Both call sites now route through one guarded implementation
instead of two hand-rolled copies of the same dedup block.
Reported by Codex on #2740.
* fix(app-router): publish concurrent intercept module loads
---------
Co-authored-by: James <james@eli.cx>
* fix(app-router): reject Route Handlers as interception source routes
An RSC request whose path has no App route match is promoted to an
interception source route selected by the `x-vinext-interception-context`
header. `findIntercept` gates that header against the intercepting route's
pattern with descendants allowed, then resolves the claimed pathname through
the route trie and returns whichever concrete route it lands on. When that
route is a Route Handler, the promoted match reaches the handler dispatch
branch, so a crafted header executes a `route.ts` that merely lives under the
intercepting route, having only run middleware for the requested target path.
Applications that guard route handlers with middleware path checks therefore
lose that boundary: a request to an interception-only target returns the
protected handler's response.
A Route Handler has no page, layouts, or parallel slots, so it can never own
or sit inside an interception source tree. Skip it when resolving the concrete
descendant source route and fall back to the slot owner, which is the fixed
destination Next.js' generated interception rewrite targets. Descendant page
routes still resolve concretely so dynamic source params survive.
The interception source pathname remains unauthenticated, matching Next.js'
`Next-URL` gating. Promoting a descendant *page* route can still render a
middleware-guarded page for a target it does not own; closing that requires
deciding whether descendant promotion should exist at all, and is left
unchanged here.
* fix(app-router): keep slot owner params when rejecting a descendant source
Falling back from a rejected Route Handler resolved the slot owner's params
with an exact pattern match against the claimed source pathname. That match
can never succeed on this path: the source was approved by the
descendants-allowed gate precisely because it carries extra segments beyond
the owner's pattern, so the match returns null and `sourceMatchedParams` ends
up empty. `matchInterceptRoute` derives the promoted owner's params solely
from that object, so a dynamic owner such as `/[locale]/feed` rendered with no
`locale` for a source of `/en/feed/admin`.
Take the owner's params from the prefix the source gate already approved when
the exact match fails. The same recovery covers a descendant source that
resolves to no concrete route at all, which had the identical gap before this
branch existed. The legacy manifest shape, which has no declared
`sourceMatchPattern`, still requires an exact match so its secondary gate keeps
rejecting unrelated sources.
* fix(app-router): reject route handler interception owners
---------
Co-authored-by: James <james@eli.cx>
* fix(pages-router): stream piped API responses with backpressure
A handler that pipes into `res` (proxying an upstream, echoing a request
body) could queue the entire stream in memory. The response bridge
acknowledged every write immediately, so `write()` never returned false
and a piped source was never paused, regardless of how slowly the client
consumed the body. The Node production server also read API responses
with `arrayBuffer()`, which held the full body in memory and deferred
delivery until the source closed, so long-lived streams (e.g. proxied
SSE) never flushed.
Hold the write callback while the response body's queue is full and
release it from the stream's pull hook. A held callback makes `write()`
return false, which pauses any piped source until the consumer reads.
Mark responses that are still being written when the handler settles and
send them through the streaming path in the Node production server.
Complete bodies (`res.json`, `res.send`, `res.end(data)`) keep the
buffered path and its Content-Length behaviour.
* fix(pages-router): avoid API stream backpressure deadlocks
* fix(pages-router): preserve streamed API handler lifecycle
* docs(pages-router): clarify API stream backpressure
---------
Co-authored-by: James <james@eli.cx>
* fix(router): minimize client rewrite data
Browser entries currently serialize resolved rewrite objects verbatim even when the client cannot execute a rule locally. Project rewrites into a client-specific discriminated shape, retaining internal destinations that support SPA routing while handing external and missing-condition rules to the server.
Keep the full matcher lazy on Pages and cover generated manifests and both client navigation ownership paths.
* fix(router): keep server-only rewrite inputs private
Cookie and header rewrite conditions can depend on values that browser JavaScript cannot observe. Evaluating them client-side can both publish those values and skip rewrites that the server would match.
Project only query and host conditions, fail closed for every other condition type, and use the same projection for the public build manifest so all client outputs preserve the same boundary.
---------
Co-authored-by: James <james@eli.cx>
* fix(build): exclude filtered require.context modules
require.context regexps previously filtered only the runtime map after a broad eager glob had imported every file. This evaluated and bundled excluded modules, including from client components.
Resolve and filter context entries during the transform so only accepted files become static dependencies. Keep context directories watched so create and delete events can update the generated module set.
* test: update require-context unit tests for static-import transform
* fix(build): harden require.context enumeration and bindings
Replace fs.glob (withFileTypes needs Node 22.2, engines allow >=22) with a readdir walk that follows directory symlinks like webpack and guards cycles via realpath, and grow the generated import binding prefix past any identifier already present in the source.
* fix(build): scope symlink cycle guard to the recursion path
A global realpath set deduplicated distinct symlink aliases of the same directory; track realpaths only along the current recursion path so aliases keep their own context keys while cycles still terminate.
* fix(build): stat directory entries with unknown dirent types
Filesystems without dirent type info (NFS, SMB, FUSE) report entries that are neither file nor directory; fall back to stat for any unknown type instead of only symlinks, and skip unresolvable ENOENT/ELOOP entries.
* fix(build): make require.context deterministic and dev-invalidation complete
Assign import binding indices after sorting so readdir order cannot change bundle bytes; invalidate recursive contexts on any membership event since a directory create/delete can change matching descendants without matching the file regexp; and drop watched-context entries for updated modules so importers that lose their last require.context call stop invalidating.
---------
Co-authored-by: James <james@eli.cx>
* fix(server): transfer request bodies into NextRequest instead of teeing
`Request.clone()` tees the body stream, and a tee branch that is never read
buffers every chunk the other branch pulls. The NextRequest constructor cloned
any body-bearing input, so wrapping the incoming request left an unread branch
holding the whole body. A route handler that streams a 128 MiB upload to
storage — O(1) memory by design — retained the full 128 MiB instead, letting an
unauthenticated client exhaust the process or Worker isolate by repeating the
request.
Upstream Next.js does `super(input, init)` here, transferring the body rather
than branching it. Match that. Callers that genuinely need two live branches
(middleware vs. downstream routing) already clone explicitly, so nothing loses
the isolation added in #1132.
The clone also defeated the mitigation added in #2026: `executeMiddleware`
cancels the middleware body branch in a `finally`, but the constructor's extra
tee sat between that cancel and the branch actually accumulating chunks, so the
cancel released a branch nobody was filling. With the clone gone the existing
cancel reaches the real branch again.
Drop the same dead tee from the basePath re-prefix path in
`createTrackedAppRouteRequest`, where the source request is replaced outright
and its body is never read again.
Measured with a 128 MiB streamed body, reading through and discarding:
before: arrayBuffers +128.0 MiB (route handler wrap, and middleware that
does not touch the body)
after: arrayBuffers +0.0 MiB, matching an unwrapped baseline
* fix(server): avoid teeing normalized RSC request bodies
* fix(middleware): release isolated request body branches
---------
Co-authored-by: James <james@eli.cx>
`x-middleware-override-headers` carries the complete post-middleware header
set, not a diff: `NextResponse.next`/`rewrite` encode every key of the Headers
object middleware passes, and Next.js deletes any request header missing from
that list. Absence means deleted.
`preserveCredentialHeaders` (#1121) read a short override list as a "partial"
override and copied the base request's `cookie`/`authorization` back in. The
documented deletion pattern — clone `request.headers`, delete the credential,
return `NextResponse.rewrite(externalUrl, { request: { headers } })` — produces
exactly such a list, so the option resurrected the stripped credentials. It was
enabled only for external rewrites, so `proxyExternalRequest` then forwarded
first-party session cookies and bearer tokens to a cross-origin target.
The "partial override" the option guarded against cannot occur:
`encodeMiddlewareRequestHeaders` is the only producer of the override list and
always emits the full key set. Remove the option and restore Next.js-exact
deletion semantics.
Covered end to end (fixture middleware deleting credentials before an external
rewrite), at the proxy boundary, and in the Pages Router pipeline.
Co-authored-by: James <james@eli.cx>
* fix(cache): bypass shared "use cache" entries in draft mode
`registerCachedFunction` keys shared entries by function id, build seed and
arguments — never by draft-mode state. Since #1489 made `draftMode().isEnabled`
readable inside cache scopes, a cached function that branches on it could have a
preview request seed unpublished content into an entry later served to public
requests, and vice versa.
Skip the shared cache lookup and store when the request is in draft mode,
alongside the existing dev bypass. This mirrors Next.js, which guards its
cache-handler read with `shouldForceRevalidate()` (true when `isDraftMode`) and
its write with an explicit `if (!workStore.isDraftMode)`. `unstable_cache` got
the same guard in #2319; this closes the remaining public "use cache" path.
"use cache: private" is unaffected — it never crosses a request boundary.
* test(app-page): stub isDraftModeEnabled in the headers mock
The element-builder suite replaces `shims/headers.js` wholesale rather than
spreading `importOriginal()`, so the new `isDraftModeEnabled` import in
cache-runtime.ts threw a mocker error through the shared "use cache" path.
* test(cache): preserve public use cache entry in draft mode
* test(cache): prove public draft bypass cache hit
---------
Co-authored-by: James <james@eli.cx>
* fix(pages): refresh next/head tags when regenerating ISR HTML
ISR regeneration re-renders the page body but reuses the cached shell
verbatim, so the <head> stays frozen at whatever the first cache-filling
render produced. A page whose title or meta tags derive from
getStaticProps data serves an updated body under permanently stale
metadata, and the two never reconverge for the lifetime of the entry.
next/head tags accumulate as a side effect of rendering into an
AsyncLocalStorage scope that closes when the regeneration pass returns,
so the refreshed head has to be read inside that pass. The render
callback now accepts an onHeadReady hook that runs after the render and
before the scope unwinds.
The collected tags are located in the cached shell through the
data-next-head attribute that getSSRHeadHTML stamps on everything it
emits, which keeps the swap working on entries written before this
change. _document.getInitialProps runs in the same hook because its head
tags share that collector; omitting it would drop them from the
regenerated shell.
_document-rendered head children and CSS-in-JS styles still come from
the cached shell, since regeneration does not re-render _document.
* fix(pages): keep the ISR head refresh off degraded _document paths
Two problems in the regeneration head swap, both raised in review.
The regeneration hook called _document.getInitialProps through the bare
callDocumentGetInitialProps helper, whose context deliberately omits req,
res, pathname, query and asPath and supplies a no-op renderPage. That is
not the contract the initial render uses: enhancePageElement is always
wired in the prod handler, so any user override resolves its head through
runDocumentRenderPage with the real request context instead. A Document
reading ctx.req would therefore throw (swallowed by the helper) or emit
fallback tags during regeneration, and the swap would replace a complete
cached head with that degraded one.
Reproducing the full document pipeline on regeneration is a much larger
change, so those apps now keep the cached head, which is the behaviour
they had before the refresh existed. Everyone else — no _document, or one
that never overrides getInitialProps — is unaffected, and for them the
helper was a no-op anyway, so the hook is now just getSSRHeadHTML.
Locating the closing head with indexOf also read raw-text content as
markup. headChildToHTML escapes </script and </style in inline bodies but
not </head>, so a next/head script may legitimately contain that string.
The scan then ended mid-element, matched nothing, and silently skipped the
refresh — or, with a differently shaped shell, inserted the fresh head
while leaving later stale tags in place. Raw-text elements are blanked
length-preservingly before the boundary lookup so indices still map onto
the original shell.
* test(pages): cover ISR head regeneration end to end
* fix(pages): ignore head text while locating ISR boundary
* test(pages): exercise stale ISR head refresh
---------
Co-authored-by: James <james@eli.cx>
* fix(document): HTML-escape NextScript.getInlineScriptSource output
The shim returned raw JSON.stringify(context.__NEXT_DATA__). JSON.stringify
does not escape characters the HTML parser treats as significant, so a string
value containing "</script>" terminates the inline script it is embedded in
and the remainder is parsed as HTML.
Next.js returns htmlEscapeJsonString(data) from every path of this method
(pages/_document.tsx). A custom _document that calls the helper and inlines
the result — the pattern the method exists for — therefore loses escaping it
would keep on Next.js, turning page props or query-derived data into script
injection.
Use safeJsonStringify, the escaper this repo already applies to the
framework-generated __NEXT_DATA__ tag in pages-page-response.ts, so both
paths produce the same output. The default renderer was unaffected.
* test(document): cover complete inline JSON escape set
---------
Co-authored-by: James <james@eli.cx>
Two Pages Router tests build temp route fixtures whose param names use
characters reserved in Windows filenames: `[repo:name].tsx` (`:` opens an
NTFS alternate data stream, so pagesRouter never discovers the route) and
`[id*].tsx` (`*` makes writeFile throw). Both errored on `vp test run`
locally on Windows. Split each test so the valid-everywhere case (`.`, `+`)
runs on all platforms and the reserved-char case is gated with
`it.runIf(process.platform !== "win32")`. These scenarios are unreachable
on Windows in production too, so gating loses no coverage; POSIX CI still
exercises them.
Related to #1792
* chore(deps): upgrade vite-plus to 0.2.6
* fix(app-router): suppress benign AbortError from superseded navigations
A fast follow-up navigation aborts the in-flight navigation's RSC fetch
mid-stream. When the aborted stream still holds an un-consumed React Flight
chunk (e.g. streamed metadata the superseded route never rendered), React
reports the resulting AbortError globally as a window `error` event rather
than to a specific consumer, which trips the "no console errors" e2e
assertion (metadata-icons.spec.ts).
Install a page-lifetime window listener at bootstrap that preventDefault()s
these benign navigation AbortErrors, mirroring the existing redirect-error
bridge. Installed once (not in a component effect) so there is no listener
gap while the router tree re-renders mid-navigation.
The vite-plus 0.2.6 toolchain shifted navigation timing enough to expose
this latent race on CI.
* chore: adapt to vite-plus 0.2.6 toolchain
Follow-up to the vite-plus 0.2.6 bump; keeps CI green under the new
oxfmt/oxlint/rolldown toolchain. No published runtime change.
- Formatting: oxfmt 0.60.0 reformats README.md, apps/web/next.config.ts and
tests/nextjs-compat/TRACKING.md (collapses empty-object-with-comment;
unpads markdown tables). Applied `vp check --fix`.
- Lint: oxlint 1.75.0 now flags dynamic `import("node:path")` under the
existing no-restricted-imports rule. Disabled inline in loadStaticPrerender
with a reason -- the resolved path feeds a dynamic import(), so pathslash's
forward-slash canonicalization buys nothing there.
- Test: rolldown code-splits the server build, so the font markers moved out
of index.js into _next/static/* chunks. Scan the whole server output for
the markers instead of index.js alone (verified they still exist).
* Revert "fix(app-router): suppress benign AbortError from superseded navigations"
Drop the runtime AbortError suppressor to keep this PR a pure toolchain bump.
The metadata-icons "rapid icon replacement" failure it addressed is a
pre-existing, timing-dependent race (not caused by the upgrade). Assessing
whether it triggers stably on CI / locally before deciding how and where to
fix it.
This reverts commit 84454d5fd of this branch.
* fix(app-router): honor cacheLife stale on the client router
`cacheLife` profiles carry three independent numbers, and `stale` is the
client-router dimension: how long the browser may reuse cached route output
without asking the server. vinext aggregated it correctly (`resolveCacheLife`
min-reduces all three; the request scope accumulates min-wins) and then
projected it away at every consumer, so the only staleness a browser ever saw
was `dynamicStaleTimeSeconds` from `experimental.staleTimes` — a build-time
constant unrelated to the cached subtrees that produced the render. A subtree
declaring `cacheLife("seconds")` (stale: 30) was held for the full 5-minute
visited-response TTL.
Carry the resolved `stale` to the client on `x-nextjs-stale-time` (matching
Next.js's `NEXT_ROUTER_STALE_TIME_HEADER`) and combine it with the config
value by taking the minimum, so neither min-wins lattice overrides the other.
The 30s prefetch floor applies to the new value too.
Two normalization rules, both deliberate:
- An absent `stale` is never synthesized from `revalidate`/`expire`. The
`default` profile has no `stale` and an `expire` of ~136 years, so deriving
one would license session-long reuse without a refresh.
- The three numbers are not assumed to be ordered — `seconds` is
`{ stale: 30, revalidate: 1, expire: 60 }` — so `revalidate` never
constrains `stale`. Only the hard `expire` ceiling does.
* fix(app-router): source the client stale time from completed renders
The previous commit derived `x-nextjs-stale-time` from `peekRequestCacheLife()`
at response-construction time. That read happens after `probeAppPageBeforeRender`
but before the RSC stream is consumed, and the probe only awaits the page
component's own async result — it does not render the returned tree. Any
`use cache` scope in a child Server Component registers later, during stream
consumption, so the header described the probe rather than the output.
Because `cacheLife` aggregation is minimum-wins, missing one late scope is
enough to make the value wrong, and "it can only shorten" did not hold: with no
`dynamicStaleTimeSeconds` present (the usual case for `use cache` pages, which
are not dynamic renders), a peeked `stale: 300` widened the prefetch window from
`PREFETCH_CACHE_TTL` (30s) to 300s — 10x longer than today, in the direction the
change set out to fix.
Carry the value on the cache entry instead. The ISR write path reads the
request-scoped accumulation via the consuming `getRequestCacheLife()` inside the
cache-write closure, which runs after the captured stream has drained, so it
observes the completed render's minimum. `CacheControlMetadata` gains `stale`,
`isrSet` persists it, and `buildAppPageCachedResponse` re-advertises it on hits.
Prerender seeds carry it through `VINEXT_PRERENDER_CACHE_LIFE_HEADER` and the
prerender manifest so a seeded entry makes the same claim a runtime render would.
Two links that would otherwise silently drop the claim are closed with it: the
`use cache` entry write now persists `stale`, and `recordRequestScopedCacheControl`
re-registers it on a data-cache hit — without both, a page's advertised freshness
would depend on data-cache temperature rather than on what it declared.
Streaming responses now advertise nothing and leave the client on its configured
`experimental.staleTimes`, which is the honest answer for a value that is not yet
known when headers are committed. Covering fresh streaming renders needs a
render-completion signal the streaming RSC path does not have today; Next.js
solves it by streaming an `AsyncIterable<number>` in the RSC payload
(`app-render.tsx` `baseResponse.s`, closed after the render settles), which is
the natural follow-up.
The client-side combination logic is unchanged: both stale signals are still
min-reduced, absent `stale` is never synthesized from `revalidate`/`expire`,
`revalidate` never clamps `stale`, and `expire` still caps it.
* fix(app-router): deliver the resolved client stale time on fresh renders
The previous round persisted the completed render's cacheLife stale onto
the ISR entry and replayed it on cache hits, which left the value missing
or drifting wherever the entry's lifetime diverged from the render's:
- Fresh streaming renders advertised nothing, so the first visitor of any
route (and every dev render, since dev writes no ISR entry) stayed on
the configured staleTimes despite a declared cacheLife. The done-script
emitted by the RSC embed transform's finalize() runs only after the
full RSC stream has drained, so it can carry the completed render's
minimum where streaming headers cannot. Emit the peeked request-scoped
cacheLife there and seed the hydration visited-response entry from it.
This also makes the client's min-combination of the config and
cacheLife signals reachable: a dynamic render with use cache subtrees
now legitimately carries both.
- The expire clamp bounded the value but not the elapsed window: an
entry of { stale: 30, expire: 60 } hit at age 59s replayed a 30s reuse
window reaching 29s past expire. Age the serve-time clamp with the
entry's lastModified so only the remaining window is advertised.
- The two client caches applied different floors: prefetch entries
floored a cacheLife stale at 30s while cold navigations honored it
verbatim, so the same declaration produced two behaviors keyed on
whether a prefetch fired first. Floor the cacheLife signal once in the
shared resolver, mirroring Next.js getStaleTimeMs, before the min so
it can never raise the config-derived bound.
Mechanically, the app-page cache setter's six-position signature
(declared identically in four modules) collapses into one exported
AppPageCacheSetter taking an AppPageCacheWritePolicy object, with
isrSetAppPage adapting to the shared positional isrSet, which stays
unchanged for the Pages Router and route handlers.
* docs(app-router): pin the cold RSC stale contract and expire precedence
The cold RSC fetch gap is a design decision, not a pending follow-up:
Next.js's AsyncIterable stale transport only works under staged rendering
(cacheComponents), where cache scopes settle before the render task queue
drains — in vinext's lazy streaming model the iterable could never close.
Next.js's plain-mode mechanism is a blocking cold render, which #961
deliberately rejected to keep ISR page streams unblocked. Assert the
resulting no-header contract on the streaming-response test.
Also reword the write-policy expire comment: a cacheLife-declared expire
replaces the config expireTime fallback (Next.js precedence), it is not
min-merged with a route-level ceiling — no such ceiling exists outside
cacheLife.
* fix(app-router): carry stale through regen and bound cold responses
Three fixes from review round 3:
Background regeneration dropped the regenerating render's cacheLife stale:
renderAppPageCacheArtifacts returned only { revalidate, expire }, so
resolveRegeneratedAppPageCachePolicy could never receive the stale it was
built to preserve and the first regen silently widened client reuse back
to the configured fallback. The producer now carries it, with a
real-producer regression test the mocked-cacheControl tests could not
provide.
The age-aware expire clamp is removed. Composed with the client's 30s
floor it delivered neither contract (a clamped 1 re-floored to 30), and
cached HTML replayed the unclamped done-script value regardless. Next.js
stores the stale header at generation and replays it verbatim on every
hit; vinext now does the same, keeping expire a serve-side ceiling.
Cold cacheable RSC responses stream before their cacheLife resolves
(#961), which left them on the 300s client fallback — reproducing the
headline bug for the first request of every entry epoch. They now carry
X-Vinext-Stale-Time-Pending, and both client caches bound such responses
at the 30s floor: the unresolved claim, once floored, could never
license less.
* fix(app-router): bound pending-stale responses by the dynamic stale time
The pending marker meant 'capture was attempted', but the client read it
as 'a cacheLife claim exists'. Capture eligibility is decided before the
lazy stream runs, so a late request-API read can make the completed
render dynamic — the finalizer skips the ISR write and no claim ever
exists, yet the marker granted 30 seconds of reuse even under
staleTimes.dynamic: 0.
Pending responses now carry the configured dynamic stale time (including
0) and the client takes the minimum, so an unresolved response never
receives a wider window than the dynamic bound.
* fix(app-router): keep the pending cap independent of staleTimes.dynamic
Pairing the pending marker with the configured dynamic stale time broke
the segment-cache-client-params compat test: dynamic-param routes
prefetch with no minimum TTL, so the paired 0 default made every cold
prefetch entry expire instantly and navigations refetched routes that
Next.js serves entirely from a static prefetch.
A cold stream cannot distinguish a render that will resolve static from
one that turns dynamic mid-stream, and bounding both by staleTimes.dynamic
sacrifices the guaranteed-correct case for the ambiguous one. Pending
responses go back to the 30s floor cap; the late-dynamic exposure
(one epoch-cold response, at most 30s) is documented as the price of
non-blocking cold renders (#961).
* fix: carry the client stale claim through KV, the prerender index, and nested cache hits
- writePrerenderIndex now copies `stale` into vinext-prerender.json so
seedMemoryCacheFromPrerender actually receives it
- KVCacheHandler.set() and buildPrerenderKVPairs persist cacheControl.stale
so warm hits on the Cloudflare KV backend replay the producing render's
claim; validateCacheEntry accepts the field
- a nested use cache HIT pushes its stored lifetime into the enclosing
cache context's lifeConfigs, mirroring the MISS path, so the outer entry
keeps the child's stale claim once the outer goes warm
* perf: trim shipped comment bytes in the stale-time plumbing
The added modules ride in every consumer build environment; the review-grade
rationale lives in the PR body, the code keeps one-line constraints.
* refactor(app-router): model the cached client stale claim as one state
CachedRscResponse carried staleTimePending and staleTimeSeconds as two
independent optionals, so the cached form admitted a state the wire never
produces (pending and resolved at once) and every consumer had to encode
the precedence rule. Replace both with a discriminated serverStaleTime
(pending | resolved), collapsed once at the header-parse boundary.
* refactor(isr): give isrSet a cache-metadata write policy
The generic setter had grown to six positional arguments, the last an
App-Router-only `stale` value, with isrSetAppPage as a policy-object
wrapper that unpacked straight back into it. Take { cacheControl, tags }
instead: routers construct the metadata they actually claim, the wrapper
and its type disappear, and the shared isrCacheControl builder replaces
the cache-control literal duplicated across writers.
* fix(app-router): keep the dynamic bound on captured dynamic renders
The done-script metadata reused the RSC header's rule of dropping the
config-derived dynamic stale time while the speculative ISR capture was
armed. That rule only holds on the header path, which substitutes the
pending marker and its 30s floor; the done script emits the resolved
cacheLife instead, so a production render that turned dynamic shipped the
cacheLife claim as its only bound and let the hydration-seeded entry reuse
dynamic output for its full duration. Dev never took the capture path, so
the two diverged.
Also migrates the pages-basic seed fixture, the last positional isrSet
caller, which typecheck does not cover.
* ci: re-run after unrelated dev-overlay canary flake
* fix(app-router): preserve completed client stale metadata
* fix(app-router): strip completion metadata during HMR
* fix(app-router): stream completion metadata safely
* fix(app-router): validate completed stale metadata
* docs(cache): clarify zero stale client claim
---------
Co-authored-by: James <james@eli.cx>