Cached Navigations: Cache visited fully static pages in the segment cache#90306
Merged
unstubbable merged 21 commits intocanaryfrom Mar 4, 2026
Merged
Cached Navigations: Cache visited fully static pages in the segment cache#90306unstubbable merged 21 commits intocanaryfrom
unstubbable merged 21 commits intocanaryfrom
Conversation
This was referenced Feb 21, 2026
Contributor
Author
Collaborator
Tests Passed |
Collaborator
Stats from current PR🔴 1 regression
📊 All Metrics📖 Metrics GlossaryDev Server Metrics:
Build Metrics:
Change Thresholds:
⚡ Dev Server
📦 Dev Server (Webpack) (Legacy)📦 Dev Server (Webpack)
⚡ Production Builds
📦 Production Builds (Webpack) (Legacy)📦 Production Builds (Webpack)
📦 Bundle SizesBundle Sizes⚡ TurbopackClient Main Bundles: **401 kB** → **401 kB**
|
| Canary | PR | Change | |
|---|---|---|---|
| middleware-b..fest.js gzip | 769 B | 764 B | ✓ |
| Total | 769 B | 764 B | ✅ -5 B |
Build Details
Build Manifests
| Canary | PR | Change | |
|---|---|---|---|
| _buildManifest.js gzip | 453 B | 451 B | ✓ |
| Total | 453 B | 451 B | ✅ -2 B |
📦 Webpack
Client
Main Bundles
| Canary | PR | Change | |
|---|---|---|---|
| 5528-HASH.js gzip | 5.54 kB | N/A | - |
| 6280-HASH.js gzip | 58.7 kB | N/A | - |
| 6335.HASH.js gzip | 169 B | N/A | - |
| 912-HASH.js gzip | 4.59 kB | N/A | - |
| e8aec2e4-HASH.js gzip | 62.6 kB | N/A | - |
| framework-HASH.js gzip | 59.7 kB | 59.7 kB | ✓ |
| main-app-HASH.js gzip | 255 B | 253 B | ✓ |
| main-HASH.js gzip | 39.1 kB | 39.1 kB | ✓ |
| webpack-HASH.js gzip | 1.68 kB | 1.68 kB | ✓ |
| 262-HASH.js gzip | N/A | 4.59 kB | - |
| 2889.HASH.js gzip | N/A | 169 B | - |
| 5602-HASH.js gzip | N/A | 5.55 kB | - |
| 6948ada0-HASH.js gzip | N/A | 62.6 kB | - |
| 9544-HASH.js gzip | N/A | 59.8 kB | - |
| Total | 232 kB | 233 kB |
Polyfills
| Canary | PR | Change | |
|---|---|---|---|
| polyfills-HASH.js gzip | 39.4 kB | 39.4 kB | ✓ |
| Total | 39.4 kB | 39.4 kB | ✓ |
Pages
| Canary | PR | Change | |
|---|---|---|---|
| _app-HASH.js gzip | 194 B | 194 B | ✓ |
| _error-HASH.js gzip | 183 B | 180 B | 🟢 3 B (-2%) |
| css-HASH.js gzip | 331 B | 330 B | ✓ |
| dynamic-HASH.js gzip | 1.81 kB | 1.81 kB | ✓ |
| edge-ssr-HASH.js gzip | 256 B | 256 B | ✓ |
| head-HASH.js gzip | 351 B | 352 B | ✓ |
| hooks-HASH.js gzip | 384 B | 383 B | ✓ |
| image-HASH.js gzip | 580 B | 581 B | ✓ |
| index-HASH.js gzip | 260 B | 260 B | ✓ |
| link-HASH.js gzip | 2.51 kB | 2.51 kB | ✓ |
| routerDirect..HASH.js gzip | 320 B | 319 B | ✓ |
| script-HASH.js gzip | 386 B | 386 B | ✓ |
| withRouter-HASH.js gzip | 315 B | 315 B | ✓ |
| 1afbb74e6ecf..834.css gzip | 106 B | 106 B | ✓ |
| Total | 7.98 kB | 7.98 kB | ✅ -1 B |
Server
Edge SSR
| Canary | PR | Change | |
|---|---|---|---|
| edge-ssr.js gzip | 125 kB | 125 kB | ✓ |
| page.js gzip | 254 kB | 256 kB | ✓ |
| Total | 379 kB | 381 kB |
Middleware
| Canary | PR | Change | |
|---|---|---|---|
| middleware-b..fest.js gzip | 617 B | 617 B | ✓ |
| middleware-r..fest.js gzip | 156 B | 155 B | ✓ |
| middleware.js gzip | 43.9 kB | 43.9 kB | ✓ |
| edge-runtime..pack.js gzip | 842 B | 842 B | ✓ |
| Total | 45.5 kB | 45.5 kB | ✅ -68 B |
Build Details
Build Manifests
| Canary | PR | Change | |
|---|---|---|---|
| _buildManifest.js gzip | 715 B | 718 B | ✓ |
| Total | 715 B | 718 B |
Build Cache
| Canary | PR | Change | |
|---|---|---|---|
| 0.pack gzip | 4.06 MB | 4.07 MB | 🔴 +8.1 kB (+0%) |
| index.pack gzip | 103 kB | 102 kB | ✓ |
| index.pack.old gzip | 103 kB | 103 kB | ✓ |
| Total | 4.26 MB | 4.27 MB |
🔄 Shared (bundler-independent)
Runtimes
| Canary | PR | Change | |
|---|---|---|---|
| app-page-exp...dev.js gzip | 321 kB | 321 kB | ✓ |
| app-page-exp..prod.js gzip | 170 kB | 170 kB | ✓ |
| app-page-tur...dev.js gzip | 320 kB | 321 kB | ✓ |
| app-page-tur..prod.js gzip | 170 kB | 170 kB | ✓ |
| app-page-tur...dev.js gzip | 317 kB | 318 kB | ✓ |
| app-page-tur..prod.js gzip | 168 kB | 168 kB | ✓ |
| app-page.run...dev.js gzip | 317 kB | 318 kB | ✓ |
| app-page.run..prod.js gzip | 168 kB | 169 kB | ✓ |
| app-route-ex...dev.js gzip | 70.8 kB | 70.8 kB | ✓ |
| app-route-ex..prod.js gzip | 49.3 kB | 49.3 kB | ✓ |
| app-route-tu...dev.js gzip | 70.9 kB | 70.9 kB | ✓ |
| app-route-tu..prod.js gzip | 49.3 kB | 49.3 kB | ✓ |
| app-route-tu...dev.js gzip | 70.5 kB | 70.5 kB | ✓ |
| app-route-tu..prod.js gzip | 49 kB | 49 kB | ✓ |
| app-route.ru...dev.js gzip | 70.4 kB | 70.4 kB | ✓ |
| app-route.ru..prod.js gzip | 49 kB | 49 kB | ✓ |
| dist_client_...dev.js gzip | 324 B | 324 B | ✓ |
| dist_client_...dev.js gzip | 326 B | 326 B | ✓ |
| dist_client_...dev.js gzip | 318 B | 318 B | ✓ |
| dist_client_...dev.js gzip | 317 B | 317 B | ✓ |
| pages-api-tu...dev.js gzip | 43.2 kB | 43.2 kB | ✓ |
| pages-api-tu..prod.js gzip | 32.9 kB | 32.9 kB | ✓ |
| pages-api.ru...dev.js gzip | 43.2 kB | 43.2 kB | ✓ |
| pages-api.ru..prod.js gzip | 32.9 kB | 32.9 kB | ✓ |
| pages-turbo....dev.js gzip | 52.6 kB | 52.6 kB | ✓ |
| pages-turbo...prod.js gzip | 38.5 kB | 38.5 kB | ✓ |
| pages.runtim...dev.js gzip | 52.6 kB | 52.6 kB | ✓ |
| pages.runtim..prod.js gzip | 38.5 kB | 38.5 kB | ✓ |
| server.runti..prod.js gzip | 62 kB | 62 kB | ✓ |
| Total | 2.83 MB | 2.83 MB |
📝 Changed Files (8 files)
Files with changes:
app-page-exp..ntime.dev.jsapp-page-exp..time.prod.jsapp-page-tur..ntime.dev.jsapp-page-tur..time.prod.jsapp-page-tur..ntime.dev.jsapp-page-tur..time.prod.jsapp-page.runtime.dev.jsapp-page.runtime.prod.js
View diffs
app-page-exp..ntime.dev.js
failed to diffapp-page-exp..time.prod.js
failed to diffapp-page-tur..ntime.dev.js
failed to diffapp-page-tur..time.prod.js
failed to diffapp-page-tur..ntime.dev.js
failed to diffapp-page-tur..time.prod.js
Diff too large to display
app-page.runtime.dev.js
failed to diffapp-page.runtime.prod.js
failed to diff📎 Tarball URL
https://vercel-packages.vercel.app/next/commits/cfc45ebd6cc948b8db06032ecd2e871ad9043e1a/next
c1deae6 to
568a877
Compare
2218724 to
d5633db
Compare
568a877 to
d7de17c
Compare
d5633db to
5394698
Compare
34ea36d to
457083c
Compare
28a2aa4 to
19d28ee
Compare
fdaa15f to
5c3b674
Compare
19d28ee to
7eccbc0
Compare
3ced99b to
10f41bb
Compare
7eccbc0 to
748b014
Compare
10f41bb to
7694cb4
Compare
748b014 to
b11ed0b
Compare
7694cb4 to
850f860
Compare
Contributor
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
b11ed0b to
0d1cec2
Compare
0457b1d to
9a87eda
Compare
acdlite
reviewed
Mar 1, 2026
d56a60a to
7cbc8ac
Compare
When a fully static page is loaded, the RSC payload inlined in the HTML is now written into the client-side segment cache during hydration. This allows subsequent client-side navigations to the same route to be served entirely from cache without any server requests, until the stale time expires. Server: Include a `StaleTimeIterable` (`s` field) in the `InitialRSCPayload` during the Cache Components prerender path. The iterable is tracked on `finalServerPrerenderStore` and closed alongside the vary params accumulator in the sequential task after prerender completes. Client: Thread the stale time and head vary params from the decoded payload through `createInitialRouterState` into a new `writeInitialSeedDataIntoCache` function that writes each segment and the head into the segment cache using the existing `writeSeedDataIntoCache` / `fulfillEntrySpawnedByRuntimePrefetch` functions. Partially static pages (resumed at runtime) are not handled yet. Those need byte-level truncation of the Flight stream to extract only the static stage (same as for dynamic RSC requests).
The `isResponsePartial` value from `cacheData` (set by `processFetch`) defaults to `true` when no marker byte is found. This is correct for navigation requests (conservative default), but wrong for Full and LoadingBoundary prefetches which are by definition complete. Before the `processFetch` refactoring, the prefetch path only read `isResponsePartial` from `stripIsPartialByte` for PPRRuntime, and defaulted to `false` for other strategies. The refactoring changed it to always read from `cacheData`, which overrode the correct `false` default with an incorrect `true` for non-PPRRuntime strategies. Fix: only use `cacheData.isResponsePartial` for PPRRuntime prefetches; default to `false` for Full and LoadingBoundary.
Replace the `isResponsePartial` boolean in `writeDynamicRenderResponseIntoCache` and `writeSeedDataIntoCache` with a three-state `ResponseCompleteness` enum that distinguishes: - `Partial`: segments have dynamic holes (static stage responses, PPRRuntime prefetches with `~` marker, postponed responses) - `Complete`: all segments are complete, but the head may still be partial per the server's flag (Full/LoadingBoundary prefetches) - `FullyStatic`: server explicitly marked the response as fully static (marker byte `#`) — both segments and head are complete The boolean conflated two independent concerns: segment partiality and whether the server's `isHeadPartial` flag can be overridden. This caused Full prefetches (which are complete but may have partial heads) to incorrectly override `isHeadPartial` to `false`, breaking loading boundary display for pages with dynamic metadata.
The server's `#` marker byte signals that a prerendered response is fully static (no dynamic follow-up needed). Previously this was used to control `ResponseCompleteness` during segment cache writes, marking segments as non-partial. This caused two regressions: 1. Loading boundaries not showing for pages with dynamic metadata (head cached as non-partial when it should be partial) 2. Server action refresh breaking parallel routes (default navbar slot cached as non-partial, preventing dynamic data from overwriting it) The root cause: the marker is a route-level signal but was applied as a segment-level signal at write time. Now, `isFullyStatic` is stored on the `FulfilledRouteCacheEntry` and checked at read time in `createCacheNodeForSegment`. When navigating to a fully static route with cached segment data available, the read path overrides `isCachedRscPartial` and `isCachedHeadPartial` to skip the dynamic request. This respects per-segment freshness: once entries expire, the override no longer applies and a dynamic request is made. The navigation write path (`writeStaticStageResponseIntoCache`) now always uses `Partial` completeness. The prefetch write path still uses `FullyStatic` for the head override since prefetch entries don't conflict with refresh data.
The three-value `ResponseCompleteness` enum (`Partial`, `Complete`, `FullyStatic`) was conflating two concerns: segment partiality and the head override for fully static routes. Now that `isFullyStatic` lives on the route cache entry, we can use it directly for the head override and reduce the enum to a simple boolean (same as on `canary`). - Set `route.isFullyStatic` from the prefetch write path (consistent with the navigation and initial HTML paths) - Use `route.isFullyStatic` for the head partiality override in `writeDynamicRenderResponseIntoCache` - Replace the enum with `isResponsePartial: boolean` in `writeDynamicRenderResponseIntoCache` and `writeSeedDataIntoCache`
The server prepends a completeness marker byte to RSC Flight responses.
Previously, both fully static prerenders and complete runtime prefetches
used `#` (0x23), which caused the client to incorrectly set
`route.isFullyStatic` for non-fully-static pages whose runtime render
happened to complete without aborting. This made the segment cache skip
the dynamic follow-up request at navigation time, leaving content from
non-runtime-prefetchable segments missing.
Introduce a third marker byte `*` (0x2a) for complete runtime prefetches
from `finalRuntimeServerPrerender`, reserving `#` for build-time
prerendered fully static pages. The client now only sets
`route.isFullyStatic` when it sees `#`, which is only served for pages
that are genuinely fully static.
The three markers are defined in a shared `ResponseCompletenessMarker`
enum:
- `#` (Static) — fully static prerender, all segments present
- `*` (Complete) — complete runtime prefetch, included segments are
complete but the response may omit segments
- `~` (Partial) — partial, contains dynamic holes
Server action redirects to fully static pages include the prerendered flight data for the redirect target, which has the `ResponseCompletenessMarker` byte prepended at build time. The server action reducer passed this response directly to React's `createFromFetch` without stripping the marker, causing a `Connection closed` error because the marker byte is not valid RSC Flight data. Fix by running the response through `processFetch` (which calls `stripCompletenessMarker`) before passing it to `createFromFetch`. Also export `processFetch` so it can be reused, and preserve the `redirected` property on the reconstructed `Response` since the `Response` constructor does not carry it over from the original.
Delete `writeInitialSeedDataIntoCache` and `processStaticStageResponse`, replacing their usage with `writeStaticStageResponseIntoCache` and inline `getStaleAt` calls at each call site. This removes an async cache write function (all cache writes should be sync, with async computations at the call site) and eliminates duplicated logic between the initial HTML and navigation cache write paths. `writeDynamicRenderResponseIntoCache` and `writeStaticStageResponseIntoCache` now take `flightData` and `buildId` as explicit params instead of a full response object, and `writeStaticStageResponseIntoCache` takes `headVaryParamsThenable` instead of pre-resolved `headVaryParams`. The `responseHeaders` param is removed — callers resolve the build ID from the deployment header before calling. The build ID check in `writeDynamicRenderResponseIntoCache` is changed from `buildId !== getNavigationBuildId()` to `buildId && buildId !== getNavigationBuildId()` so that a missing build ID (e.g. from the initial HTML path) is treated as permissive rather than as a mismatch. All `getStaleAt` call sites now use `.then().catch()` instead of `.then(onFulfilled, onRejected)` so errors from both the stale time computation and the cache write are caught. `getStaleAt` is exported for use by external call sites.
The initial HTML of fully static pages should be cached with `FetchStrategy.Full` and `isResponsePartial = false`, since all segments are present and no dynamic follow-up is needed. Previously, `writeStaticStageResponseIntoCache` was used for both navigation responses and initial HTML, but its `FetchStrategy.PPR` + `isResponsePartial = true` settings caused issues with mismatching prefetch rewrites — partial entries triggered unnecessary dynamic follow-up requests. `writeStaticStageResponseIntoCache` is now used only for navigation responses (static stage of PPR).
When writing fully static pages into the segment cache (via navigation or initial HTML), segments are now written directly as non-partial instead of using a route-level `isFullyStatic` flag to override partiality at read time. Default page segments are skipped during the cache write to prevent their content from being stored at keys that collide with reused active slots. During a refresh, the reused slot's lookup would hit these entries and render the static default content instead of fetching fresh dynamic data. The filter handles two cases: segments with `__DEFAULT__` directly (the implicit `children` parallel route), and `(slot)` virtual wrapper segments whose `children` child is `__DEFAULT__` (named parallel route slots like `@navbar`). The `(slot)` wrapper is a build-time construct from `next-app-loader` that is always exactly one level deep. `writeInitialFullyStaticResponseIntoCache` is removed in favor of calling `writeStaticStageResponseIntoCache` with `isResponsePartial: false`.
The head partial override in `writeDynamicRenderResponseIntoCache` is now controlled by an explicit `overrideHeadAsNonPartial` parameter instead of being derived from `isResponsePartial`. Only `writeStaticStageResponseIntoCache` passes `true` for fully static responses. This prevents per-segment prefetches and other callers from incorrectly marking the head as non-partial, which would block dynamic metadata from being fetched on subsequent navigations to pages with different metadata.
A runtime prefetch can receive a statically generated response (with the `Static` completeness marker) for a fully static page. In that case the head is complete despite the server conservatively marking it as partial. Without the override, navigating to a previously visited fully static page would make an unnecessary dynamic request just for the head.
The two boolean parameters `isResponsePartial` and `overrideHeadAsNonPartial` encoded three valid states but allowed an invalid fourth combination. They are replaced with a single `ResponseCompleteness` enum that distinguishes `Partial` (segments have dynamic holes), `Complete` (all included segments are complete but some may have been omitted based on the router state tree, or the head may still be partial), and `Static` (the server confirmed the entire response is fully static including the head).
Instead of the server prepending a completeness marker byte with three possible values (Partial, Complete, Static), it now prepends a single byte indicating whether the response is partial (contains dynamic holes) or not, as is the case on `canary`. This simplifies the logic on both the server and client, since the server only needs to distinguish between partial and non-partial responses, and the client derives all caching decisions from a single boolean. Responses without a recognized marker (e.g. regular navigation responses that don't go through the prerender path) are conservatively treated as partial. The head partial override in `writeDynamicRenderResponseIntoCache` is guarded by `process.env.__NEXT_CACHE_COMPONENTS` because the server's defensive `isPossiblyPartialHead` marking only applies during static generation with Cache Components enabled. Without Cache Components, the server already sends the correct `isHeadPartial` value.
When writing a server response into the segment cache, we should not assume that the returned tree matches the exact shape that was requested by the client. The server sends back a patch, which must be applied to the "base" tree (the one included in the request header) to reconstruct the actual shape of the data. Previously, the route tree was read from a pre-existing route cache entry, which could diverge from the response in cases like dynamic rewrites or when handling parallel routes. This rarely manifested as a bug, but was incorrect in principle. This refactors the cache write path so the route tree is always derived from the Flight data itself, guaranteeing it stays in sync with the response. For the static stage write path, the seed is computed from the static stage's own Flight data, since the static and dynamic stages may produce different trees. This is a step in an ongoing migration from FlightRouterState to RouteTree on the client. RouteTree is optimized for cache access (e.g. pre-computed vary paths), but many places still expect FlightRouterState, so both representations coexist for now. As a next step, normalizeFlightData and convertServerPatchToFullTree should be unified into a single pass, since they are now almost always called together.
4808cf1 to
cfc45eb
Compare
acdlite
approved these changes
Mar 4, 2026
sokra
pushed a commit
that referenced
this pull request
Mar 6, 2026
…ache (#90306) When a fully static page is loaded (via initial HTML or client-side navigation), the segments are now written into the segment cache so subsequent navigations can be served entirely from cache without server requests. Initial HTML: The RSC payload inlined in the HTML is written during hydration via `getStaleAt` + `writeStaticStageResponseIntoCache` with `isResponsePartial: false`, using `FetchStrategy.Full` since all segments are present. A `StaleTimeIterable` (`s` field) is included in the `InitialRSCPayload` during the Cache Components prerender path to provide the stale time. Navigation: The `isResponsePartial` flag from the prepended byte (stripped by `processFetch`) is now also used to determine how segments from `writeStaticStageResponseIntoCache` are cached. For fully static routes (`isResponsePartial: false`), segments are written as non-partial so no dynamic follow-up is needed. The route tree used for cache writes is now always derived from the Flight data itself (via `convertServerPatchToFullTree`), rather than from a pre-existing route cache entry. This guarantees the tree stays in sync with the response and avoids key mismatches between the static stage tree and the per-segment prefetch tree for parallel route slots. When writing the head into the cache, `isHeadPartial` from the server is overridden to `false` for non-partial responses, but only when Cache Components is enabled. This corrects the server's conservative `isPossiblyPartialHead` marking during static generation. Without Cache Components, the server already sends the correct value. Responses without a recognized marker byte (e.g. regular navigations that don't go through the prerender path) are conservatively treated as partial. Partially static pages (where the static stage needs to be extracted via byte-level truncation of the initial HTML Flight stream) will be handled in a follow-up. --------- Co-authored-by: Andrew Clark <git@andrewclark.io>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.

When a fully static page is loaded (via initial HTML or client-side navigation), the segments are now written into the segment cache so subsequent navigations can be served entirely from cache without server requests.
Initial HTML: The RSC payload inlined in the HTML is written during hydration via
getStaleAt+writeStaticStageResponseIntoCachewithisResponsePartial: false, usingFetchStrategy.Fullsince all segments are present. AStaleTimeIterable(sfield) is included in theInitialRSCPayloadduring the Cache Components prerender path to provide the stale time.Navigation: The
isResponsePartialflag from the prepended byte (stripped byprocessFetch) is now also used to determine how segments fromwriteStaticStageResponseIntoCacheare cached. For fully static routes (isResponsePartial: false), segments are written as non-partial so no dynamic follow-up is needed.The route tree used for cache writes is now always derived from the Flight data itself (via
convertServerPatchToFullTree), rather than from a pre-existing route cache entry. This guarantees the tree stays in sync with the response and avoids key mismatches between the static stage tree and the per-segment prefetch tree for parallel route slots.When writing the head into the cache,
isHeadPartialfrom the server is overridden tofalsefor non-partial responses, but only when Cache Components is enabled. This corrects the server's conservativeisPossiblyPartialHeadmarking during static generation. Without Cache Components, the server already sends the correct value. Responses without a recognized marker byte (e.g. regular navigations that don't go through the prerender path) are conservatively treated as partial.Partially static pages (where the static stage needs to be extracted via byte-level truncation of the initial HTML Flight stream) will be handled in a follow-up.