Skip to content

refactor(use-cache): move server function directives to user land - #2156

Draft
james-elicx wants to merge 557 commits into
fix/use-cache-nested-function-propsfrom
codex/pr-1871-userland-server-functions
Draft

refactor(use-cache): move server function directives to user land#2156
james-elicx wants to merge 557 commits into
fix/use-cache-nested-function-propsfrom
codex/pr-1871-userland-server-functions

Conversation

@james-elicx

@james-elicx james-elicx commented Jun 18, 2026

Copy link
Copy Markdown
Member

Summary

POC for implementing server-function directives entirely in vinext user land instead of adding the serverFunctionDirectives option and orchestration plugin originally proposed in vitejs/vite-plugin-react#1246.

This update pins @vitejs/plugin-rsc to the latest #1246 prerelease at 50eaf476, which incorporates the merged server-reference registration API from vitejs/vite-plugin-react#1310. vinext init and deploy-time dependency installation also use that exact prerelease because the registry's stable 0.5.30 package predates #1310 despite sharing the same package version. This PR remains stacked on #1871.

Vinext-owned lifecycle

Vinext now uses a single vinext:server-function-directives plugin before rsc:use-server. Running first preserves the original module shape for mixed module-level "use server" boundaries, while #1310's independently owned claims let the two plugins update metadata without an after-plugin restoration pass.

The plugin uses RscPluginManager.serverReferences to:

  • call resolve() for plugin-rsc's canonical development and build reference identity
  • call replaceClaim() for references emitted by vinext
  • call deleteClaim() when a directive is removed or another environment does not own the reference
  • rely on plugin-rsc to aggregate compatible claims, deduplicate export names, and reject conflicting ownership
  • hand exclusive file-level ownership between vinext:server-function-directives and rsc:use-server during HMR

As a result, vinext no longer needs:

  • a second metadata plugin after rsc:use-server
  • a private metadata ownership map
  • direct writes to serverReferenceMetaMap
  • local copies of plugin-rsc's reference-key hashing or Vite URL normalization

The private /* __vinext_server_function_directives__ */ marker remains only to avoid reprocessing vinext's own transformed output; plugin-rsc does not interpret it.

Transform composition

Vinext composes the public low-level primitives directly:

  • transformWrapExport() for module-level custom directives
  • transformHoistInlineDirective() for function-level custom directives
  • transformDirectiveProxyExport() for SSR/client proxies
  • transformExpandExportAll() for module-level re-exports

Generated RSC transforms import registerServerReference from @vitejs/plugin-rsc/react/rsc/server.

Cache replay API

Cached Flight replay continues to use the API merged in vitejs/vite-plugin-react#1289:

createFromReadableStream(stream, {}, { preserveServerReferences: true })

This preserves opaque server references while replaying cached RSC without importing their implementation into the replaying RSC runtime.

Upstream dependencies

This POC now depends on:

It does not depend on:

  • a serverFunctionDirectives plugin option
  • plugin-rsc-owned custom directive orchestration
  • upstream recognition of vinext's transform marker
  • plugin-rsc preserving vinext metadata through rsc:use-server

Validation

  • focused formatting, lint, and type checks
  • frozen lockfile install verification
  • 29 server-function directive transform tests, including mixed claims, directive removal, and file-level ownership handoff
  • production vinext package build and knip checks
  • production nested cached-function action round-trip
  • production App Router E2E for direct Client Component imports and cached Flight replay with nested functions
  • development App Router HMR transition: "use cache""use server""use cache"
  • full CI, including all unit/integration shards, App Router production E2E, Linux/Windows packed create-next-app installs, example deploys, and smoke tests

Refs #1871

@pkg-pr-new

pkg-pr-new Bot commented Jun 18, 2026

Copy link
Copy Markdown

Open in StackBlitz

npm i https://pkg.pr.new/@vinext/cloudflare@2156
npm i https://pkg.pr.new/create-vinext-app@2156
npm i https://pkg.pr.new/@vinext/types@2156
npm i https://pkg.pr.new/vinext@2156

commit: cc55a10

@github-actions

Copy link
Copy Markdown
Contributor
Example Preview Production Original
app-router-cloudflare preview production
pages-router-cloudflare preview production
app-router-playground preview production original
realworld-api-rest preview production
nextra-docs-template preview production
benchmarks preview production
hackernews preview production original
workers-cache preview production
web preview production

NathanDrake2406 and others added 28 commits June 29, 2026 21:27
The font self-hosting unit tests depended on live Google Fonts CSS and font file requests. When the CSS fetch hit a recoverable network error, the plugin correctly skipped _vinext injection for offline fallback, but the test expected self-hosted output.

Mock both fonts.googleapis.com CSS and fonts.gstatic.com font binaries for the self-hosting assertions. The offline fallback behavior remains covered by the explicit network-error test.
* fix(pages): route dotted dynamic paths in dev

* fix(pages): preserve i18n dotted dynamic routes

* chore(pages): clarify dotted route preflight

* chore(pages): share dev hostname parsing

* chore(pages): reuse dev request origin
* fix(pages): preload initial dev stylesheets

* fix(pages): keep dev module path encoder internal

* fix(pages): harden dev stylesheet parity

* fix(pages): preserve hybrid app HMR

* fix(pages): preserve CSS resource query imports
* fix(cloudflare): support sentry request errors on workers

* fix(cloudflare): avoid worker conditions in node pages builds

* test(cloudflare): run sentry worker e2es in ci

* test(cloudflare): cover sentry render errors

* test(cloudflare): mirror sentry next setup in fixtures

* update lockfile

* update lockfile

* test(cloudflare): cover sentry client instrumentation
* fix(dev): seed Cloudflare Pages Router worker deps

* fix(dev): include external store selector dep

* fix(dev): guard optional worker optimizer dep

* Revert "fix(dev): guard optional worker optimizer dep"

This reverts commit 65530f1.

* fix(dev): suppress optional worker optimizer warning

* fix(dev): harden optional optimizer warning filter
* test(routing): cover optional catch-all root with empty params

Add a regression test for a ROOT-level optional catch-all page
pages/[[...markdownPath]].js whose getStaticPaths emits the empty-params
entry { markdownPath: [] } (the react.dev shape). Verifies the dev server
serves the root / HTML, the /_next/data/<id>/index.json endpoint, a
non-root concrete path, and a 404 for unlisted paths under fallback:false.

The existing optional catch-all test only covers a non-root subpath; this
guards the empty-params-at-root case, which routes through the trie's
0-segment optional catch-all match (route-trie.ts) and the empty-array
path normalization (route-pattern.ts / pages-page-data.ts).

* test(routing): tighten optional catch-all root coverage

---------

Co-authored-by: James <james@eli.cx>
* fix(link): reuse full prefetch loading shells

* fix(link): share loading shell prefetch ownership
* fix(css): preserve Sass partial asset URLs

* fix(css): preserve Sass use namespaces

* fix(css): match Sass namespace derivation
* fix(app-router): support styled-jsx from next

* fix(app-router): preserve styled jsx refresh

* test(app-router): exercise styled jsx compiler

* fix(app-router): keep styled jsx refresh server-safe

* fix(app-router): keep styled jsx refresh server-safe

* test(app-router): cover server-safe styled jsx dev

* fix(app-router): detect styled jsx syntax variants

* fix(app-router): scope styled jsx detection

* fix(app-router): parse styled jsx attributes

* fix(app-router): delegate styled jsx detection

* fix(app-router): preserve vinext-only style builds

* fix(app-router): detect styled jsx with AST

* fix(app-router): prefer Next styled jsx runtime

* fix(app-router): skip styled-jsx transforms in dependencies
* fix(app-router): preserve rewritten route identity

* test(app-router): isolate rewrite prefetch regression

* test(app-router): cover rewritten element keys

* test(app-router): remove orphaned rewrite fixture

* fix(prerender): avoid reloading RSC entry after path injection
* fix(app-router): honor basePath false rewrites

* fix(app-router): preserve basePath routing boundaries

* test(app-router): cover basePath handler boundaries

* fix(app-router): track basePath rewrite claims

* chore(app-router): remove stale basePath note

* fix(app-router): allow late rewrite route posts

* fix(app-router): short-circuit unclaimed basePath misses

* fix(app-router): preserve out-of-basePath 404 semantics

* fix(app-router): validate late basePath rewrites

* docs(app-router): clarify late action rewrites

* fix(app-router): resolve progressive action rewrites
)

* fix(config): resolve and bundle extensionless .cjs config imports

`vinext init` renames CJS config files (tailwind.config.js,
postcss.config.js) to .cjs when it adds "type": "module", and app code
imports them extensionlessly (import cfg from "../tailwind.config").

Two problems blocked this on the app module graph:

1. vinext overrides resolve.extensions for every Vite environment with a
   list that, like Vite's default, omits .cjs/.cts, so the extensionless
   import failed with [UNRESOLVED_IMPORT]. Append .cjs/.cts (lowest
   priority) to buildViteResolveExtensions' default list.

2. Once resolved, vite-plugin-commonjs rewrote the .cjs module.exports to
   ESM export {}, but rolldown infers moduleType: cjs from the extension
   and re-parsed the output as CommonJS, failing with "Cannot use export
   statement outside a module". Return false from the commonjs() filter for
   project-local .cjs/.cts so vite-plugin-commonjs skips them and rolldown's
   own CJS interop bundles them. Everything else returns undefined, which
   preserves the plugin's defaults, including its existing skip of
   node_modules .cjs files.

Fixes #13.

* fix(config): harden local cjs commonjs filter

* docs(config): fix cjs regression references

* test(config): cover extensionless cts config imports

* docs(config): clarify cjs resolve extension defaults

---------

Co-authored-by: James <james@eli.cx>
…2437)

* perf(build): parallelize prerender across a pool of render processes

Build-time prerender rendered every static route by fetching it from a
single in-process production server driven by a promise pool. React
SSR/RSC rendering is CPU-bound JS, so the pool only overlaps I/O — every
render serialized on one core, and raising --prerender-concurrency did
nothing. Next.js forks a worker pool and saturates every core.

Fork a pool of production-server child processes (one per core, capped)
and round-robin the per-route render fetch across them, keeping route
scanning, getStaticPaths/static-params resolution, file writing and the
manifest on the main process. Pool size scales by cores AND routes, so
small apps, low-memory machines, and --prerender-concurrency 1 keep the
single in-process server (no fork, no regression); running from source
(no built .js worker entry) also falls back to single-process.

child_process, not worker_threads: worker threads contend for CPU on this
workload (measured ~2x slower per route and non-scaling), which is also
why Next.js uses processes.

react.dev (809 routes, cold cache, same machine): 29.9s -> 15.1s, now
faster than its own Next.js build (~19.3s). An 801-route static fixture:
~22s -> ~2-6s. Prerender output is byte-identical to the single-process
path on deterministic renders; workers install the same NoOp cache
handler the in-process path uses. A worker that exits unexpectedly fails
the build loudly instead of shipping partial output.

* fix(build): harden prerender worker pool

---------

Co-authored-by: James <james@eli.cx>
…ctMode> (#2433)

* feat(pages): enforce reactStrictMode by wrapping client root in <StrictMode>

`reactStrictMode: true` was recognized but not enforced — the app root was
never wrapped in <React.StrictMode>, so dev-time strict checks (double-invoked
effects/render, deprecation warnings) were silently lost.

Resolve `reactStrictMode` from next.config (preserved as `boolean | null` so
each router applies its own default) and, for the Pages Router, wrap the
client tree in <React.StrictMode> when the value is `true`. The default
matches Next.js: `null`/unset is OFF for the Pages Router
(`reactStrictMode === null ? false` in define-env.ts).

The wrap lives in `wrapWithRouterContext` (next/router) — the single seam every
render path funnels through: the initial hydration entry (production AND the
dev server's inline hydration script) and every client-side navigation
`root.render()` in shims/router.ts. This mirrors Next.js, whose `doRender`
closure wraps in <React.StrictMode> for both the initial hydrate and subsequent
`reactRoot.render()` calls (client/index.tsx). Wrapping only the production
client entry would have been inert — StrictMode does nothing in production, and
the dev server hydrates via a separate template — so the flag is also threaded
into createSSRHandler and the dev hydration script. The wrap is gated on a
client-only `window.__VINEXT_REACT_STRICT_MODE__` flag so the server-rendered
tree is never wrapped (Next.js wraps client-side only); StrictMode renders no
DOM, so SSR markup and hydration are unaffected. The CommitBoundary stays
outside StrictMode so its commit effect is not double-invoked (Next.js keeps
`<Root>` outside <StrictMode> too).

`vinext check` reports reactStrictMode as "partial": enforced for the Pages
Router, but the App Router is not yet wrapped (its root is mounted by the RSC
client runtime, not vinext-owned code, and Next.js defaults App Router strict
mode on).

* test(pages): cover strict mode navigation renders

---------

Co-authored-by: James <james@eli.cx>
* fix(dev): treat .js as JSX in the optimizeDeps scanner

The dep optimizer (scanner + pre-bundler) runs its own Rolldown/esbuild
pipeline that does not go through the `vinext:jsx-in-js` transform plugin,
so JSX in plain `.js`/`.mjs` source files made the dependency scan fail with
"Unexpected JSX expression" and aborted pre-bundling.

Configure the dep optimizer to treat `.js`/`.mjs` as JSX
(`optimizeDeps.rolldownOptions.moduleTypes` on Vite 8,
`optimizeDeps.esbuildOptions.loader` on Vite 7), mirroring how the main
transform treats `.js`/`.mjs` (its `/\.m?js$/` filter). Applied to the
top-level optimizeDeps and the per-environment (rsc/ssr/client) blocks via
getDepOptimizeNodeEnvOptions.

The motivating real-world symptom is that, once the scan aborts,
pre-bundling is skipped and UMD/CJS deps can fail to interop under SSR
("window is not defined"). That downstream cascade runs through a different
optimizer path and is not what this change is verified to fix — the added
tests assert only that the dependency scan no longer aborts on
JSX-in-`.js`/`.mjs`.

* test(dev): cover pages jsx optimizer scan

* test(dev): strengthen jsx optimizer scan coverage

* fix(dev): apply jsx optimizer config to pages build clients

---------

Co-authored-by: James <james@eli.cx>
* fix(build): parse dynamic request JS files as JSX

* fix(build): parse mjs cjs dynamic requests as JSX

---------

Co-authored-by: James <james@eli.cx>
* fix(app-router): evict segment prefetches under memory pressure

* fix(app-router): hold prefetch queue slots until body read
* fix(link): avoid reusing dynamic app route prefetches

* fix(link): preserve static dynamic app prefetches

* test(e2e): scope segment cache client params to prod
james-elicx and others added 20 commits August 3, 2026 00:28
* 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>
…ring it (#2733)

* 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(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>
…2730)

* 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): 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): 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): 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>
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>
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>
* 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>
* fix(pages): preserve not-found response headers

* fix(pages): preserve not-found headers in dev
* fix(pages): preserve gSSP headers on redirects

* test(pages): cover data redirect headers
* fix(ci): pin Next.js tracker OpenCode version

* fix(ci): align tracker model with Big Bonk
* fix(pages): preserve raw data URLs for middleware

* fix(pages): preserve data route path with skipped normalization
* fix(pages): run middleware before image endpoint

* fix(pages): preserve image rewrite query
Bumps [zizmorcore/zizmor-action](https://github.com/zizmorcore/zizmor-action) from 0.6.0 to 0.6.2.
- [Release notes](https://github.com/zizmorcore/zizmor-action/releases)
- [Commits](zizmorcore/zizmor-action@6599ee8...3dc1ecc)

---
updated-dependencies:
- dependency-name: zizmorcore/zizmor-action
  dependency-version: 0.6.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
james-elicx and others added 9 commits August 4, 2026 15:20
* fix(middleware): align encoded path matching

* test(pages): separate matcher and route identity

* fix(middleware): preserve delimiter matcher parity

* fix(middleware): preserve trailing source delimiters

* test(middleware): align trailing source expectations

* fix(middleware): align trailing slash matcher normalization
Bumps [voidzero-dev/setup-vp](https://github.com/voidzero-dev/setup-vp) from 1.15.0 to 1.16.1.
- [Release notes](https://github.com/voidzero-dev/setup-vp/releases)
- [Commits](voidzero-dev/setup-vp@250f29c...143f5f3)

---
updated-dependencies:
- dependency-name: voidzero-dev/setup-vp
  dependency-version: 1.16.1
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
* fix(client): bridge React for Module Federation

* fix(ci): register generated React bootstrap entry

---------

Co-authored-by: James <james@eli.cx>
…d-server-functions

# Conflicts:
#	.github/workflows/ci.yml
#	packages/vinext/src/deploy.ts
#	packages/vinext/src/index.ts
#	packages/vinext/src/init.ts
#	packages/vinext/src/shims/cache-runtime.ts
#	playwright.config.ts
#	pnpm-lock.yaml
#	pnpm-workspace.yaml
#	tests/shims.test.ts
#	tests/use-cache-transform.test.ts
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.