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/.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 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, ), )); 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/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 66d0e84bb6d42..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) +} - // Similarly, notify all invalidation listeners (i.e. those passed to - // `router.prefetch(onInvalidate)`), so they can trigger a new prefetch - // if needed. +/** + * 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++ + + 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, @@ -679,16 +707,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 +731,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 +764,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 +780,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, @@ -781,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 } @@ -824,8 +856,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 @@ -860,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 } @@ -926,10 +961,10 @@ function pingBlockedTasks(entry: { } function fulfillRouteCacheEntry( + now: number, entry: RouteCacheEntry, tree: RouteTree, metadataVaryPath: PageVaryPath, - staleAt: number, couldBeIntercepted: boolean, canonicalUrl: string, renderedSearch: NormalizedSearch, @@ -957,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 @@ -1583,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, @@ -1765,13 +1803,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 @@ -1953,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 @@ -1988,10 +2015,10 @@ function writeDynamicTreeResponseIntoCache( } const fulfilledEntry = fulfillRouteCacheEntry( + now, entry, routeTree, metadataVaryPath, - now + staleTimeMs, couldBeIntercepted, canonicalUrl, renderedSearch, @@ -2105,7 +2132,6 @@ function writeDynamicRenderResponseIntoCache( now, task, fetchStrategy, - route, tree, staleAt, seedData, @@ -2119,7 +2145,6 @@ function writeDynamicRenderResponseIntoCache( fulfillEntrySpawnedByRuntimePrefetch( now, fetchStrategy, - route, head, flightData.isHeadPartial, staleAt, @@ -2153,7 +2178,6 @@ function writeSeedDataIntoCache( | FetchStrategy.LoadingBoundary | FetchStrategy.PPRRuntime | FetchStrategy.Full, - route: FulfilledRouteCacheEntry, tree: RouteTree, staleAt: number, seedData: CacheNodeSeedData, @@ -2170,7 +2194,6 @@ function writeSeedDataIntoCache( fulfillEntrySpawnedByRuntimePrefetch( now, fetchStrategy, - route, rsc, isPartial, staleAt, @@ -2191,7 +2214,6 @@ function writeSeedDataIntoCache( now, task, fetchStrategy, - route, childTree, staleAt, childSeedData, @@ -2209,7 +2231,6 @@ function fulfillEntrySpawnedByRuntimePrefetch( | FetchStrategy.LoadingBoundary | FetchStrategy.PPRRuntime | FetchStrategy.Full, - route: FulfilledRouteCacheEntry, rsc: React.ReactNode, isPartial: boolean, staleAt: number, @@ -2233,7 +2254,6 @@ function fulfillEntrySpawnedByRuntimePrefetch( const possiblyNewEntry = readOrCreateSegmentCacheEntry( now, fetchStrategy, - route, tree ) if (possiblyNewEntry.status === EntryStatus.Empty) { @@ -2250,7 +2270,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..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) @@ -788,12 +796,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 +851,6 @@ function pingSharedPartOfCacheComponentsTree( const segment = readOrCreateSegmentCacheEntry( now, task.fetchStrategy, - route, newTree ) pingStaticSegmentData(now, task, route, segment, task.key, newTree) @@ -946,12 +948,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 +1143,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 +1254,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 +1292,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 +1306,7 @@ function pingRouteTreeAndIncludeDynamicData( fetchStrategy ) ) { - spawnedSegment = pingFullSegmentRevalidation( - now, - route, - tree, - fetchStrategy - ) + spawnedSegment = pingFullSegmentRevalidation(now, tree, fetchStrategy) } break } @@ -1514,7 +1495,6 @@ function pingPPRSegmentRevalidation( const revalidatingSegment = readOrCreateRevalidatingSegmentEntry( now, FetchStrategy.PPR, - route, tree ) switch (revalidatingSegment.status) { @@ -1549,14 +1529,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 +1564,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 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 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' )