* 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): preserve recent segment state with Activity BFCache
Fresh App Router navigations were reusing the active React tree when moving between dynamic segment values. That leaked client state into a newly entered segment and made the upstream back-forward-cache suite fail before it even reached browser traversal assertions.
The root cause was that vinext had history-entry bfcacheId plumbing but no separate segment state-key cache. Next.js treats those identities separately: render state is keyed by segment identity/pathname, while useRouter().bfcacheId identifies a concrete history entry.
Add state-key maps for app router slots, render retained entries through React.Activity when cacheComponents is enabled, keep the active one-entry keyed reset path otherwise, and port focused Next.js regression coverage for the three-entry BFCache behavior.
* fix(bfcache): store stateKeyMap in retained slot entries and re-provide on render
Review feedback requested two changes:
1. Add stateKeyMap to BfcacheSlotEntry so each retained BFCache entry carries
the state-key map that belonged to it when active, rather than borrowing the
current navigation's map. This prevents nested hidden entries from being rendered
with old elements but new pathname-derived state keys — an identity corruption
bug that upstream Next.js avoids by rendering each retained entry from its own
state.
2. Add a nested dynamic regression test: /nested/[section]/item/[id] with client
counters in both the [section] layout and [id] page. The existing flat /page/[n]
tests do not exercise nested retained-entry identity, which is exactly where the
missing state-key-map bug lives.
Changes:
- BfcacheSlotEntry gains stateKeyMap?: Readonly<Record<string, string>>
- updateBfcacheSlotEntries accepts and stores an optional stateKeyMap argument
- BfcacheSlotBoundary stores the current stateKeyMap alongside elements when
caching the active entry
- Both the Activity visible/hidden path and the non-cacheComponents single-entry
path now wrap rendered entries in BfcacheStateKeyMapContext.Provider using the
entry's own stateKeyMap (falling back to current map for forward compatibility)
- New e2e test verifies nested layout + page counter preservation across
segment navigations
* refactor(bfcache): extract shared provider stack into BfcacheEntryProviders
BfcacheSlotBoundary rendered the BfcacheStateKeyMapContext / ElementsContext /
SegmentContext provider stack in two places: the one-entry keyed reset path and
the per-entry Activity retention list. The two stacks were identical except for
where the React key sat, so the retained-entry bookkeeping and its render shape
were tangled together and harder to audit than needed.
Extract BfcacheEntryProviders as a pure presentational helper and render it from
both paths. The keyed-reset path keeps its remount semantics: the key now sits on
the BfcacheEntryProviders element, so a fresh active stateKey still remounts the
whole provider subtree.
Hook ordering is unchanged. All hooks stay above the early returns in
BfcacheSlotBoundary, so the boundary still satisfies the Rules of Hooks across the
activeStateKey undefined-to-defined transition. Covered by the slot BFCache
entry-state suite in tests/shims.test.ts.
* test(bfcache): isolate cacheComponents BFCache coverage in a dedicated fixture
The back/forward-cache e2e suite ran against the shared app-basic fixture with
cacheComponents: true. app-basic backs the single app-router Playwright server
that every app-router spec uses, so enabling cacheComponents globally changed a
core DOM invariant for unrelated tests: inactive route trees stay mounted as
hidden Activity DOM. The contamination already leaked a :visible workaround into
the interception-modal bfcacheId spec, which had nothing to do with Activity.
cacheComponents is an opt-in semantic mode, not default app-router behaviour, so
it does not belong on the shared baseline fixture. Move the BFCache routes into a
dedicated tests/fixtures/app-bfcache fixture with cacheComponents: true, served by
a new app-router-bfcache Playwright project on port 4183, and run the
back-forward-cache spec there against the project baseURL. The :visible locators
stay in that spec, where duplicate hidden DOM is the expected contract.
Remove cacheComponents and the BFCache routes from app-basic so the shared fixture
models default behaviour again, and revert the :visible workaround in
use-router-bfcache-id.spec.ts now that the modal route no longer retains hidden
DOM. Fix the nested item page import while moving it: nested/[section]/item/[id]/
page.tsx imported ../counter, but the counter lives two levels up at
nested/[section]/counter.tsx, so it must be ../../counter.
Validation: PLAYWRIGHT_PROJECT=app-router-bfcache (5 passed) and the full
use-router-bfcache-id spec under app-router (11 passed), plus the slot BFCache
unit suite (1172 passed) and vp check (formatting + lint + types clean).
* ci: run the app-router-bfcache e2e project in the matrix
The dedicated app-router-bfcache Playwright project was added to
playwright.config.ts but not to the CI E2E matrix, so the back/forward-cache
specs never ran in CI. Add it to the matrix project list so the BFCache suite
runs on every push. It uses the default chromium browser, so the existing setup
and browser-cache steps apply unchanged.
* fix(bfcache): keep persisted slots mounted when cacheComponents is off
Without cacheComponents there is no Activity retention, yet BfcacheSlotBoundary
still keyed the active entry by its segment stateKey. That stateKey tracks the
pathname (createBfcacheSegmentIdentity), so any slot whose identity moves with
the URL (shared layouts, interception source slots) was remounted on navigation,
discarding client state that survives a normal route change. The baseline router
reconciles these slots in place.
This regressed seven existing app-router e2e tests once cacheComponents was no
longer masking it on the shared fixture: interception source-context and modal
preservation in advanced.spec.ts, and layout-persistence.spec.ts dynamic-segment
layout counter survival. Fresh-entry reset is driven by userland bfcacheId keying
in the fixtures, not by remounting the slot subtree, so the keying was pure
overreach in the non-cacheComponents path.
Render the single active entry unkeyed when cacheComponents is off so the boundary
reconciles in place like the baseline. The keyed Activity retention path is
unchanged for cacheComponents.
Validation: full app-router project 436 passed / 0 failed (the seven regressions
now pass); use-router-bfcache-id 11 passed (fresh-push reset intact); app-bfcache
5 passed (Activity retention intact); shims unit suite 1024 passed.
* docs(test): point bfcacheId spec comment at the app-router-bfcache project
The header noted that Activity-backed preservation is covered in
back-forward-cache.spec.ts, but that spec moved to the dedicated app-bfcache
fixture under the app-router-bfcache project. Name the project and path so the
cross-reference stays accurate.
* docs: align cacheComponents comment with unkeyed active-segment behavior
* test(bfcache): assert fresh bfcacheId after Activity-restored return
* style: collapse locator chains per oxfmt
* refactor(bfcache): split slot entry order from snapshots
* fix(bfcache): keep non-cacheComponents slot path baseline
* fix(bfcache): use string cacheComponents define
* test: remove external fetch from revalidate tag fixture
* fix(bfcache): harden Activity state key retention
* docs(bfcache): document cacheComponents Activity caveats
* fix(bfcache): preserve navigation context for fallback SSR
Fallback boundary rendering can outlive the original request-scoped navigation context. That made app SSR fail fast with a missing navigation context when loading-boundary digest routes rendered their HTTP fallback shell.
The SSR boundary contract still requires a navigation context for real request renders, but boundary renderers now synthesize the same typed shape from the request URL and matched params when request scope has already been cleared.
* refactor(bfcache): normalize pathname in state-key map, document invariant callers
Moves hydration-pathname normalization into createBfcacheSegmentStateKeyMap so SSR and client always produce byte-identical Activity keys. Documents the requireNavigationContext guaranteeing callers per review feedback.
* fix(bfcache): tolerate malformed state-key pathnames
* docs(bfcache): document known Next.js divergence and dead-branch contract
* chore(deps): update lockfile for vite 0.1.24
* fix(app-router): preserve encoded delimiters in bfcache keys
* docs(bfcache): track router-owned retention follow-up
* chore: retrigger ci
* fix(app-router): preserve redirect bridge fallback
* refactor(server): replace prerender HTML buffering with metadataReady contract
* fix(server): wait for allReady before piping through tick-buffered transform when waitForAllReady is true
* refactor(server): clean up AppSsrRenderResult normalization and metadataReady promise resolution
* test(server): cover ISR revalidation waitForAllReady; address review nits
- Add focused test asserting stale HTML cache regeneration passes
waitForAllReady: true to handleSsr so Suspense fallbacks never leak
into the cache entry
- Document why metadataReady resolves eagerly (allReady is already
awaited for the prerender path) in app-ssr-entry.ts
- Remove stray blank line left by the readStreamAsText import removal
- Type capturedOnError in the prerender special-error test instead of any
* fix(pages): export PagesPageModule type consumed by pages-page-handler
The dts bundle build (vp pack) failed because pages-page-handler.ts
imports PagesPageModule from pages-page-data.js, but the type was not
exported, so rolldown-plugin-dts could not resolve it.
---------
Co-authored-by: James <james@eli.cx>
Extends #1565: the merged change gated the marker on the resolved segment
config, but only covered the two `buildAppPage*Response` builders. Five
other response construction paths bypassed those helpers and skipped the
header for edge-runtime routes:
- `app-page-cache.ts` — ISR HIT/STALE responses
- `app-server-action-execution.ts` — server action RSC responses
(redirect, no-revalidate, re-render)
- `app-page-boundary.ts` — boundary RSC responses
- `app-page-execution.ts` — 200 + flight-payload redirect responses
- `app-page-stream.ts` — HTML stream responses used by the boundary path
Centralizes the gating in a single `applyEdgeRuntimeHeader` helper so
every site shares one source of truth for header name + condition, and
threads `isEdgeRuntime` through the fallback renderer + dispatch options
so each construction site receives it.
Adds positive + negative coverage for each newly-covered path.
* fix(app-router): emit streamed redirect and not-found meta tags
Streaming App Router SSR currently returns a 200 once the shell commits, but redirect() or notFound() discovered inside a later Suspense boundary never emits the browser-visible meta tag that Next.js relies on. That leaves the control-flow digest only in the RSC payload until client hydration handles it.
The SSR onError path now captures digest-bearing render errors and a request-local flusher injects noindex or refresh meta tags through the existing server-inserted HTML stream hook. The app page pipeline also forwards basePath into SSR so streamed redirect meta tags match early redirect responses.
* fix(app-router): avoid double-prefixing basePath root redirects
Streamed redirect meta tags checked basePath against the full redirect location string. Already-prefixed root URLs with a query or hash, such as /docs?from=checkout, failed the pathname-shaped basePath check and were prefixed a second time.
Add a shared pathname-only basePath prefix helper and use it from both early redirect responses and streamed redirect metadata. The streamed path now splits query/hash before checking the pathname.
* 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
* feat(server): add waitForAllReady forkpoint for prerender/ISR parity
Mirrors Next.js PR #93376: adds a waitForAllReady option that defers
emitting the HTML stream until React's onAllReady fires, ensuring the
full tree (including resolved Suspense boundaries) is present before
serialization.
Changes:
- app-ssr-entry.ts: accept waitForAllReady and await stream.allReady
- app-page-stream.ts: thread waitForAllReady through the pipeline
- app-page-render.ts: pass waitForAllReady: isPrerender to the HTML render
Closes#1011
* test(app-page-stream): use toHaveBeenCalledWith for waitForAllReady forwarding
Replaces the positional-tuple type cast with expect.objectContaining,
which reads more clearly and stays robust if handleSsr's signature
gains new positional args.
---------
Co-authored-by: James <james@eli.cx>
deferUntilStreamConsumed's pull() handler had no error path — when the
upstream RSC/SSR stream errored mid-consumption, the onFlush callback
(clearRequestContext) was never invoked, leaking per-request state.
The cancel hook handled client disconnect and the flush hook handled
normal completion, but stream errors were uncovered. Adds an error
handler that calls once() before propagating the error.
* fix: defer clearRequestContext() until HTML stream is fully consumed
headers()/cookies() throw on warm requests because clearRequestContext()
was called synchronously after receiving the RSC/SSR stream handle, before
any Server Components had actually executed. The RSC/SSR pipeline is lazy —
components run while the response body is pulled by the HTTP layer.
Fix: pipe the HTML stream through a TransformStream whose flush() callback
calls clearRequestContext(), deferring cleanup until the last byte is
consumed. Eagerly-read values (draftCookie, consumeDynamicUsage, etc.) are
still captured synchronously before the stream is returned.
Two sites fixed:
- server/app-page-render.ts (renderAppPageLifecycle, covers both the plain
and ISR-cacheable HTML paths)
- server/app-page-stream.ts (renderAppPageHtmlResponse)
Regression tests added for both sites.
Fixes#660
* fix: guard against stream-cancelled crashes in tick-buffered transform and early-disconnect cleanup
- createTickBufferedTransform: wrap the setTimeout callback in try/catch so
that a stream cancellation between scheduling and firing (e.g. a health-check
that calls res.body.cancel()) does not throw ERR_INVALID_STATE and crash the
Vite dev server process.
- deferUntilStreamConsumed: add a cancel() handler so clearRequestContext() is
still called when the HTTP consumer drops the connection before the stream is
fully drained, preventing per-request context leaks on early disconnect.