From 1ce62f28d356bd010257464837193228cd15aaa9 Mon Sep 17 00:00:00 2001 From: Tim Neutkens Date: Wed, 28 Jan 2026 16:10:09 +0100 Subject: [PATCH 1/5] Turbopack: Remove unused argument (#80235) ## What? Found this argument was unused and only being passed around. --- crates/next-core/src/pages_structure.rs | 4 ---- 1 file changed, 4 deletions(-) diff --git a/crates/next-core/src/pages_structure.rs b/crates/next-core/src/pages_structure.rs index ab69a8b36575c..3c05755771fa1 100644 --- a/crates/next-core/src/pages_structure.rs +++ b/crates/next-core/src/pages_structure.rs @@ -209,7 +209,6 @@ async fn get_pages_structure_for_root_directory( get_pages_structure_for_directory( dir_project_path.clone(), next_router_path.join(name)?, - 1, page_extensions, ) .to_resolved() @@ -222,7 +221,6 @@ async fn get_pages_structure_for_root_directory( get_pages_structure_for_directory( dir_project_path.clone(), next_router_path.join(name)?, - 1, page_extensions, ), )); @@ -325,7 +323,6 @@ async fn get_pages_structure_for_root_directory( async fn get_pages_structure_for_directory( project_path: FileSystemPath, next_router_path: FileSystemPath, - position: u32, page_extensions: Vc>, ) -> Result> { let span = tracing::info_span!( @@ -368,7 +365,6 @@ async fn get_pages_structure_for_directory( get_pages_structure_for_directory( dir_project_path.clone(), next_router_path.join(name)?, - position + 1, page_extensions, ), )); From 3769252dd42f2f64f424a5e918419b9c91b67de7 Mon Sep 17 00:00:00 2001 From: Andrew Clark Date: Wed, 28 Jan 2026 10:55:14 -0500 Subject: [PATCH 2/5] Decouple route stale time from segment-level data (#88834) ## Summary Refactors segment cache entries to receive their stale time from the server response rather than inheriting from the parent route cache entry. This decouples the lifetime of segment data from the route tree, preparing for future optimizations. When I say "route" here, what I'm referring to is the mapping of URL -> layout/page hierarchy. Typically this is a predictable mapping based on the App Router's file-based routing conventions; however, it's possible to rewrite the URL to a different internal route path. Conceptually, the cache life of a route corresponds to how often a proxy is expected to dynamically rewrite or redirect a URL. We don't currently expose a way to directly control this, but if we did, you could think of it as being equivalent to adding `use cache` (and `cacheLife`, `cacheTag`, etc.) to the `proxy()` function. The point, though, is that the route tree has a different lifetime than data rendered on the page. Route trees change relatively infrequently, like due to a logged in/out state change, or a feature flag change. So we can/should cache them fairly aggressively by default. This is related to an upcoming change where we'll go even further and _predict_ routes we haven't visited yet based on structurally similar routes we've already seen. This PR itself, though, makes no behavioral changes; it just sets up the subsequent steps. ## Test Plan No behavioral changes - existing tests should pass. --- .../client/components/segment-cache/cache.ts | 41 +++++++++--------- .../components/segment-cache/scheduler.ts | 42 +++---------------- .../app-render/collect-segment-data.tsx | 24 +++++++++-- 3 files changed, 46 insertions(+), 61 deletions(-) diff --git a/packages/next/src/client/components/segment-cache/cache.ts b/packages/next/src/client/components/segment-cache/cache.ts index 66d0e84bb6d42..cfa1f3bac4298 100644 --- a/packages/next/src/client/components/segment-cache/cache.ts +++ b/packages/next/src/client/components/segment-cache/cache.ts @@ -679,16 +679,17 @@ function createOptimisticRouteTree( export function readOrCreateSegmentCacheEntry( now: number, fetchStrategy: FetchStrategy, - route: FulfilledRouteCacheEntry, tree: RouteTree ): SegmentCacheEntry { const existingEntry = readSegmentCacheEntry(now, tree.varyPath) if (existingEntry !== null) { return existingEntry } - // Create a pending entry and add it to the cache. + // Create a pending entry and add it to the cache. The stale time is set to a + // default value; the actual stale time will be set when the entry is + // fulfilled with data from the server response. const varyPathForRequest = getSegmentVaryPathForRequest(fetchStrategy, tree) - const pendingEntry = createDetachedSegmentCacheEntry(route.staleAt) + const pendingEntry = createDetachedSegmentCacheEntry(now) const isRevalidation = false setInCacheMap( segmentCacheMap, @@ -702,7 +703,6 @@ export function readOrCreateSegmentCacheEntry( export function readOrCreateRevalidatingSegmentEntry( now: number, fetchStrategy: FetchStrategy, - route: FulfilledRouteCacheEntry, tree: RouteTree ): SegmentCacheEntry { // This function is called when we've already confirmed that a particular @@ -736,9 +736,11 @@ export function readOrCreateRevalidatingSegmentEntry( if (existingEntry !== null) { return existingEntry } - // Create a pending entry and add it to the cache. + // Create a pending entry and add it to the cache. The stale time is set to a + // default value; the actual stale time will be set when the entry is + // fulfilled with data from the server response. const varyPathForRequest = getSegmentVaryPathForRequest(fetchStrategy, tree) - const pendingEntry = createDetachedSegmentCacheEntry(route.staleAt) + const pendingEntry = createDetachedSegmentCacheEntry(now) const isRevalidation = true setInCacheMap( segmentCacheMap, @@ -750,15 +752,17 @@ export function readOrCreateRevalidatingSegmentEntry( } export function overwriteRevalidatingSegmentCacheEntry( + now: number, fetchStrategy: FetchStrategy, - route: FulfilledRouteCacheEntry, tree: RouteTree ) { // This function is called when we've already decided to replace an existing // revalidation entry. Create a new entry and write it into the cache, - // overwriting the previous value. + // overwriting the previous value. The stale time is set to a default value; + // the actual stale time will be set when the entry is fulfilled with data + // from the server response. const varyPathForRequest = getSegmentVaryPathForRequest(fetchStrategy, tree) - const pendingEntry = createDetachedSegmentCacheEntry(route.staleAt) + const pendingEntry = createDetachedSegmentCacheEntry(now) const isRevalidation = true setInCacheMap( segmentCacheMap, @@ -824,8 +828,11 @@ export function upsertSegmentEntry( } export function createDetachedSegmentCacheEntry( - staleAt: number + now: number ): EmptySegmentCacheEntry { + // Default stale time for pending segment cache entries. The actual stale time + // is set when the entry is fulfilled with data from the server response. + const staleAt = now + 30 * 1000 const emptyEntry: EmptySegmentCacheEntry = { status: EntryStatus.Empty, // Default to assuming the fetch strategy will be PPR. This will be updated @@ -1765,13 +1772,12 @@ export async function fetchSegmentOnCacheMiss( rejectSegmentCacheEntry(segmentCacheEntry, Date.now() + 10 * 1000) return null } + const staleAt = Date.now() + getStaleTimeMs(serverData.staleTime) return { value: fulfillSegmentCacheEntry( segmentCacheEntry, serverData.rsc, - // TODO: The server does not currently provide per-segment stale time. - // So we use the stale time of the route. - route.staleAt, + staleAt, serverData.isPartial ), // Return a promise that resolves when the network connection closes, so @@ -2105,7 +2111,6 @@ function writeDynamicRenderResponseIntoCache( now, task, fetchStrategy, - route, tree, staleAt, seedData, @@ -2119,7 +2124,6 @@ function writeDynamicRenderResponseIntoCache( fulfillEntrySpawnedByRuntimePrefetch( now, fetchStrategy, - route, head, flightData.isHeadPartial, staleAt, @@ -2153,7 +2157,6 @@ function writeSeedDataIntoCache( | FetchStrategy.LoadingBoundary | FetchStrategy.PPRRuntime | FetchStrategy.Full, - route: FulfilledRouteCacheEntry, tree: RouteTree, staleAt: number, seedData: CacheNodeSeedData, @@ -2170,7 +2173,6 @@ function writeSeedDataIntoCache( fulfillEntrySpawnedByRuntimePrefetch( now, fetchStrategy, - route, rsc, isPartial, staleAt, @@ -2191,7 +2193,6 @@ function writeSeedDataIntoCache( now, task, fetchStrategy, - route, childTree, staleAt, childSeedData, @@ -2209,7 +2210,6 @@ function fulfillEntrySpawnedByRuntimePrefetch( | FetchStrategy.LoadingBoundary | FetchStrategy.PPRRuntime | FetchStrategy.Full, - route: FulfilledRouteCacheEntry, rsc: React.ReactNode, isPartial: boolean, staleAt: number, @@ -2233,7 +2233,6 @@ function fulfillEntrySpawnedByRuntimePrefetch( const possiblyNewEntry = readOrCreateSegmentCacheEntry( now, fetchStrategy, - route, tree ) if (possiblyNewEntry.status === EntryStatus.Empty) { @@ -2250,7 +2249,7 @@ function fulfillEntrySpawnedByRuntimePrefetch( // replace it with the new one from the server. const newEntry = fulfillSegmentCacheEntry( upgradeToPendingSegment( - createDetachedSegmentCacheEntry(staleAt), + createDetachedSegmentCacheEntry(now), fetchStrategy ), rsc, diff --git a/packages/next/src/client/components/segment-cache/scheduler.ts b/packages/next/src/client/components/segment-cache/scheduler.ts index 348d2a29c95a1..a269af4376cef 100644 --- a/packages/next/src/client/components/segment-cache/scheduler.ts +++ b/packages/next/src/client/components/segment-cache/scheduler.ts @@ -788,12 +788,7 @@ function pingStaticHead( now, task, route, - readOrCreateSegmentCacheEntry( - now, - FetchStrategy.PPR, - route, - route.metadata - ), + readOrCreateSegmentCacheEntry(now, FetchStrategy.PPR, route.metadata), task.key, route.metadata ) @@ -848,7 +843,6 @@ function pingSharedPartOfCacheComponentsTree( const segment = readOrCreateSegmentCacheEntry( now, task.fetchStrategy, - route, newTree ) pingStaticSegmentData(now, task, route, segment, task.key, newTree) @@ -946,12 +940,7 @@ function pingNewPartOfCacheComponentsTree( } // This segment should not be runtime prefetched. Prefetch its static data. - const segment = readOrCreateSegmentCacheEntry( - now, - task.fetchStrategy, - route, - tree - ) + const segment = readOrCreateSegmentCacheEntry(now, task.fetchStrategy, tree) pingStaticSegmentData(now, task, route, segment, task.key, tree) if (tree.slots !== null) { if (!hasNetworkBandwidth(task)) { @@ -1146,12 +1135,7 @@ function pingPPRDisabledRouteTreeUpToLoadingBoundary( let refetchMarker: 'refetch' | 'inside-shared-layout' | null = refetchMarkerContext === null ? 'inside-shared-layout' : null - const segment = readOrCreateSegmentCacheEntry( - now, - task.fetchStrategy, - route, - tree - ) + const segment = readOrCreateSegmentCacheEntry(now, task.fetchStrategy, tree) switch (segment.status) { case EntryStatus.Empty: { // This segment is not cached. Add a refetch marker so the server knows @@ -1262,7 +1246,6 @@ function pingRouteTreeAndIncludeDynamicData( // always use runtime prefetching (via `export const prefetch`), and those should check for // entries that include search params. fetchStrategy, - route, tree ) @@ -1301,12 +1284,7 @@ function pingRouteTreeAndIncludeDynamicData( // - we have a static prefetch, and we're doing a runtime prefetch // - we have a static or runtime prefetch, and we're doing a Full prefetch (or a navigation). // In either case, we need to include it in the request to get a more specific (or full) version. - spawnedSegment = pingFullSegmentRevalidation( - now, - route, - tree, - fetchStrategy - ) + spawnedSegment = pingFullSegmentRevalidation(now, tree, fetchStrategy) } break } @@ -1320,12 +1298,7 @@ function pingRouteTreeAndIncludeDynamicData( fetchStrategy ) ) { - spawnedSegment = pingFullSegmentRevalidation( - now, - route, - tree, - fetchStrategy - ) + spawnedSegment = pingFullSegmentRevalidation(now, tree, fetchStrategy) } break } @@ -1514,7 +1487,6 @@ function pingPPRSegmentRevalidation( const revalidatingSegment = readOrCreateRevalidatingSegmentEntry( now, FetchStrategy.PPR, - route, tree ) switch (revalidatingSegment.status) { @@ -1549,14 +1521,12 @@ function pingPPRSegmentRevalidation( function pingFullSegmentRevalidation( now: number, - route: FulfilledRouteCacheEntry, tree: RouteTree, fetchStrategy: FetchStrategy.Full | FetchStrategy.PPRRuntime ): PendingSegmentCacheEntry | null { const revalidatingSegment = readOrCreateRevalidatingSegmentEntry( now, fetchStrategy, - route, tree ) if (revalidatingSegment.status === EntryStatus.Empty) { @@ -1586,8 +1556,8 @@ function pingFullSegmentRevalidation( // The existing revalidation was fetched using a less specific strategy. // Reset it and start a new revalidation. const emptySegment = overwriteRevalidatingSegmentCacheEntry( + now, fetchStrategy, - route, tree ) const pendingSegment = upgradeToPendingSegment( diff --git a/packages/next/src/server/app-render/collect-segment-data.tsx b/packages/next/src/server/app-render/collect-segment-data.tsx index bec0d56055d4c..b9d46dc707864 100644 --- a/packages/next/src/server/app-render/collect-segment-data.tsx +++ b/packages/next/src/server/app-render/collect-segment-data.tsx @@ -80,6 +80,7 @@ export type SegmentPrefetch = { buildId: string rsc: React.ReactNode | null isPartial: boolean + staleTime: number } const filterStackFrame = @@ -241,6 +242,7 @@ async function PrefetchTreeData({ isClientParamParsingEnabled, flightRouterState, buildId, + staleTime, seedData, clientModules, ROOT_SEGMENT_REQUEST_KEY, @@ -253,7 +255,13 @@ async function PrefetchTreeData({ // the client cache. segmentTasks.push( waitAtLeastOneReactRenderTask().then(() => - renderSegmentPrefetch(buildId, head, HEAD_REQUEST_KEY, clientModules) + renderSegmentPrefetch( + buildId, + staleTime, + head, + HEAD_REQUEST_KEY, + clientModules + ) ) ) @@ -275,6 +283,7 @@ function collectSegmentDataImpl( isClientParamParsingEnabled: boolean, route: FlightRouterState, buildId: string, + staleTime: number, seedData: CacheNodeSeedData | null, clientModules: ManifestNode, requestKey: SegmentRequestKey, @@ -301,6 +310,7 @@ function collectSegmentDataImpl( isClientParamParsingEnabled, childRoute, buildId, + staleTime, childSeedData, clientModules, childRequestKey, @@ -320,7 +330,13 @@ function collectSegmentDataImpl( // Since we're already in the middle of a render, wait until after the // current task to escape the current rendering context. waitAtLeastOneReactRenderTask().then(() => - renderSegmentPrefetch(buildId, seedData[0], requestKey, clientModules) + renderSegmentPrefetch( + buildId, + staleTime, + seedData[0], + requestKey, + clientModules + ) ) ) } else { @@ -361,17 +377,17 @@ function collectSegmentDataImpl( async function renderSegmentPrefetch( buildId: string, + staleTime: number, rsc: React.ReactNode, requestKey: SegmentRequestKey, clientModules: ManifestNode ): Promise<[SegmentRequestKey, Buffer]> { // Render the segment data to a stream. - // In the future, this is where we can include additional metadata, like the - // stale time and cache tags. const segmentPrefetch: SegmentPrefetch = { buildId, rsc, isPartial: await isPartialRSCData(rsc, clientModules), + staleTime, } // Since all we're doing is decoding and re-encoding a cached prerender, if // it takes longer than a microtask, it must because of hanging promises From 244fac6eece025f8cd97cd9c4d62bf9ee8304a8f Mon Sep 17 00:00:00 2001 From: Andrew Clark Date: Wed, 28 Jan 2026 10:55:39 -0500 Subject: [PATCH 3/5] Decouple route and segment cache lifecycles (#88989) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Based on: - https://github.com/vercel/next.js/pull/88834 --- Follows from the previous commits that decoupled route stale time from segment data. Now that routes have their own lifecycle, we can simplify the implementation: route stale times are always the static constant, rather than being derived from server-provided values. Route structure is essentially static — it only changes on deploy. The exception is rewrites/redirects in middleware or proxy, which can change routes dynamically based on request state. But we have other strategies for handling those cases (e.g., route interception headers, cache invalidation on auth state changes). This simplification removes the need to thread stale time through various call sites. The `fulfillRouteCacheEntry` function now computes the stale time internally from a `now` timestamp parameter, following the convention used elsewhere in the codebase. --- .../reducers/refresh-reducer.ts | 11 +- .../reducers/server-action-reducer.ts | 15 ++- .../client/components/segment-cache/cache.ts | 117 +++++++++++------- .../components/segment-cache/scheduler.ts | 24 ++-- .../segment-cache/staleness/app/page.tsx | 16 +-- .../page.tsx | 4 +- .../page.tsx | 4 +- .../page.tsx | 4 +- .../page.tsx | 4 +- .../segment-cache-stale-time.test.ts | 84 ++++++------- 10 files changed, 161 insertions(+), 122 deletions(-) rename test/e2e/app-dir/segment-cache/staleness/app/{runtime-stale-5-minutes => runtime-stale-2-minutes}/page.tsx (89%) rename test/e2e/app-dir/segment-cache/staleness/app/{runtime-stale-10-minutes => runtime-stale-4-minutes}/page.tsx (89%) rename test/e2e/app-dir/segment-cache/staleness/app/{stale-5-minutes => stale-2-minutes}/page.tsx (78%) rename test/e2e/app-dir/segment-cache/staleness/app/{stale-10-minutes => stale-4-minutes}/page.tsx (78%) diff --git a/packages/next/src/client/components/router-reducer/reducers/refresh-reducer.ts b/packages/next/src/client/components/router-reducer/reducers/refresh-reducer.ts index 700513e4ed00b..f58c6fe6d3216 100644 --- a/packages/next/src/client/components/router-reducer/reducers/refresh-reducer.ts +++ b/packages/next/src/client/components/router-reducer/reducers/refresh-reducer.ts @@ -6,18 +6,19 @@ import { convertServerPatchToFullTree, navigateToKnownRoute, } from '../../segment-cache/navigation' -import { revalidateEntireCache } from '../../segment-cache/cache' +import { invalidateSegmentCacheEntries } from '../../segment-cache/cache' import { hasInterceptionRouteInCurrentTree } from './has-interception-route-in-current-tree' import { FreshnessPolicy } from '../ppr-navigations' import { invalidateBfCache } from '../../segment-cache/bfcache' export function refreshReducer(state: ReadonlyReducerState): ReducerState { - // TODO: Currently, all refreshes purge the prefetch cache. In the future, - // only client-side refreshes will have this behavior; the server-side - // `refresh` should send new data without purging the prefetch cache. + // During a refresh, we invalidate the segment cache but not the route cache. + // The route cache contains the tree structure (which segments exist at a + // given URL) which doesn't change during a refresh. The segment cache + // contains the actual RSC data which needs to be re-fetched. const currentNextUrl = state.nextUrl const currentRouterState = state.tree - revalidateEntireCache(currentNextUrl, currentRouterState) + invalidateSegmentCacheEntries(currentNextUrl, currentRouterState) return refreshDynamicData(state, FreshnessPolicy.RefreshAll) } diff --git a/packages/next/src/client/components/router-reducer/reducers/server-action-reducer.ts b/packages/next/src/client/components/router-reducer/reducers/server-action-reducer.ts index b2bcc01acd085..05899ea88a870 100644 --- a/packages/next/src/client/components/router-reducer/reducers/server-action-reducer.ts +++ b/packages/next/src/client/components/router-reducer/reducers/server-action-reducer.ts @@ -45,7 +45,8 @@ import { extractInfoFromServerReferenceId, omitUnusedArgs, } from '../../../../shared/lib/server-reference-info' -import { revalidateEntireCache } from '../../segment-cache/cache' +import { invalidateEntirePrefetchCache } from '../../segment-cache/cache' +import { startRevalidationCooldown } from '../../segment-cache/scheduler' import { getDeploymentId } from '../../../../shared/lib/deployment-id' import { completeHardNavigation, @@ -291,11 +292,19 @@ export function serverActionReducer( // (ie, due to a navigation, before the action completed) action.didRevalidate = true - // If there was a revalidation, evict the entire prefetch cache. + // If there was a revalidation, evict the prefetch cache. // TODO: Evict only segments with matching tags and/or paths. + // TODO: We should only invalidate the route cache if cookies were + // mutated, since route trees may vary based on cookies. For now we + // invalidate both caches until we have a way to detect cookie + // mutations on the client. if (revalidationKind === ActionDidRevalidateStaticAndDynamic) { - revalidateEntireCache(nextUrl, state.tree) + invalidateEntirePrefetchCache(nextUrl, state.tree) } + + // Start a cooldown before re-prefetching to allow CDN cache + // propagation. + startRevalidationCooldown() } const navigateType = redirectType || 'push' diff --git a/packages/next/src/client/components/segment-cache/cache.ts b/packages/next/src/client/components/segment-cache/cache.ts index cfa1f3bac4298..d0952d040e88f 100644 --- a/packages/next/src/client/components/segment-cache/cache.ts +++ b/packages/next/src/client/components/segment-cache/cache.ts @@ -29,7 +29,6 @@ import { isPrefetchTaskDirty, type PrefetchTask, type PrefetchSubtaskResult, - startRevalidationCooldown, } from './scheduler' import { type RouteVaryPath, @@ -298,40 +297,69 @@ let segmentCacheMap: CacheMap = createCacheMap() // prefetch task if desired. let invalidationListeners: Set | null = null -// Incrementing counter used to track cache invalidations. -let currentCacheVersion = 0 +// Incrementing counters used to track cache invalidations. Route and segment +// caches have separate versions so they can be invalidated independently. +// Invalidation does not eagerly evict anything from the cache; entries are +// lazily evicted when read. +let currentRouteCacheVersion = 0 +let currentSegmentCacheVersion = 0 -export function getCurrentCacheVersion(): number { - return currentCacheVersion +export function getCurrentRouteCacheVersion(): number { + return currentRouteCacheVersion +} + +export function getCurrentSegmentCacheVersion(): number { + return currentSegmentCacheVersion } /** - * Used to clear the client prefetch cache when a server action calls - * revalidatePath or revalidateTag. Eventually we will support only clearing the - * segments that were actually affected, but there's more work to be done on the - * server before the client is able to do this correctly. + * Invalidates all prefetch cache entries (both route and segment caches). + * + * After invalidation, triggers re-prefetching of visible links and notifies + * invalidation listeners. */ -export function revalidateEntireCache( +export function invalidateEntirePrefetchCache( nextUrl: string | null, tree: FlightRouterState -) { - // Increment the current cache version. This does not eagerly evict anything - // from the cache, but because all the entries are versioned, and we check - // the version when reading from the cache, this effectively causes all - // entries to be evicted lazily. We do it lazily because in the future, - // actions like revalidateTag or refresh will not evict the entire cache, - // but rather some subset of the entries. - currentCacheVersion++ - - // Start a cooldown before re-prefetching to allow CDN cache propagation. - startRevalidationCooldown() - - // Prefetch all the currently visible links again, to re-fill the cache. +): void { + currentRouteCacheVersion++ + currentSegmentCacheVersion++ + pingVisibleLinks(nextUrl, tree) + pingInvalidationListeners(nextUrl, tree) +} + +/** + * Invalidates all route cache entries. Route entries contain the tree structure + * (which segments exist at a given URL) but not the segment data itself. + * + * After invalidation, triggers re-prefetching of visible links and notifies + * invalidation listeners. + */ +export function invalidateRouteCacheEntries( + nextUrl: string | null, + tree: FlightRouterState +): void { + currentRouteCacheVersion++ + + pingVisibleLinks(nextUrl, tree) + pingInvalidationListeners(nextUrl, tree) +} + +/** + * Invalidates all segment cache entries. Segment entries contain the actual + * RSC data for each segment. + * + * After invalidation, triggers re-prefetching of visible links and notifies + * invalidation listeners. + */ +export function invalidateSegmentCacheEntries( + nextUrl: string | null, + tree: FlightRouterState +): void { + currentSegmentCacheVersion++ - // Similarly, notify all invalidation listeners (i.e. those passed to - // `router.prefetch(onInvalidate)`), so they can trigger a new prefetch - // if needed. + pingVisibleLinks(nextUrl, tree) pingInvalidationListeners(nextUrl, tree) } @@ -401,7 +429,7 @@ export function readRouteCacheEntry( const isRevalidation = false return getFromCacheMap( now, - getCurrentCacheVersion(), + getCurrentRouteCacheVersion(), routeCacheMap, varyPath, isRevalidation @@ -415,7 +443,7 @@ export function readSegmentCacheEntry( const isRevalidation = false return getFromCacheMap( now, - getCurrentCacheVersion(), + getCurrentSegmentCacheVersion(), segmentCacheMap, varyPath, isRevalidation @@ -429,7 +457,7 @@ function readRevalidatingSegmentCacheEntry( const isRevalidation = true return getFromCacheMap( now, - getCurrentCacheVersion(), + getCurrentSegmentCacheVersion(), segmentCacheMap, varyPath, isRevalidation @@ -487,7 +515,7 @@ export function readOrCreateRouteCacheEntry( // Since this is an empty entry, there's no reason to ever evict it. It will // be updated when the data is populated. staleAt: Infinity, - version: getCurrentCacheVersion(), + version: getCurrentRouteCacheVersion(), } const varyPath: RouteVaryPath = getRouteVaryPath( key.pathname, @@ -785,7 +813,7 @@ export function upsertSegmentEntry( // since the request was made. We can do that by passing the "owner" entry to // this function and confirming it's the same as `existingEntry`. - if (isValueExpired(now, getCurrentCacheVersion(), candidateEntry)) { + if (isValueExpired(now, getCurrentSegmentCacheVersion(), candidateEntry)) { // The entry is expired. We cannot upsert it. return null } @@ -867,11 +895,11 @@ export function upgradeToPendingSegment( } // Set the version here, since this is right before the request is initiated. - // The next time the global cache version is incremented, the entry will + // The next time the segment cache version is incremented, the entry will // effectively be evicted. This happens before initiating the request, rather // than when receiving the response, because it's guaranteed to happen // before the data is read on the server. - pendingEntry.version = getCurrentCacheVersion() + pendingEntry.version = getCurrentSegmentCacheVersion() return pendingEntry } @@ -933,10 +961,10 @@ function pingBlockedTasks(entry: { } function fulfillRouteCacheEntry( + now: number, entry: RouteCacheEntry, tree: RouteTree, metadataVaryPath: PageVaryPath, - staleAt: number, couldBeIntercepted: boolean, canonicalUrl: string, renderedSearch: NormalizedSearch, @@ -964,7 +992,11 @@ function fulfillRouteCacheEntry( fulfilledEntry.status = EntryStatus.Fulfilled fulfilledEntry.tree = tree fulfilledEntry.metadata = metadata - fulfilledEntry.staleAt = staleAt + // Route structure is essentially static — it only changes on deploy. + // Always use the static stale time. + // NOTE: An exception is rewrites/redirects in middleware or proxy, which can + // change routes dynamically. We have other strategies for handling those. + fulfilledEntry.staleAt = now + STATIC_STALETIME_MS fulfilledEntry.couldBeIntercepted = couldBeIntercepted fulfilledEntry.canonicalUrl = canonicalUrl fulfilledEntry.renderedSearch = renderedSearch @@ -1590,12 +1622,11 @@ export async function fetchRouteOnCacheMiss( return null } - const staleTimeMs = getStaleTimeMs(serverData.staleTime) fulfillRouteCacheEntry( + Date.now(), entry, routeTree, metadataVaryPath, - Date.now() + staleTimeMs, couldBeIntercepted, canonicalUrl, renderedSearch, @@ -1959,16 +1990,6 @@ function writeDynamicTreeResponseIntoCache( } const flightRouterState = flightData.tree - // For runtime prefetches, stale time is in the payload at rp[1]. - // For other responses, fall back to the header. - const staleTimeSeconds = - typeof serverData.rp?.[1] === 'number' - ? serverData.rp[1] - : parseInt(response.headers.get(NEXT_ROUTER_STALE_TIME_HEADER) ?? '', 10) - const staleTimeMs = !isNaN(staleTimeSeconds) - ? getStaleTimeMs(staleTimeSeconds) - : STATIC_STALETIME_MS - // If the response contains dynamic holes, then we must conservatively assume // that any individual segment might contain dynamic holes, and also the // head. If it did not contain dynamic holes, then we can assume every segment @@ -1994,10 +2015,10 @@ function writeDynamicTreeResponseIntoCache( } const fulfilledEntry = fulfillRouteCacheEntry( + now, entry, routeTree, metadataVaryPath, - now + staleTimeMs, couldBeIntercepted, canonicalUrl, renderedSearch, diff --git a/packages/next/src/client/components/segment-cache/scheduler.ts b/packages/next/src/client/components/segment-cache/scheduler.ts index a269af4376cef..94fe0d43953c2 100644 --- a/packages/next/src/client/components/segment-cache/scheduler.ts +++ b/packages/next/src/client/components/segment-cache/scheduler.ts @@ -35,7 +35,10 @@ import { type PrefetchTaskFetchStrategy, PrefetchPriority, } from './types' -import { getCurrentCacheVersion } from './cache' +import { + getCurrentRouteCacheVersion, + getCurrentSegmentCacheVersion, +} from './cache' import { addSearchParamsIfPageSegment, PAGE_SEGMENT_KEY, @@ -65,10 +68,13 @@ export type PrefetchTask = { treeAtTimeOfPrefetch: FlightRouterState /** - * The cache version at the time the task was initiated. This is used to - * determine if the cache was invalidated since the task was initiated. + * The cache versions at the time the task was initiated. Used to determine + * if the cache was invalidated since the task was initiated. Route and + * segment caches have separate versions so they can be invalidated + * independently. */ - cacheVersion: number + routeCacheVersion: number + segmentCacheVersion: number /** * Whether to prefetch dynamic data, in addition to static data. This is @@ -249,7 +255,8 @@ export function schedulePrefetchTask( const task: PrefetchTask = { key, treeAtTimeOfPrefetch, - cacheVersion: getCurrentCacheVersion(), + routeCacheVersion: getCurrentRouteCacheVersion(), + segmentCacheVersion: getCurrentSegmentCacheVersion(), priority, phase: PrefetchPhase.RouteTree, hasBackgroundWork: false, @@ -336,9 +343,9 @@ export function isPrefetchTaskDirty( // strictly an optimization — theoretically, if it always returned true, no // behavior should change because a full prefetch task will effectively // perform the same checks. - const currentCacheVersion = getCurrentCacheVersion() return ( - task.cacheVersion !== currentCacheVersion || + task.routeCacheVersion !== getCurrentRouteCacheVersion() || + task.segmentCacheVersion !== getCurrentSegmentCacheVersion() || task.treeAtTimeOfPrefetch !== tree || task.key.nextUrl !== nextUrl ) @@ -477,7 +484,8 @@ function processQueueInMicrotask() { // Process the task queue until we run out of network bandwidth. let task = heapPeek(taskHeap) while (task !== null && hasNetworkBandwidth(task)) { - task.cacheVersion = getCurrentCacheVersion() + task.routeCacheVersion = getCurrentRouteCacheVersion() + task.segmentCacheVersion = getCurrentSegmentCacheVersion() const exitStatus = pingRoute(now, task) diff --git a/test/e2e/app-dir/segment-cache/staleness/app/page.tsx b/test/e2e/app-dir/segment-cache/staleness/app/page.tsx index 617f16b7a2e1d..056b5868fa8cf 100644 --- a/test/e2e/app-dir/segment-cache/staleness/app/page.tsx +++ b/test/e2e/app-dir/segment-cache/staleness/app/page.tsx @@ -11,23 +11,23 @@ export default function Page() {

  • - - Page with stale time of 5 minutes + + Page with stale time of 2 minutes
  • - - Page with stale time of 10 minutes + + Page with stale time of 4 minutes
  • - - Page whose runtime prefetch has a stale time of 5 minutes + + Page whose runtime prefetch has a stale time of 2 minutes
  • - - Page whose runtime prefetch has a stale time of 10 minutes + + Page whose runtime prefetch has a stale time of 4 minutes
  • diff --git a/test/e2e/app-dir/segment-cache/staleness/app/runtime-stale-5-minutes/page.tsx b/test/e2e/app-dir/segment-cache/staleness/app/runtime-stale-2-minutes/page.tsx similarity index 89% rename from test/e2e/app-dir/segment-cache/staleness/app/runtime-stale-5-minutes/page.tsx rename to test/e2e/app-dir/segment-cache/staleness/app/runtime-stale-2-minutes/page.tsx index 7baec63e1cad3..763c125531b45 100644 --- a/test/e2e/app-dir/segment-cache/staleness/app/runtime-stale-5-minutes/page.tsx +++ b/test/e2e/app-dir/segment-cache/staleness/app/runtime-stale-2-minutes/page.tsx @@ -27,6 +27,6 @@ async function RuntimePrefetchable() { async function Content() { 'use cache' await new Promise((resolve) => setTimeout(resolve, 0)) - cacheLife({ stale: 5 * 60 }) - return 'Content with stale time of 5 minutes' + cacheLife({ stale: 2 * 60 }) + return 'Content with stale time of 2 minutes' } diff --git a/test/e2e/app-dir/segment-cache/staleness/app/runtime-stale-10-minutes/page.tsx b/test/e2e/app-dir/segment-cache/staleness/app/runtime-stale-4-minutes/page.tsx similarity index 89% rename from test/e2e/app-dir/segment-cache/staleness/app/runtime-stale-10-minutes/page.tsx rename to test/e2e/app-dir/segment-cache/staleness/app/runtime-stale-4-minutes/page.tsx index 948c6d19d6ec3..4639fbbaa66a1 100644 --- a/test/e2e/app-dir/segment-cache/staleness/app/runtime-stale-10-minutes/page.tsx +++ b/test/e2e/app-dir/segment-cache/staleness/app/runtime-stale-4-minutes/page.tsx @@ -27,6 +27,6 @@ async function RuntimePrefetchable() { async function Content() { 'use cache' await new Promise((resolve) => setTimeout(resolve, 0)) - cacheLife({ stale: 10 * 60 }) - return 'Content with stale time of 10 minutes' + cacheLife({ stale: 4 * 60 }) + return 'Content with stale time of 4 minutes' } diff --git a/test/e2e/app-dir/segment-cache/staleness/app/stale-5-minutes/page.tsx b/test/e2e/app-dir/segment-cache/staleness/app/stale-2-minutes/page.tsx similarity index 78% rename from test/e2e/app-dir/segment-cache/staleness/app/stale-5-minutes/page.tsx rename to test/e2e/app-dir/segment-cache/staleness/app/stale-2-minutes/page.tsx index 125bd86603dd2..01ad244f6cfbe 100644 --- a/test/e2e/app-dir/segment-cache/staleness/app/stale-5-minutes/page.tsx +++ b/test/e2e/app-dir/segment-cache/staleness/app/stale-2-minutes/page.tsx @@ -4,8 +4,8 @@ import { cacheLife } from 'next/cache' async function Content() { 'use cache' await new Promise((resolve) => setTimeout(resolve, 0)) - cacheLife({ stale: 5 * 60 }) - return 'Content with stale time of 5 minutes' + cacheLife({ stale: 2 * 60 }) + return 'Content with stale time of 2 minutes' } export default function Page() { diff --git a/test/e2e/app-dir/segment-cache/staleness/app/stale-10-minutes/page.tsx b/test/e2e/app-dir/segment-cache/staleness/app/stale-4-minutes/page.tsx similarity index 78% rename from test/e2e/app-dir/segment-cache/staleness/app/stale-10-minutes/page.tsx rename to test/e2e/app-dir/segment-cache/staleness/app/stale-4-minutes/page.tsx index be402b05bbe6b..ec279549c9907 100644 --- a/test/e2e/app-dir/segment-cache/staleness/app/stale-10-minutes/page.tsx +++ b/test/e2e/app-dir/segment-cache/staleness/app/stale-4-minutes/page.tsx @@ -4,8 +4,8 @@ import { cacheLife } from 'next/cache' async function Content() { 'use cache' await new Promise((resolve) => setTimeout(resolve, 0)) - cacheLife({ stale: 10 * 60 }) - return 'Content with stale time of 10 minutes' + cacheLife({ stale: 4 * 60 }) + return 'Content with stale time of 4 minutes' } export default function Page() { diff --git a/test/e2e/app-dir/segment-cache/staleness/segment-cache-stale-time.test.ts b/test/e2e/app-dir/segment-cache/staleness/segment-cache-stale-time.test.ts index d33614c7efea7..3f2b0ade53b48 100644 --- a/test/e2e/app-dir/segment-cache/staleness/segment-cache-stale-time.test.ts +++ b/test/e2e/app-dir/segment-cache/staleness/segment-cache-stale-time.test.ts @@ -23,57 +23,57 @@ describe('segment cache (staleness)', () => { await page.clock.install() // Reveal the link to trigger a prefetch - const toggle5MinutesLink = await browser.elementByCss( - 'input[data-link-accordion="/stale-5-minutes"]' + const toggle2MinutesLink = await browser.elementByCss( + 'input[data-link-accordion="/stale-2-minutes"]' ) - const toggle10MinutesLink = await browser.elementByCss( - 'input[data-link-accordion="/stale-10-minutes"]' + const toggle4MinutesLink = await browser.elementByCss( + 'input[data-link-accordion="/stale-4-minutes"]' ) await act( async () => { - await toggle5MinutesLink.click() - await browser.elementByCss('a[href="/stale-5-minutes"]') + await toggle2MinutesLink.click() + await browser.elementByCss('a[href="/stale-2-minutes"]') }, { - includes: 'Content with stale time of 5 minutes', + includes: 'Content with stale time of 2 minutes', } ) await act( async () => { - await toggle10MinutesLink.click() - await browser.elementByCss('a[href="/stale-10-minutes"]') + await toggle4MinutesLink.click() + await browser.elementByCss('a[href="/stale-4-minutes"]') }, { - includes: 'Content with stale time of 10 minutes', + includes: 'Content with stale time of 4 minutes', } ) // Hide the links - await toggle5MinutesLink.click() - await toggle10MinutesLink.click() + await toggle2MinutesLink.click() + await toggle4MinutesLink.click() - // Fast forward 5 minutes and 1 millisecond - await page.clock.fastForward(5 * 60 * 1000 + 1) + // Fast forward 2 minutes and 1 millisecond + await page.clock.fastForward(2 * 60 * 1000 + 1) // Reveal the links again to trigger new prefetch tasks await act( async () => { - await toggle5MinutesLink.click() - await browser.elementByCss('a[href="/stale-5-minutes"]') + await toggle2MinutesLink.click() + await browser.elementByCss('a[href="/stale-2-minutes"]') }, - // The page with a stale time of 5 minutes is requested again + // The page with a stale time of 2 minutes is requested again // because its stale time elapsed. { - includes: 'Content with stale time of 5 minutes', + includes: 'Content with stale time of 2 minutes', } ) await act( async () => { - await toggle10MinutesLink.click() - await browser.elementByCss('a[href="/stale-10-minutes"]') + await toggle4MinutesLink.click() + await browser.elementByCss('a[href="/stale-4-minutes"]') }, - // The page with a stale time of 10 minutes is *not* requested again + // The page with a stale time of 4 minutes is *not* requested again // because it's still fresh. 'no-requests' ) @@ -91,57 +91,57 @@ describe('segment cache (staleness)', () => { await page.clock.install() // Reveal the links to trigger a runtime prefetch - const toggle5MinutesLink = await browser.elementByCss( - 'input[data-link-accordion="/runtime-stale-5-minutes"]' + const toggle2MinutesLink = await browser.elementByCss( + 'input[data-link-accordion="/runtime-stale-2-minutes"]' ) - const toggle10MinutesLink = await browser.elementByCss( - 'input[data-link-accordion="/runtime-stale-10-minutes"]' + const toggle4MinutesLink = await browser.elementByCss( + 'input[data-link-accordion="/runtime-stale-4-minutes"]' ) await act( async () => { - await toggle5MinutesLink.click() - await browser.elementByCss('a[href="/runtime-stale-5-minutes"]') + await toggle2MinutesLink.click() + await browser.elementByCss('a[href="/runtime-stale-2-minutes"]') }, { - includes: 'Content with stale time of 5 minutes', + includes: 'Content with stale time of 2 minutes', } ) await act( async () => { - await toggle10MinutesLink.click() - await browser.elementByCss('a[href="/runtime-stale-10-minutes"]') + await toggle4MinutesLink.click() + await browser.elementByCss('a[href="/runtime-stale-4-minutes"]') }, { - includes: 'Content with stale time of 10 minutes', + includes: 'Content with stale time of 4 minutes', } ) // Hide the links - await toggle5MinutesLink.click() - await toggle10MinutesLink.click() + await toggle2MinutesLink.click() + await toggle4MinutesLink.click() - // Fast forward 5 minutes and 1 millisecond - await page.clock.fastForward(5 * 60 * 1000 + 1) + // Fast forward 2 minutes and 1 millisecond + await page.clock.fastForward(2 * 60 * 1000 + 1) // Reveal the links again to trigger new prefetch tasks await act( async () => { - await toggle5MinutesLink.click() - await browser.elementByCss('a[href="/runtime-stale-5-minutes"]') + await toggle2MinutesLink.click() + await browser.elementByCss('a[href="/runtime-stale-2-minutes"]') }, - // The page with a stale time of 5 minutes is requested again + // The page with a stale time of 2 minutes is requested again // because its stale time elapsed. { - includes: 'Content with stale time of 5 minutes', + includes: 'Content with stale time of 2 minutes', } ) await act( async () => { - await toggle10MinutesLink.click() - await browser.elementByCss('a[href="/runtime-stale-10-minutes"]') + await toggle4MinutesLink.click() + await browser.elementByCss('a[href="/runtime-stale-4-minutes"]') }, - // The page with a stale time of 10 minutes is *not* requested again + // The page with a stale time of 4 minutes is *not* requested again // because it's still fresh. 'no-requests' ) From 0aa41854a71366267d69ac2c78ea7ac9a9585033 Mon Sep 17 00:00:00 2001 From: Hendrik Liebau Date: Wed, 28 Jan 2026 17:31:28 +0100 Subject: [PATCH 4/5] Prettier-ignore changes in `next-env.d.ts` files in all top-level apps (#89176) Specifically, the `lint` job is currently failing in CI for `apps/bundle-analyzer/next-env.d.ts`, e.g.: https://github.com/vercel/next.js/actions/runs/21444929031/job/61758061793?pr=89175 --- .prettierignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.prettierignore b/.prettierignore index 198d0a004f3f6..de50a79a0b6ce 100644 --- a/.prettierignore +++ b/.prettierignore @@ -78,6 +78,7 @@ test/integration/typescript-app-type-declarations/next-env.strictRouteTypes.d.ts /turbopack/crates/turbopack-tracing/tests/node-file-trace/test/unit/tsx/lib.tsx /apps/docs/.source/* +/apps/*/next-env.d.ts # Symlink files readme.md From 11e295089c5759891b82168c2cf7153731704519 Mon Sep 17 00:00:00 2001 From: Hendrik Liebau Date: Wed, 28 Jan 2026 19:24:27 +0100 Subject: [PATCH 5/5] [ci] Silence `baseline-browser-mapping` warnings (#89175) The warnings from `baseline-browser-mapping` about outdated data fail some tests that don't expect the CLI output. By updating the dependency we avoid showing the warning for now, and by setting the environment variable `BASELINE_BROWSER_MAPPING_IGNORE_OLD_DATA` we should silence any future warnings as well. related: - #86625 - #86653 --- .github/workflows/build_reusable.yml | 4 ++++ packages/next/package.json | 2 +- pnpm-lock.yaml | 12 ++++++------ 3 files changed, 11 insertions(+), 7 deletions(-) diff --git a/.github/workflows/build_reusable.yml b/.github/workflows/build_reusable.yml index e873d550e3529..a45fb4ef75f36 100644 --- a/.github/workflows/build_reusable.yml +++ b/.github/workflows/build_reusable.yml @@ -118,6 +118,10 @@ env: # defaults to 256, but we run a lot of tests in parallel, so the limit should be lower NEXT_TURBOPACK_IO_CONCURRENCY: 64 + # Disable warnings from baseline-browser-mapping + # https://github.com/web-platform-dx/baseline-browser-mapping/blob/ec8136ae9e034b332fab991d63a340d2e13b8afc/README.md?plain=1#L34 + BASELINE_BROWSER_MAPPING_IGNORE_OLD_DATA: 1 + jobs: build: timeout-minutes: ${{ inputs.timeout_minutes }} diff --git a/packages/next/package.json b/packages/next/package.json index 860fac9c5fe30..1f0f6dd134e0b 100644 --- a/packages/next/package.json +++ b/packages/next/package.json @@ -99,7 +99,7 @@ "dependencies": { "@next/env": "16.2.0-canary.14", "@swc/helpers": "0.5.15", - "baseline-browser-mapping": "^2.8.3", + "baseline-browser-mapping": "^2.9.19", "caniuse-lite": "^1.0.30001579", "postcss": "8.4.31", "styled-jsx": "5.1.6" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e670db8c0f1f5..2d99652ac37b7 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1091,8 +1091,8 @@ importers: specifier: 0.5.15 version: 0.5.15 baseline-browser-mapping: - specifier: ^2.8.3 - version: 2.8.32 + specifier: ^2.9.19 + version: 2.9.19 caniuse-lite: specifier: 1.0.30001746 version: 1.0.30001746 @@ -7811,8 +7811,8 @@ packages: base64-js@1.5.1: resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==} - baseline-browser-mapping@2.8.32: - resolution: {integrity: sha512-OPz5aBThlyLFgxyhdwf/s2+8ab3OvT7AdTNvKHBwpXomIYeXqpUUuT8LrdtxZSsWJ4R4CU1un4XGh5Ez3nlTpw==} + baseline-browser-mapping@2.9.19: + resolution: {integrity: sha512-ipDqC8FrAl/76p2SSWKSI+H9tFwm7vYqXQrItCuiVPt26Km0jS+NzSsBWAaBusvSbQcfJG+JitdMm+wZAgTYqg==} hasBin: true batch@0.6.1: @@ -25413,7 +25413,7 @@ snapshots: base64-js@1.5.1: {} - baseline-browser-mapping@2.8.32: {} + baseline-browser-mapping@2.9.19: {} batch@0.6.1: {} @@ -25607,7 +25607,7 @@ snapshots: browserslist@4.28.0: dependencies: - baseline-browser-mapping: 2.8.32 + baseline-browser-mapping: 2.9.19 caniuse-lite: 1.0.30001746 electron-to-chromium: 1.5.262 node-releases: 2.0.27