* fix(app-router): respect reactMaxHeadersLength for preload Link header (#1552)
* test(app-router): cover reactMaxHeadersLength config resolution
* refactor(app-router): prioritize React preloads in Link header; cover buildAppPageLinkHeader
Address review feedback on #1841:
- Order React preload hints before font preloads so render-critical entries
survive a tight reactMaxHeadersLength cap.
- Rename the buildAppPageHtmlResponse option to linkHeader (it now carries the
combined header, not just fonts).
- Add unit coverage for buildAppPageLinkHeader (cap, 0-disables, ordering,
whole-entry truncation).
* fix(app-router): emit per-page dynamic stale time metadata
App Router pages that export unstable_dynamicStaleTime currently behave as if no page-level stale window exists. The server segment config resolver discards the export, so RSC responses cannot carry the per-page metadata that the client cache needs for independently configured pages.
The violated invariant is that route segment config resolution must preserve page-only stale-time metadata across active parallel pages before response construction. Parse the page export, reduce active page slots by shortest stale time, thread the value through generated App page dispatch, and emit it on RSC responses for the client cache to consume.
* fix(app-router): gate dynamic stale time header to dynamic-only renders
Only emit X-Vinext-Dynamic-Stale-Time when the render is truly dynamic
(not prerender or force-static), matching Next.js behavior where the
response `d` field is only set for non-static-generation renders.
The client treats the headers presence as authoritative; leaking it onto
static/prerendered RSC responses would cause the client cache to apply
dynamic stale time semantics to a statically-cached entry.
* test(app-router): add behavioral tests for dynamic stale time gating
* docs(app-page-render): add line-numbered link to Next.js source
* refactor(app-router): drop redundant Number.isFinite check
Number.isInteger already returns false for Infinity/NaN, so the
preceding Number.isFinite guard is redundant in both the dynamic
stale-time header check and resolveDynamicStaleTimeSeconds.
* fix(app-router): gate dynamic stale time on cache-capture predicate
The previous gating (isPrerender || isForceStatic) was too narrow for
production: isPrerender is only true during the build-time prerender
pass, so a production ISR render (revalidate > 0) still emitted
X-Vinext-Dynamic-Stale-Time even though that render is captured into the
ISR cache. Next.js gates on !workStore.isStaticGeneration; the runtime
analog here is shouldCaptureRscForCacheMetadata, so add it to the guard.
Adds production ISR coverage to lock down the gating.
* docs(app-router): document dynamic stale time over-gating boundary
Add a comment explaining that shouldCaptureRscForCacheMetadata is
computed from config before the stream runs, so a production
default-config route that turns dynamic via a late dynamic API loses the
advisory stale-time hint even though Next.js (!isStaticGeneration) would
emit it. The value is unknowable until after headers are built.
Adds a complementary test pinning down this boundary behavior.
---------
Co-authored-by: James <james@eli.cx>
* fix(app-router): cache pages with revalidate=Infinity/false (#1487)
App Router pages exporting `revalidate = Infinity` (or `revalidate = false`,
which resolves to Infinity in segment config) were re-rendered on every
request: the HTML response policy returned `shouldWriteToCache: false`, and
the cache-write policy rejected non-finite revalidate values outright. The
downstream Cache-Control header was correct (1y s-maxage), but vinext itself
never served identical bytes for repeat requests inside the same process.
This change treats `revalidate = Infinity` as "cache indefinitely" rather
than "never write to cache":
- `resolveAppPageHtmlResponsePolicy` now writes the cached HTML/RSC bundle
in production and reports `MISS` on first render, matching the standard
ISR write flow. Dev mode continues to emit `STATIC` with no cache write.
- `resolveAppPageCacheWritePolicy` accepts `Infinity` as a valid revalidate
interval; only `null`, `NaN`, and non-positive values reject the write.
The MemoryCacheHandler (used by tests and as the default) already handles
Infinity correctly: `revalidateAt = now + Infinity` keeps the entry fresh
forever until `revalidatePath`/`revalidateTag` invalidates it.
Adds two production-server regression tests:
- `export const revalidate = 60` returns HIT with identical timestamp on
the second request (covers the standard finite case).
- `export const revalidate = Infinity` returns HIT with identical timestamp
on the second request (covers the previously broken indefinite case).
Refs #1487. Other symptoms listed in the issue (`unstable_cache`, fetch
cache directives, force-static, force-no-store, on-demand revalidate) are
out of scope here and tracked separately.
* test(app-page-response): update Infinity revalidate assertions
Production + revalidateSeconds: Infinity now writes to the ISR cache
(cacheState: "MISS", shouldWriteToCache: true). Update the existing
assertion to match, and add a dev-mode case asserting Infinity stays
as a no-write STATIC response in development.
* fix(app-router): emit x-edge-runtime marker on edge RSC route response
Closes#1531
The Next.js edge-runtime app template (edge-ssr-app.ts) sets
`x-edge-runtime: 1` on every App Router response. vinext serves App
Router responses from a Cloudflare Worker (an edge-equivalent runtime)
but was not emitting this marker, so Next.js' upstream test suite
(`test/e2e/app-dir/app/index.test.ts`) saw `null` for the header.
Add the header to both the RSC and HTML response builders so the
assertion passes for any App Router page, regardless of which payload
shape the client requests.
* fix(app-router): gate x-edge-runtime marker on configured runtime
Only emit `x-edge-runtime: 1` when the route's resolved segment config
opts into the edge runtime (`export const runtime = "edge"` or
`"experimental-edge"`). Plumb the resolved runtime from the segment
config through dispatch and the render lifecycle so both RSC and HTML
response builders see it.
Without the gate, nodejs-runtime routes also advertise the marker, which
hides the configured runtime from downstream consumers.
* fix(app-router): match Next.js RSC Content-Type (no charset suffix)
Next.js sets the RSC (React Server Components / 'flight') response
Content-Type to exactly 'text/x-component' (RSC_CONTENT_TYPE_HEADER in
.nextjs-ref/packages/next/src/client/components/app-router-headers.ts:17,
used by FlightRenderResult in
.nextjs-ref/packages/next/src/server/app-render/flight-render-result.ts:15).
vinext was sending 'text/x-component; charset=utf-8'. The suffix breaks
Next.js's own segment-cache deduplication checks (action-handler.ts uses
.startsWith(RSC_CONTENT_TYPE_HEADER) on the unsuffixed string) and fails
Next.js e2e tests that assert strict equality, e.g.:
- test/e2e/app-dir/app/index.test.ts L362, L371
- test/e2e/app-dir/segment-cache/deployment-skew/deployment-skew.test.ts L80
Replace the duplicated literal at seven sites with the existing
VINEXT_RSC_CONTENT_TYPE constant in app-rsc-cache-busting.ts.
* fix(app-router): match Next.js 404 plain-text body 'This page could not be found'
Next.js's plain-text 404 response body is exactly
'This page could not be found' (no trailing period in the response body;
the React-rendered not-found component uses the same text with a period).
vinext was returning 'Not Found' / '404 Not Found' / '404 - Not found',
which fails Next.js e2e tests that assert .toContain('This page could
not be found'), e.g.:
- test/e2e/app-dir/app/index.test.ts (several cases)
- test/e2e/app-dir/parallel-route-not-found/parallel-route-not-found.test.ts L52
- test/e2e/app-dir/prefetching-not-found/prefetching-not-found.test.ts L16
Next.js source citations (in .nextjs-ref):
- packages/next/src/server/route-modules/pages/pages-handler.ts L121, L535
- packages/next/src/build/templates/app-route.ts L170, L349
- packages/next/src/build/templates/app-page.ts L701, L1043
- packages/next/src/pages/_error.tsx L7
- packages/next/src/client/components/builtin/not-found.tsx L7 (HTML variant)
Updates the body in notFoundResponse() and the open-redirect / pages-router
SSR-fallback / worker-entry inline 404 sites. The HTTP status reason phrase
('Not Found') is unchanged. Route-specific bodies like '404 - API route not
found' are intentionally retained.
* fix(app-router): hard navigate stale RSC build payloads
App Router soft navigation currently accepts any valid RSC payload after deploy. That lets stale tabs decode RSC responses produced by a different build, which can break client navigation when the server and browser artifacts no longer match.
The missing invariant was that every RSC response consumed by the browser must identify the build that produced it, and the browser must reject payloads from a different build before handing them to React.
Emit a vinext-owned build ID header on App Router RSC responses, preserve it in prefetched and visited response snapshots, and hard navigate when a response is missing or mismatches the current client build ID.
* chore: fix e2e fixture hook lint suppression
* chore: avoid hook-like fixture callback name
* fix(app-router): preserve build IDs for cached RSC payloads
* fix(app-router): use public RSC compatibility IDs
* fix(app-router): preserve useActionState form state after hydration
Progressive multipart server actions executed before the initial HTML render were dropping React form state. That meant useActionState hydrated from the JavaScript initializer instead of the server action result after a no-JS form submission.
The progressive action path now decodes React form state, carries it through the App Router render pipeline, passes it to React's server and client hydration APIs, and marks form-state HTML responses as uncacheable so action results cannot be replaced by stale ISR HTML.
Adds targeted unit coverage for action decoding, SSR forwarding, cache policy, cache read bypass, and a Playwright no-JS useActionState regression.
* fix(app-router): keep progressive action result type internal
Knip reported ProgressiveServerActionResult as an unused exported type. The type is only part of this module's implementation detail for the progressive action return path, so keep it module-local.
* fix(app-router): separate progressive action cache bypass
* chore(app-router): tighten form state bootstrap wiring
App Page ISR could serve a cached production response to a request carrying a valid __prerender_bypass cookie. That violates Next.js draft-mode semantics because preview users must render dynamically and must not populate shared page cache entries.
The dispatch layer now identifies draft-mode requests with the same cookie parser and secret used by draftMode(), skips App Page cache reads, and passes that state into render response policy. HTML and RSC render paths now treat draft mode as no-store and avoid cache capture/write work.
Tests cover valid draft cookies bypassing cached HTML, arbitrary cookie values preserving cache hits, and response-policy no-store behavior.
* fix(app-router): validate RSC cache-busting params
App Router RSC responses could vary by request headers while client fetches and server validation still relied on Vary-aware caches. CDNs that key only by URL could reuse a mounted-slot or interception-context RSC payload for a different request.
Next.js validates a cache-busting _rsc search param against the RSC request headers and redirects malformed requests to the canonical URL. Vinext now applies the same invariant to its .rsc request model, centralizes the RSC Vary header set in normal server modules, and de-duplicates middleware Vary additions.
Tests cover missing and stale _rsc values, variant-header hashes, generated-entry delegation, browser RSC request markers, response Vary propagation, and App Router RSC integration.
* fix(app-router): hide RSC cache tokens from navigation URLs
* fix(app-router): hide RSC cache token from searchParams
* fix(app-router): address RSC cache-busting review
* fix(app-router): preserve HTML redirect destination for direct .rsc navigations
Config redirects from next.config.js were unconditionally converted to
.rsc destinations for any request whose URL ended in .rsc. This broke
direct browser navigations to .rsc URLs (e.g. a bookmark or external
link) because the browser was redirected to an RSC payload it could not
render.
Client-side RSC fetches always send the RSC: 1 header, so restrict the
.rsc destination conversion to requests that carry that header. Direct
browser hits without the header continue to redirect to the canonical
HTML URL, matching the pre-PR behaviour for config redirects.
Pages exporting revalidate = 0 should be treated as fully dynamic (never
cached), matching Next.js behavior. The previous code used truthiness
checks on revalidateSeconds, causing 0 to fall through with no
Cache-Control header — allowing CDNs to cache dynamic content.
Add explicit === 0 guards in both resolveAppPageRscResponsePolicy and
resolveAppPageHtmlResponsePolicy, placed before any branch that uses
!revalidateSeconds or revalidateSeconds > 0. Also tighten the
isForceStatic/isDynamicError condition in the HTML helper from
(=== null || === 0) to === null, since 0 is now handled earlier.
* fix: apply middleware headers to intercept route and server action responses
Middleware response headers (_mwCtx.headers) were silently dropped on three
code paths: intercepting route responses, server action re-render responses,
and server action redirect responses. All three constructed Response objects
directly without merging middleware headers, unlike the normal page path
which goes through buildAppPageRscResponse().
Extract mergeMiddlewareResponseHeaders() from the inline logic in
buildAppPageRscResponse() and call it in all three paths. This ensures
auth cookies, CORS headers, and security headers set in middleware are
applied consistently to all response types.
* fix: merge middleware headers before x-action-redirect control headers
Codex review correctly identified that mergeMiddlewareResponseHeaders()
uses set() for most keys, meaning middleware headers could overwrite the
x-action-redirect* control headers if a middleware happened to set them.
Fix by merging middleware headers first, then writing the x-action-redirect*
headers afterward so they are always authoritative. The framework's own
redirect control signals must survive regardless of what middleware sets.
* fix: update RSC entry snapshots for middleware headers
Headers.set() requires ByteString values (chars <= U+00FF). When dynamic
route params contain non-ASCII characters (Korean, Japanese, etc.),
JSON.stringify preserves them verbatim which causes a TypeError on the
Headers.set() call in buildAppPageRscResponse.
Fix: encodeURIComponent the JSON on the server side before setting the
header, and decodeURIComponent on the client side when reading it back
during initial hydration and client-side navigation.
Closes#676