Cached Navigations: Cache visited fully static pages in the segment cache#90306
Cached Navigations: Cache visited fully static pages in the segment cache#90306unstubbable wants to merge 12 commits intocanaryfrom
Conversation
Failing test suitesCommit: 963e5dc | About building and testing Next.js
Expand output● mismatching prefetch › recovers when a navigation rewrites to a different route than the one that was prefetched
Expand output● segment cache (revalidation) › re-fetch visible links after a navigation, if needed
Expand output● segment cache - vary params › does not reuse prefetched segment when page accesses searchParams
Expand output● app dir - metadata static routes cache › should generate different content after replace the static metadata file
Expand output● app dir - prefetching › should show layout eagerly when prefetched with loading one level down ● app dir - prefetching › should immediately render the loading state for a dynamic segment when fetched from higher up in the tree |
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: **400 kB** → **400 kB**
|
| Canary | PR | Change | |
|---|---|---|---|
| middleware-b..fest.js gzip | 764 B | 766 B | ✓ |
| Total | 764 B | 766 B |
Build Details
Build Manifests
| Canary | PR | Change | |
|---|---|---|---|
| _buildManifest.js gzip | 451 B | 452 B | ✓ |
| Total | 451 B | 452 B |
📦 Webpack
Client
Main Bundles
| Canary | PR | Change | |
|---|---|---|---|
| 5528-HASH.js gzip | 5.54 kB | N/A | - |
| 6280-HASH.js gzip | 58.3 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 | 256 B | 254 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.4 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.5 kB | 2.5 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.97 kB | 7.97 kB | ✅ -2 B |
Server
Edge SSR
| Canary | PR | Change | |
|---|---|---|---|
| edge-ssr.js gzip | 125 kB | 125 kB | ✓ |
| page.js gzip | 254 kB | 255 kB | ✓ |
| Total | 379 kB | 380 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.8 kB | 43.7 kB | ✓ |
| edge-runtime..pack.js gzip | 842 B | 842 B | ✓ |
| Total | 45.4 kB | 45.3 kB | ✅ -93 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.02 MB | 4.03 MB | 🔴 +13.9 kB (+0%) |
| index.pack gzip | 103 kB | 103 kB | ✓ |
| index.pack.old gzip | 103 kB | 101 kB | 🟢 2.04 kB (-2%) |
| Total | 4.22 MB | 4.23 MB |
🔄 Shared (bundler-independent)
Runtimes
| Canary | PR | Change | |
|---|---|---|---|
| app-page-exp...dev.js gzip | 320 kB | 321 kB | ✓ |
| app-page-exp..prod.js gzip | 170 kB | 170 kB | ✓ |
| app-page-tur...dev.js gzip | 319 kB | 320 kB | ✓ |
| app-page-tur..prod.js gzip | 169 kB | 170 kB | ✓ |
| app-page-tur...dev.js gzip | 316 kB | 317 kB | ✓ |
| app-page-tur..prod.js gzip | 168 kB | 168 kB | ✓ |
| app-page.run...dev.js gzip | 316 kB | 317 kB | ✓ |
| app-page.run..prod.js gzip | 168 kB | 168 kB | ✓ |
| app-route-ex...dev.js gzip | 70.8 kB | 70.8 kB | ✓ |
| app-route-ex..prod.js gzip | 49.2 kB | 49.2 kB | ✓ |
| app-route-tu...dev.js gzip | 70.8 kB | 70.8 kB | ✓ |
| app-route-tu..prod.js gzip | 49.2 kB | 49.2 kB | ✓ |
| app-route-tu...dev.js gzip | 70.4 kB | 70.4 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.8 kB | 32.8 kB | ✓ |
| pages-turbo....dev.js gzip | 52.5 kB | 52.5 kB | ✓ |
| pages-turbo...prod.js gzip | 38.5 kB | 38.5 kB | ✓ |
| pages.runtim...dev.js gzip | 52.5 kB | 52.5 kB | ✓ |
| pages.runtim..prod.js gzip | 38.4 kB | 38.4 kB | ✓ |
| server.runti..prod.js gzip | 62 kB | 62 kB | ✓ |
| Total | 2.82 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
failed to diffapp-page.runtime.dev.js
failed to diffapp-page.runtime.prod.js
failed to diff📎 Tarball URL
next@https://vercel-packages.vercel.app/next/prs/90306/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
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
b11ed0b to
0d1cec2
Compare
0457b1d to
9a87eda
Compare
0d1cec2 to
e2ef94e
Compare
9a87eda to
59984fa
Compare
59984fa to
c0d147a
Compare
| * with Cache Components enabled). | ||
| */ | ||
| async function stripIsPartialByte( | ||
| export async function writeInitialSeedDataIntoCache( |
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.
c0d147a to
963e5dc
Compare

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, reusing the same cache write path as navigations. AStaleTimeIterable(sfield) is included in theInitialRSCPayloadduring the Cache Components prerender path to provide the stale time.Navigation: The server prepends a
ResponseCompletenessMarkerbyte to prerenderedflightData, extending the mechanism introduced for runtime prefetching in #90160. Three markers are now used:#(Static) for fully static prerenders,*(Complete) for complete runtime prefetches, and~(Partial) for partial responses with dynamic holes. The client strips it viaprocessFetchincreateFetch. The*marker prevents runtime prefetches of non-fully-static pages from incorrectly settingisFullyStatic.Segments from
writeStaticStageResponseIntoCacheare always written as partial, even for fully static routes. Writing them as non-partial would prevent refreshes and shared parallel route slots from being overwritten on subsequent navigations. Instead, theisFullyStaticflag is stored on the route cache entry (set only for#) and checked at read time (increateCacheNodeForSegment) to skip the dynamic follow-up request when cached segments are available.The runtime prefetch path (
fetchSegmentPrefetchesUsingDynamicRequest) now readscompletenessMarkerfromcacheDataonRSCResponseinstead of callingstripCompletenessMarkerseparately, since the marker has already been stripped byprocessFetchincreateFetch.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.