From 5dd48e3598b1d70188e7af5828ea724aa3eb8203 Mon Sep 17 00:00:00 2001 From: Andrew Clark Date: Mon, 16 Mar 2026 15:38:17 -0400 Subject: [PATCH 1/8] Add `unstable_dynamicStaleTime` route segment config (#91437) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Background Next.js has an existing global config, `experimental.staleTimes.dynamic`, that controls how long the client-side router cache retains data from dynamic navigation responses. By default this is 0 — meaning dynamic data is always refetched on every navigation. Some users set this to a higher value (e.g. 30 seconds) so that navigating back to a recently visited page reuses the cached response instead of making a new request. The limitation of the global config is that it applies uniformly to every page in the app. Users have asked for the ability to set different stale times for different pages — for example, a dashboard page that should always show fresh data vs. a product listing page where it's acceptable to show slightly stale data for a smoother navigation experience. ## What this PR adds `export const unstable_dynamicStaleTime` is a per-page version of `staleTimes.dynamic`. It accepts a number in seconds with the same unit and semantics, but scoped to a single page. When present, it overrides the global config for that page — whether the per-page value is larger or smaller than the global default. Static and cached responses are unaffected. When parallel routes render multiple pages in a single response, the minimum value across all parallel slots wins, consistent with how stale times compose generally. Exporting this from a layout is a build error (pages only). ## Relationship to Cache Components The recommended way to control data freshness in Next.js is to use Cache Components (`"use cache"`) with `cacheLife`. This API exists as a simpler migration path for apps that are already using `staleTimes.dynamic` and haven't yet adopted Cache Components. It's prefixed with `unstable_` not because the implementation is unstable, but because it's not the recommended long-term idiom — apps should migrate to Cache Components for more granular and composable control over data freshness. Based on the design from #88609. Co-authored-by: Jimmy Lai Co-authored-by: Sam Selikoff --- packages/next/errors.json | 4 +- .../build/analysis/get-page-static-info.ts | 17 + .../segment-config/app/app-segment-config.ts | 19 + .../plugins/next-types-plugin/index.ts | 1 + .../create-initial-router-state.ts | 11 +- .../router-reducer/fetch-server-response.ts | 17 +- .../router-reducer/ppr-navigations.ts | 121 ++++-- .../reducers/refresh-reducer.ts | 13 +- .../reducers/restore-reducer.ts | 8 +- .../reducers/server-action-reducer.ts | 13 +- .../components/segment-cache/bfcache.ts | 70 +++- .../client/components/segment-cache/cache.ts | 66 ++-- .../components/segment-cache/navigation.ts | 14 +- .../next/src/server/app-render/app-render.tsx | 69 +++- .../app-render/create-component-tree.tsx | 42 +++ .../src/server/typescript/rules/config.ts | 11 + .../next/src/shared/lib/app-router-types.ts | 16 + .../per-page-config/dynamic-stale-10/page.tsx | 19 + .../per-page-config/dynamic-stale-60/page.tsx | 19 + .../staleness/app/per-page-config/layout.tsx | 3 + .../staleness/app/per-page-config/page.tsx | 30 ++ .../parallel-slots/@slotA/page.tsx | 17 + .../parallel-slots/@slotB/page.tsx | 17 + .../per-page-config/parallel-slots/layout.tsx | 17 + .../per-page-config/parallel-slots/page.tsx | 3 + ...-cache-per-page-dynamic-stale-time.test.ts | 352 ++++++++++++++++++ 26 files changed, 894 insertions(+), 95 deletions(-) create mode 100644 test/e2e/app-dir/segment-cache/staleness/app/per-page-config/dynamic-stale-10/page.tsx create mode 100644 test/e2e/app-dir/segment-cache/staleness/app/per-page-config/dynamic-stale-60/page.tsx create mode 100644 test/e2e/app-dir/segment-cache/staleness/app/per-page-config/layout.tsx create mode 100644 test/e2e/app-dir/segment-cache/staleness/app/per-page-config/page.tsx create mode 100644 test/e2e/app-dir/segment-cache/staleness/app/per-page-config/parallel-slots/@slotA/page.tsx create mode 100644 test/e2e/app-dir/segment-cache/staleness/app/per-page-config/parallel-slots/@slotB/page.tsx create mode 100644 test/e2e/app-dir/segment-cache/staleness/app/per-page-config/parallel-slots/layout.tsx create mode 100644 test/e2e/app-dir/segment-cache/staleness/app/per-page-config/parallel-slots/page.tsx create mode 100644 test/e2e/app-dir/segment-cache/staleness/segment-cache-per-page-dynamic-stale-time.test.ts diff --git a/packages/next/errors.json b/packages/next/errors.json index 0d5837e008f04b..f9afa9be58f531 100644 --- a/packages/next/errors.json +++ b/packages/next/errors.json @@ -1133,5 +1133,7 @@ "1132": "Route %s used \\`draftMode()\\` inside \\`generateStaticParams\\`. This is not supported because \\`generateStaticParams\\` runs at build time without an HTTP request. Read more: https://nextjs.org/docs/messages/next-dynamic-api-wrong-context", "1133": "createSearchParamsFromClient should not be called inside generateStaticParams.", "1134": "Route %s used \\`headers()\\` inside \\`generateStaticParams\\`. This is not supported because \\`generateStaticParams\\` runs at build time without an HTTP request. Read more: https://nextjs.org/docs/messages/next-dynamic-api-wrong-context", - "1135": "\"use cache\" cannot be used outside of App Router. Expected a WorkUnitStore." + "1135": "\"use cache\" cannot be used outside of App Router. Expected a WorkUnitStore.", + "1136": "Page \"%s\" cannot use both \\`export const unstable_dynamicStaleTime\\` and \\`export const unstable_instant\\`.", + "1137": "\"%s\" cannot use \\`export const unstable_dynamicStaleTime\\`. This config is only supported in page files, not layouts." } diff --git a/packages/next/src/build/analysis/get-page-static-info.ts b/packages/next/src/build/analysis/get-page-static-info.ts index d86e0e6510d7c6..67b1c9387b9420 100644 --- a/packages/next/src/build/analysis/get-page-static-info.ts +++ b/packages/next/src/build/analysis/get-page-static-info.ts @@ -691,6 +691,23 @@ export async function getAppPageStaticInfo({ ) } + // Prevent unstable_dynamicStaleTime in layouts. + if ('unstable_dynamicStaleTime' in config) { + const isLayout = /\/layout\.[^/]+$/.test(pageFilePath) + if (isLayout) { + throw new Error( + `"${page}" cannot use \`export const unstable_dynamicStaleTime\`. This config is only supported in page files, not layouts.` + ) + } + } + + // Prevent combining unstable_dynamicStaleTime and unstable_instant. + if ('unstable_dynamicStaleTime' in config && 'unstable_instant' in config) { + throw new Error( + `Page "${page}" cannot use both \`export const unstable_dynamicStaleTime\` and \`export const unstable_instant\`.` + ) + } + return { type: PAGE_TYPES.APP, rsc, diff --git a/packages/next/src/build/segment-config/app/app-segment-config.ts b/packages/next/src/build/segment-config/app/app-segment-config.ts index 84169de8961eef..6f8dde47af09df 100644 --- a/packages/next/src/build/segment-config/app/app-segment-config.ts +++ b/packages/next/src/build/segment-config/app/app-segment-config.ts @@ -145,6 +145,13 @@ const AppSegmentConfigSchema = z.object({ */ unstable_instant: InstantConfigSchema.optional(), + /** + * The stale time for dynamic responses in seconds. + * Controls how long the client-side router cache retains dynamic page data. + * Pages only — not allowed in layouts. + */ + unstable_dynamicStaleTime: z.number().int().nonnegative().optional(), + /** * The preferred region for the page. */ @@ -188,6 +195,11 @@ export function parseAppSegmentConfig( message: `Invalid unstable_instant value ${JSON.stringify(ctx.data)} on "${route}", must be an object with \`prefetch: "static"\` or \`prefetch: "runtime"\`, or \`false\`. Read more at https://nextjs.org/docs/messages/invalid-instant-configuration`, } } + case 'unstable_dynamicStaleTime': { + return { + message: `Invalid unstable_dynamicStaleTime value ${JSON.stringify(ctx.data)} on "${route}", must be a non-negative number`, + } + } default: } } @@ -242,6 +254,13 @@ export type AppSegmentConfig = { */ unstable_instant?: Instant + /** + * The stale time for dynamic responses in seconds. + * Controls how long the client-side router cache retains dynamic page data. + * Pages only — not allowed in layouts. + */ + unstable_dynamicStaleTime?: number + /** * The preferred region for the page. */ diff --git a/packages/next/src/build/webpack/plugins/next-types-plugin/index.ts b/packages/next/src/build/webpack/plugins/next-types-plugin/index.ts index 437a54038e24b8..66a8f15a3aab8c 100644 --- a/packages/next/src/build/webpack/plugins/next-types-plugin/index.ts +++ b/packages/next/src/build/webpack/plugins/next-types-plugin/index.ts @@ -72,6 +72,7 @@ checkFields | false dynamic?: 'auto' | 'force-dynamic' | 'error' | 'force-static' dynamicParams?: boolean diff --git a/packages/next/src/client/components/router-reducer/create-initial-router-state.ts b/packages/next/src/client/components/router-reducer/create-initial-router-state.ts index 93ff5e97ecca78..ffd29066412e0f 100644 --- a/packages/next/src/client/components/router-reducer/create-initial-router-state.ts +++ b/packages/next/src/client/components/router-reducer/create-initial-router-state.ts @@ -14,6 +14,10 @@ import { writeStaticStageResponseIntoCache, } from '../segment-cache/cache' import { FetchStrategy } from '../segment-cache/types' +import { + UnknownDynamicStaleTime, + computeDynamicStaleAt, +} from '../segment-cache/bfcache' import { decodeStaticStage } from './fetch-server-response' import { discoverKnownRoute } from '../segment-cache/optimistic-routes' import type { NormalizedSearch } from '../segment-cache/cache-key' @@ -41,6 +45,7 @@ export function createInitialRouterState({ l: initialStaticStageByteLength, h: initialHeadVaryParams, p: initialRuntimePrefetchStream, + d: initialDynamicStaleTimeSeconds, } = initialRSCPayload // When initialized on the server, the canonical URL is provided as an array of parts. @@ -87,7 +92,11 @@ export function createInitialRouterState({ navigatedAt, initialRouteTree, initialSeedData, - initialHead + initialHead, + computeDynamicStaleAt( + navigatedAt, + initialDynamicStaleTimeSeconds ?? UnknownDynamicStaleTime + ) ) // The following only applies in the browser (location !== null) since neither diff --git a/packages/next/src/client/components/router-reducer/fetch-server-response.ts b/packages/next/src/client/components/router-reducer/fetch-server-response.ts index 99dc181e94965d..411cc61c5c3d73 100644 --- a/packages/next/src/client/components/router-reducer/fetch-server-response.ts +++ b/packages/next/src/client/components/router-reducer/fetch-server-response.ts @@ -25,7 +25,6 @@ import { RSC_CONTENT_TYPE_HEADER, NEXT_HMR_REFRESH_HEADER, NEXT_DID_POSTPONE_HEADER, - NEXT_ROUTER_STALE_TIME_HEADER, NEXT_HTML_REQUEST_ID_HEADER, NEXT_REQUEST_ID_HEADER, } from '../app-router-headers' @@ -43,6 +42,7 @@ import { getDeploymentId } from '../../../shared/lib/deployment-id' import { getNavigationBuildId } from '../../navigation-build-id' import { NEXT_NAV_DEPLOYMENT_ID_HEADER } from '../../../lib/constants' import { stripIsPartialByte } from '../segment-cache/cache' +import { UnknownDynamicStaleTime } from '../segment-cache/bfcache' const createFromReadableStream = createFromReadableStreamBrowser as (typeof import('react-server-dom-webpack/client.browser'))['createFromReadableStream'] @@ -81,7 +81,7 @@ type SpaFetchServerResponseResult = { couldBeIntercepted: boolean supportsPerSegmentPrefetching: boolean postponed: boolean - staleTime: number + dynamicStaleTime: number staticStageData: StaticStageData | null runtimePrefetchStream: ReadableStream | null responseHeaders: Headers @@ -196,13 +196,6 @@ export async function fetchServerResponse( const contentType = res.headers.get('content-type') || '' const interception = !!res.headers.get('vary')?.includes(NEXT_URL) const postponed = !!res.headers.get(NEXT_DID_POSTPONE_HEADER) - const staleTimeHeaderSeconds = res.headers.get( - NEXT_ROUTER_STALE_TIME_HEADER - ) - const staleTime = - staleTimeHeaderSeconds !== null - ? parseInt(staleTimeHeaderSeconds, 10) * 1000 - : -1 let isFlightResponse = contentType.startsWith(RSC_CONTENT_TYPE_HEADER) if (process.env.NODE_ENV === 'production') { @@ -289,7 +282,11 @@ export async function fetchServerResponse( couldBeIntercepted: interception, supportsPerSegmentPrefetching: flightResponse.S, postponed, - staleTime, + // The dynamicStaleTime is only present in the response body when + // a page exports unstable_dynamicStaleTime and this is a dynamic render. + // When absent (UnknownDynamicStaleTime), the client falls back to the + // global DYNAMIC_STALETIME_MS. The value is in seconds. + dynamicStaleTime: flightResponse.d ?? UnknownDynamicStaleTime, staticStageData, runtimePrefetchStream: flightResponse.p ?? null, responseHeaders: res.headers, diff --git a/packages/next/src/client/components/router-reducer/ppr-navigations.ts b/packages/next/src/client/components/router-reducer/ppr-navigations.ts index 25c58e670d2cf9..c58235bee6167d 100644 --- a/packages/next/src/client/components/router-reducer/ppr-navigations.ts +++ b/packages/next/src/client/components/router-reducer/ppr-navigations.ts @@ -53,8 +53,9 @@ import { readFromBFCacheDuringRegularNavigation, writeToBFCache, writeHeadToBFCache, + updateBFCacheEntryStaleAt, + computeDynamicStaleAt, } from '../segment-cache/bfcache' -import { DYNAMIC_STALETIME_MS } from './reducers/navigate-reducer' // This is yet another tree type that is used to track pending promises that // need to be fulfilled once the dynamic data is received. The terminal nodes of @@ -132,7 +133,8 @@ export function createInitialCacheNodeForHydration( navigatedAt: number, initialTree: RouteTree, seedData: CacheNodeSeedData | null, - seedHead: HeadData + seedHead: HeadData, + seedDynamicStaleAt: number ): NavigationTask { // Create the initial cache node tree, using the data embedded into the // HTML document. @@ -147,6 +149,7 @@ export function createInitialCacheNodeForHydration( FreshnessPolicy.Hydration, seedData, seedHead, + seedDynamicStaleAt, false, accumulation ) @@ -193,6 +196,7 @@ export function startPPRNavigation( freshness: FreshnessPolicy, seedData: CacheNodeSeedData | null, seedHead: HeadData | null, + seedDynamicStaleAt: number, isSamePageNavigation: boolean, accumulation: NavigationRequestAccumulation ): NavigationTask | null { @@ -214,6 +218,7 @@ export function startPPRNavigation( didFindRootLayout, seedData, seedHead, + seedDynamicStaleAt, isSamePageNavigation, parentNeedsDynamicRequest, oldRootRefreshState, @@ -233,6 +238,7 @@ function updateCacheNodeOnNavigation( didFindRootLayout: boolean, seedData: CacheNodeSeedData | null, seedHead: HeadData | null, + seedDynamicStaleAt: number, isSamePageNavigation: boolean, parentNeedsDynamicRequest: boolean, oldRootRefreshState: RefreshState, @@ -290,6 +296,7 @@ function updateCacheNodeOnNavigation( freshness, seedData, seedHead, + seedDynamicStaleAt, parentNeedsDynamicRequest, accumulation ) @@ -356,7 +363,8 @@ function updateCacheNodeOnNavigation( seedRsc, newMetadataVaryPath, seedHead, - freshness + freshness, + seedDynamicStaleAt ) newCacheNode = result.cacheNode needsDynamicRequest = result.needsDynamicRequest @@ -486,6 +494,7 @@ function updateCacheNodeOnNavigation( childDidFindRootLayout, seedDataChild ?? null, seedHeadChild, + seedDynamicStaleAt, isSamePageNavigation, parentNeedsDynamicRequest || needsDynamicRequest, oldRootRefreshState, @@ -600,6 +609,7 @@ function createCacheNodeOnNavigation( freshness: FreshnessPolicy, seedData: CacheNodeSeedData | null, seedHead: HeadData | null, + seedDynamicStaleAt: number, parentNeedsDynamicRequest: boolean, accumulation: NavigationRequestAccumulation ): NavigationTask { @@ -625,7 +635,8 @@ function createCacheNodeOnNavigation( seedRsc, newMetadataVaryPath, seedHead, - freshness + freshness, + seedDynamicStaleAt ) const newCacheNode = result.cacheNode const needsDynamicRequest = result.needsDynamicRequest @@ -661,6 +672,7 @@ function createCacheNodeOnNavigation( freshness, seedDataChild ?? null, seedHead, + seedDynamicStaleAt, parentNeedsDynamicRequest || needsDynamicRequest, accumulation ) @@ -882,7 +894,8 @@ function createCacheNodeForSegment( seedRsc: React.ReactNode | null, metadataVaryPath: PageVaryPath | null, seedHead: HeadData | null, - freshness: FreshnessPolicy + freshness: FreshnessPolicy, + dynamicStaleAt: number ): { cacheNode: CacheNode; needsDynamicRequest: boolean } { // Construct a new CacheNode using data from the BFCache, the client's // Segment Cache, or seeded from a server response. @@ -905,29 +918,23 @@ function createCacheNodeForSegment( // the BFCache. switch (freshness) { case FreshnessPolicy.Default: { - // When experimental.staleTimes.dynamic config is set, we read from the - // BFCache even during regular navigations. (This is not a recommended API - // with Cache Components, but it's supported for backwards compatibility. - // Use cacheLife instead.) - - // This outer check isn't semantically necessary; even if the configured - // stale time is 0, the bfcache will return null, because any entry would - // have immediately expired. Just an optimization. - if (DYNAMIC_STALETIME_MS > 0) { - const bfcacheEntry = readFromBFCacheDuringRegularNavigation( - now, - tree.varyPath - ) - if (bfcacheEntry !== null) { - return { - cacheNode: createCacheNode( - bfcacheEntry.rsc, - bfcacheEntry.prefetchRsc, - bfcacheEntry.head, - bfcacheEntry.prefetchHead - ), - needsDynamicRequest: false, - } + // Check BFCache during regular navigations. The entry's staleAt + // determines whether it's still fresh. This is used when + // staleTimes.dynamic is configured globally or when a page exports + // unstable_dynamicStaleTime for per-page control. + const bfcacheEntry = readFromBFCacheDuringRegularNavigation( + now, + tree.varyPath + ) + if (bfcacheEntry !== null) { + return { + cacheNode: createCacheNode( + bfcacheEntry.rsc, + bfcacheEntry.prefetchRsc, + bfcacheEntry.head, + bfcacheEntry.prefetchHead + ), + needsDynamicRequest: false, } } break @@ -953,9 +960,23 @@ function createCacheNodeForSegment( const prefetchRsc = null const head = isPage ? seedHead : null const prefetchHead = null - writeToBFCache(now, tree.varyPath, rsc, prefetchRsc, head, prefetchHead) + writeToBFCache( + now, + tree.varyPath, + rsc, + prefetchRsc, + head, + prefetchHead, + dynamicStaleAt + ) if (isPage && metadataVaryPath !== null) { - writeHeadToBFCache(now, metadataVaryPath, head, prefetchHead) + writeHeadToBFCache( + now, + metadataVaryPath, + head, + prefetchHead, + dynamicStaleAt + ) } return { cacheNode: createCacheNode(rsc, prefetchRsc, head, prefetchHead), @@ -1180,9 +1201,23 @@ function createCacheNodeForSegment( // Skip BFCache writes for optimistic navigations since they are transient // and will be replaced by the canonical navigation. if (freshness !== FreshnessPolicy.Gesture) { - writeToBFCache(now, tree.varyPath, rsc, prefetchRsc, head, prefetchHead) + writeToBFCache( + now, + tree.varyPath, + rsc, + prefetchRsc, + head, + prefetchHead, + dynamicStaleAt + ) if (isPage && metadataVaryPath !== null) { - writeHeadToBFCache(now, metadataVaryPath, head, prefetchHead) + writeHeadToBFCache( + now, + metadataVaryPath, + head, + prefetchHead, + dynamicStaleAt + ) } } @@ -1583,10 +1618,14 @@ async function fetchMissingDynamicData( seed: null, } } + const now = Date.now() + const seed = convertServerPatchToFullTree( + now, task.route, result.flightData, - result.renderedSearch + result.renderedSearch, + result.dynamicStaleTime ) // If the navigation lock is active, wait for it to be released before @@ -1596,8 +1635,6 @@ async function fetchMissingDynamicData( await waitForNavigationLock() } - const now = Date.now() - if (routeCacheEntry !== null && result.staticStageData !== null) { const { response: staticStageResponse, isResponsePartial } = result.staticStageData @@ -1653,11 +1690,16 @@ async function fetchMissingDynamicData( }) } + // result.dynamicStaleTime is in seconds (from the server's `d` field). + // Convert to an absolute timestamp using the centralized helper. + const dynamicStaleAt = computeDynamicStaleAt(now, result.dynamicStaleTime) + const didReceiveUnknownParallelRoute = writeDynamicDataIntoNavigationTask( task, seed.routeTree, seed.data, seed.head, + dynamicStaleAt, result.debugInfo ) @@ -1685,11 +1727,19 @@ function writeDynamicDataIntoNavigationTask( serverRouteTree: RouteTree, dynamicData: CacheNodeSeedData | null, dynamicHead: HeadData, + dynamicStaleAt: number, debugInfo: Array | null ): boolean { if (task.status === NavigationTaskStatus.Pending && dynamicData !== null) { task.status = NavigationTaskStatus.Fulfilled finishPendingCacheNode(task.node, dynamicData, dynamicHead, debugInfo) + + // Update the BFCache entry's staleAt for this segment with the value + // from the dynamic response. This applies the per-page + // unstable_dynamicStaleTime if set, or the default DYNAMIC_STALETIME_MS. + // We only update segments that received dynamic data — static segments + // are unaffected. + updateBFCacheEntryStaleAt(serverRouteTree.varyPath, dynamicStaleAt) } const taskChildren = task.children @@ -1740,6 +1790,7 @@ function writeDynamicDataIntoNavigationTask( serverRouteTreeChild, dynamicDataChild, dynamicHead, + dynamicStaleAt, debugInfo ) if (childDidReceiveUnknownParallelRoute) { 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 b742da8f636921..8256d91f67803b 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 @@ -11,7 +11,10 @@ import { 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' +import { + invalidateBfCache, + UnknownDynamicStaleTime, +} from '../../segment-cache/bfcache' export function refreshReducer( state: ReadonlyReducerState, @@ -63,13 +66,17 @@ export function refreshDynamicData( // TODO: Eventually we will store this type directly on the state object // instead of reconstructing it on demand. Part of a larger series of // refactors to unify the various tree types that the client deals with. + const now = Date.now() + // TODO: Store the dynamic stale time on the top-level state so it's known + // during restores and refreshes. const refreshSeed = convertServerPatchToFullTree( + now, currentFlightRouterState, null, - currentRenderedSearch + currentRenderedSearch, + UnknownDynamicStaleTime ) - const now = Date.now() const navigateType = 'replace' return navigateToKnownRoute( now, diff --git a/packages/next/src/client/components/router-reducer/reducers/restore-reducer.ts b/packages/next/src/client/components/router-reducer/reducers/restore-reducer.ts index bc53333c10b487..b2b0ae03a4f671 100644 --- a/packages/next/src/client/components/router-reducer/reducers/restore-reducer.ts +++ b/packages/next/src/client/components/router-reducer/reducers/restore-reducer.ts @@ -16,6 +16,7 @@ import { completeTraverseNavigation, convertServerPatchToFullTree, } from '../../segment-cache/navigation' +import { UnknownDynamicStaleTime } from '../../segment-cache/bfcache' export function restoreReducer( state: ReadonlyReducerState, @@ -44,14 +45,18 @@ export function restoreReducer( extractPathFromFlightRouterState(treeToRestore) ?? restoredUrl.pathname const now = Date.now() + // TODO: Store the dynamic stale time on the top-level state so it's known + // during restores and refreshes. const accumulation: NavigationRequestAccumulation = { separateRefreshUrls: null, scrollRef: null, } const restoreSeed = convertServerPatchToFullTree( + now, treeToRestore, null, - renderedSearch + renderedSearch, + UnknownDynamicStaleTime ) const task = startPPRNavigation( now, @@ -64,6 +69,7 @@ export function restoreReducer( FreshnessPolicy.HistoryTraversal, null, null, + restoreSeed.dynamicStaleAt, false, accumulation ) 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 77dfea089bb9e5..6bcbc7cd38b681 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 @@ -68,7 +68,10 @@ import { import { isExternalURL } from '../../app-router-utils' import { FreshnessPolicy } from '../ppr-navigations' import { processFetch } from '../fetch-server-response' -import { invalidateBfCache } from '../../segment-cache/bfcache' +import { + invalidateBfCache, + UnknownDynamicStaleTime, +} from '../../segment-cache/bfcache' const createFromFetch = createFromFetchBrowser as (typeof import('react-server-dom-webpack/client.browser'))['createFromFetch'] @@ -437,12 +440,16 @@ export function serverActionReducer( // subset of the data needed to render the new page, we'll initiate a // new fetch, like we would for a normal navigation. const redirectCanonicalUrl = createHrefFromUrl(redirectUrl) + const now = Date.now() + // TODO: Store the dynamic stale time on the top-level state so it's + // known during restores and refreshes. const redirectSeed = convertServerPatchToFullTree( + now, currentFlightRouterState, flightData, - flightDataRenderedSearch + flightDataRenderedSearch, + UnknownDynamicStaleTime ) - const now = Date.now() // Learn the route pattern so we can predict it for future navigations. const metadataVaryPath = redirectSeed.metadataVaryPath diff --git a/packages/next/src/client/components/segment-cache/bfcache.ts b/packages/next/src/client/components/segment-cache/bfcache.ts index 80073942ed4d24..4daa8972835964 100644 --- a/packages/next/src/client/components/segment-cache/bfcache.ts +++ b/packages/next/src/client/components/segment-cache/bfcache.ts @@ -1,4 +1,25 @@ +import { DYNAMIC_STALETIME_MS } from '../router-reducer/reducers/navigate-reducer' import type { SegmentVaryPath } from './vary-path' + +/** + * Sentinel value indicating that no per-page dynamic stale time was provided. + * When this is the dynamicStaleTime, the default DYNAMIC_STALETIME_MS is used. + */ +export const UnknownDynamicStaleTime = -1 + +/** + * Converts a dynamic stale time (in seconds, as sent by the server in the `d` + * field of the Flight response) to an absolute staleAt timestamp. When the + * value is unknown, falls back to the global DYNAMIC_STALETIME_MS. + */ +export function computeDynamicStaleAt( + now: number, + dynamicStaleTimeSeconds: number +): number { + return dynamicStaleTimeSeconds !== UnknownDynamicStaleTime + ? now + dynamicStaleTimeSeconds * 1000 + : now + DYNAMIC_STALETIME_MS +} import { setInCacheMap, getFromCacheMap, @@ -6,7 +27,6 @@ import { type CacheMap, createCacheMap, } from './cache-map' -import { DYNAMIC_STALETIME_MS } from '../router-reducer/reducers/navigate-reducer' export type BFCacheEntry = { rsc: React.ReactNode | null @@ -16,6 +36,12 @@ export type BFCacheEntry = { ref: UnknownMapEntry | null size: number + // The time at which this data was received. Used to compute the stale time + // for dynamic prefetches (which use STATIC_STALETIME_MS instead of + // DYNAMIC_STALETIME_MS). Stored explicitly because staleAt may be + // overridden by a per-page unstable_dynamicStaleTime, which would break + // any reverse calculation from staleAt. + navigatedAt: number staleAt: number version: number } @@ -37,7 +63,8 @@ export function writeToBFCache( rsc: React.ReactNode, prefetchRsc: React.ReactNode, head: React.ReactNode, - prefetchHead: React.ReactNode + prefetchHead: React.ReactNode, + dynamicStaleAt: number ): void { if (typeof window === 'undefined') { return @@ -60,9 +87,12 @@ export function writeToBFCache( // entirely and use memory pressure events instead. size: 100, + navigatedAt: now, + // A back/forward navigation will disregard the stale time. This field is - // only relevant when staleTimes.dynamic is enabled. - staleAt: now + DYNAMIC_STALETIME_MS, + // only relevant when staleTimes.dynamic is enabled or unstable_dynamicStaleTime + // is exported by a page. + staleAt: dynamicStaleAt, version: currentBfCacheVersion, } const isRevalidation = false @@ -73,10 +103,38 @@ export function writeHeadToBFCache( now: number, varyPath: SegmentVaryPath, head: React.ReactNode, - prefetchHead: React.ReactNode + prefetchHead: React.ReactNode, + dynamicStaleAt: number ): void { // Read the special "segment" that represents the head data. - writeToBFCache(now, varyPath, head, prefetchHead, null, null) + writeToBFCache(now, varyPath, head, prefetchHead, null, null, dynamicStaleAt) +} + +/** + * Update the staleAt of an existing BFCache entry. Used after a dynamic + * response arrives with a per-page stale time from `unstable_dynamicStaleTime`. + * The per-page value is authoritative — it overrides whatever staleAt was set + * by the default DYNAMIC_STALETIME_MS. + */ +export function updateBFCacheEntryStaleAt( + varyPath: SegmentVaryPath, + newStaleAt: number +): void { + if (typeof window === 'undefined') { + return + } + const isRevalidation = false + // Read with staleness bypass (-1) so we can update even stale entries + const entry = getFromCacheMap( + -1, + currentBfCacheVersion, + bfcacheMap, + varyPath, + isRevalidation + ) + if (entry !== null) { + entry.staleAt = newStaleAt + } } export function readFromBFCache( diff --git a/packages/next/src/client/components/segment-cache/cache.ts b/packages/next/src/client/components/segment-cache/cache.ts index 2a7f39697a4b34..5c52bec3921eb2 100644 --- a/packages/next/src/client/components/segment-cache/cache.ts +++ b/packages/next/src/client/components/segment-cache/cache.ts @@ -99,15 +99,12 @@ import { normalizeFlightData, prepareFlightRouterStateForRequest, } from '../../flight-data-helpers' -import { - DYNAMIC_STALETIME_MS, - STATIC_STALETIME_MS, -} from '../router-reducer/reducers/navigate-reducer' +import { STATIC_STALETIME_MS } from '../router-reducer/reducers/navigate-reducer' import { pingVisibleLinks } from '../links' import { PAGE_SEGMENT_KEY } from '../../../shared/lib/segment' import { FetchStrategy } from './types' import { createPromiseWithResolvers } from '../../../shared/lib/promise-with-resolvers' -import { readFromBFCacheDuringRegularNavigation } from './bfcache' +import { readFromBFCache, UnknownDynamicStaleTime } from './bfcache' import { discoverKnownRoute, matchKnownRoute } from './optimistic-routes' import { convertServerPatchToFullTree, type NavigationSeed } from './navigation' import { getNavigationBuildId } from '../../navigation-build-id' @@ -951,23 +948,20 @@ export function attemptToFulfillDynamicSegmentFromBFCache( // regular navigation. const varyPath = tree.varyPath - // The stale time for dynamic prefetches (default: 5 mins) is different from - // the stale time for regular navigations (default: 0 secs). We adjust the - // current timestamp to account for the difference. - const adjustedCurrentTime = now - STATIC_STALETIME_MS + DYNAMIC_STALETIME_MS - const bfcacheEntry = readFromBFCacheDuringRegularNavigation( - adjustedCurrentTime, - varyPath - ) + // Read from the BFCache without expiring it (pass -1). We check freshness + // ourselves using navigatedAt, because the BFCache's staleAt may have been + // overridden by a per-page unstable_dynamicStaleTime and can't be used to + // derive the original request time. + const bfcacheEntry = readFromBFCache(varyPath) if (bfcacheEntry !== null) { - // Fulfill the prefetch using the bfcache entry. - - // As explained above, the stale time of this prefetch entry is different - // than the one for the bfcache. Calculate when it was originally requested - // by subtracting the stale time used by the bfcache. - const requestedAt = bfcacheEntry.staleAt - DYNAMIC_STALETIME_MS - // Now add the stale time used by dynamic prefetches. - const dynamicPrefetchStaleAt = requestedAt + STATIC_STALETIME_MS + // The stale time for dynamic prefetches (default: 5 mins) is different + // from the stale time for regular navigations (default: 0 secs). Use + // navigatedAt to compute the correct expiry for prefetch purposes. + const dynamicPrefetchStaleAt = + bfcacheEntry.navigatedAt + STATIC_STALETIME_MS + if (now > dynamicPrefetchStaleAt) { + return null + } const pendingSegment = upgradeToPendingSegment(segment, FetchStrategy.Full) const isPartial = false @@ -992,14 +986,13 @@ export function attemptToUpgradeSegmentFromBFCache( tree: RouteTree ): FulfilledSegmentCacheEntry | null { const varyPath = tree.varyPath - const adjustedCurrentTime = now - STATIC_STALETIME_MS + DYNAMIC_STALETIME_MS - const bfcacheEntry = readFromBFCacheDuringRegularNavigation( - adjustedCurrentTime, - varyPath - ) + const bfcacheEntry = readFromBFCache(varyPath) if (bfcacheEntry !== null) { - const requestedAt = bfcacheEntry.staleAt - DYNAMIC_STALETIME_MS - const dynamicPrefetchStaleAt = requestedAt + STATIC_STALETIME_MS + const dynamicPrefetchStaleAt = + bfcacheEntry.navigatedAt + STATIC_STALETIME_MS + if (now > dynamicPrefetchStaleAt) { + return null + } const pendingSegment = upgradeToPendingSegment( createDetachedSegmentCacheEntry(now), FetchStrategy.Full @@ -2292,9 +2285,12 @@ export async function fetchSegmentPrefetchesUsingDynamicRequest( return null } const navigationSeed = convertServerPatchToFullTree( + now, dynamicRequestTree, flightDatas, - renderedSearch + renderedSearch, + // Not needed for prefetch responses; pass unknown to use the default. + UnknownDynamicStaleTime ) fulfilledEntries = writeDynamicRenderResponseIntoCache( now, @@ -2395,9 +2391,11 @@ function writeDynamicTreeResponseIntoCache( // enabled everywhere. Tree prefetches should never include segment data. We // can delete it. Leaving for a subsequent PR. const navigationSeed = convertServerPatchToFullTree( + now, flightRouterState, normalizedFlightDataResult, - renderedSearch + renderedSearch, + UnknownDynamicStaleTime ) const buildId = response.headers.get(NEXT_NAV_DEPLOYMENT_ID_HEADER) ?? serverData.b @@ -2921,9 +2919,11 @@ export function writeStaticStageResponseIntoCache( return } const navigationSeed = convertServerPatchToFullTree( + now, baseTree, flightDatas, - renderedSearch + renderedSearch, + UnknownDynamicStaleTime ) writeDynamicRenderResponseIntoCache( now, @@ -2979,9 +2979,11 @@ export async function processRuntimePrefetchStream( return null } const navigationSeed = convertServerPatchToFullTree( + now, baseTree, flightDatas, - renderedSearch + renderedSearch, + UnknownDynamicStaleTime ) return { diff --git a/packages/next/src/client/components/segment-cache/navigation.ts b/packages/next/src/client/components/segment-cache/navigation.ts index 0b0ce955dc8f0b..f64ba5eff68791 100644 --- a/packages/next/src/client/components/segment-cache/navigation.ts +++ b/packages/next/src/client/components/segment-cache/navigation.ts @@ -38,6 +38,7 @@ import type { AppRouterState } from '../router-reducer/router-reducer-types' import { ScrollBehavior } from '../router-reducer/router-reducer-types' import { computeChangedPath } from '../router-reducer/compute-changed-path' import { isJavaScriptURLString } from '../../lib/javascript-url' +import { UnknownDynamicStaleTime, computeDynamicStaleAt } from './bfcache' /** * Navigate to a new URL, using the Segment Cache to construct a response. @@ -258,6 +259,7 @@ export function navigateToKnownRoute( freshnessPolicy, navigationSeed.data, navigationSeed.head, + navigationSeed.dynamicStaleAt, isSamePageNavigation, accumulation ) @@ -314,6 +316,7 @@ function navigateUsingPrefetchedRouteTree( metadataVaryPath: route.metadata.varyPath as any, data: null, head: null, + dynamicStaleAt: computeDynamicStaleAt(now, UnknownDynamicStaleTime), } return navigateToKnownRoute( now, @@ -406,6 +409,7 @@ async function navigateToUnknownRoute( renderedSearch, couldBeIntercepted, supportsPerSegmentPrefetching, + dynamicStaleTime, staticStageData, runtimePrefetchStream, responseHeaders, @@ -416,9 +420,11 @@ async function navigateToUnknownRoute( // different, we'll need to massage the data a bit. Create FlightRouterState // tree that simulates what we'd receive as the result of a prefetch. const navigationSeed = convertServerPatchToFullTree( + now, currentFlightRouterState, flightData, - renderedSearch + renderedSearch, + dynamicStaleTime ) // Learn the route pattern so we can predict it for future navigations. @@ -736,12 +742,15 @@ export type NavigationSeed = { metadataVaryPath: PageVaryPath | null data: CacheNodeSeedData | null head: HeadData | null + dynamicStaleAt: number } export function convertServerPatchToFullTree( + now: number, currentTree: FlightRouterState, flightData: Array | null, - renderedSearch: string + renderedSearch: string, + dynamicStaleTimeSeconds: number ): NavigationSeed { // During a client navigation or prefetch, the server sends back only a patch // for the parts of the tree that have changed. @@ -806,6 +815,7 @@ export function convertServerPatchToFullTree( data: baseData, renderedSearch, head, + dynamicStaleAt: computeDynamicStaleAt(now, dynamicStaleTimeSeconds), } } diff --git a/packages/next/src/server/app-render/app-render.tsx b/packages/next/src/server/app-render/app-render.tsx index 361c85e54fdb41..2f2c48b05e7eb1 100644 --- a/packages/next/src/server/app-render/app-render.tsx +++ b/packages/next/src/server/app-render/app-render.tsx @@ -260,7 +260,10 @@ import { createValidationBoundaryTracking, type ValidationBoundaryTracking, } from './instant-validation/boundary-tracking' -import type { InstantSample } from '../../build/segment-config/app/app-segment-config' +import type { + AppSegmentConfig, + InstantSample, +} from '../../build/segment-config/app/app-segment-config' import { ResponseCookies } from '../web/spec-extension/cookies' import { isInstantValidationError } from './instant-validation/instant-validation-error' @@ -431,6 +434,48 @@ function parseRequestHeaders( } } +/** + * Walks the loader tree to find the minimum `unstable_dynamicStaleTime` exported by + * any page module. Returns null if no page exports the config. + * + * This only reads static exports from page modules — it does not render any + * server components, so it's cheap to call. + * + * TODO: Move this to the prefetch hints file so we don't have to walk the + * tree on every render. + */ +async function getDynamicStaleTime(tree: LoaderTree): Promise { + const { page, parallelRoutes } = parseLoaderTree(tree) + + let result: number | null = null + + // Only pages (not layouts) can export unstable_dynamicStaleTime. + if (typeof page !== 'undefined') { + const pageMod = await page[0]() + if ( + pageMod && + typeof (pageMod as AppSegmentConfig).unstable_dynamicStaleTime === + 'number' + ) { + const value = (pageMod as AppSegmentConfig).unstable_dynamicStaleTime! + result = result !== null ? Math.min(result, value) : value + } + } + + const childPromises: Promise[] = [] + for (const parallelRouteKey in parallelRoutes) { + childPromises.push(getDynamicStaleTime(parallelRoutes[parallelRouteKey])) + } + const childResults = await Promise.all(childPromises) + for (const childResult of childResults) { + if (childResult !== null) { + result = result !== null ? Math.min(result, childResult) : childResult + } + } + + return result +} + function createNotFoundLoaderTree(loaderTree: LoaderTree): LoaderTree { const components = loaderTree[2] const hasGlobalNotFound = !!components['global-not-found'] @@ -668,6 +713,20 @@ async function generateDynamicRSCPayload( baseResponse.p = options.runtimePrefetchStream } + // Include the per-page dynamic stale time from unstable_dynamicStaleTime, but only + // for dynamic renders (not prerenders/static generation). The client treats + // its presence as authoritative. + // TODO: Move this to the prefetch hints file so we don't have to walk the + // tree on every render. + if (!workStore.isStaticGeneration) { + const dynamicStaleTime = await getDynamicStaleTime( + ctx.componentMod.routeModule.userland.loaderTree + ) + if (dynamicStaleTime !== null) { + baseResponse.d = dynamicStaleTime + } + } + return baseResponse } @@ -1789,6 +1848,14 @@ async function getRSCPayload( s: staleTimeIterable, l: staticStageByteLengthPromise, p: runtimePrefetchStream, + // Include the per-page dynamic stale time from unstable_dynamicStaleTime, but + // only for dynamic renders. The client treats its presence as + // authoritative. + // TODO: Move this to the prefetch hints file so we don't have to walk + // the tree on every render. + d: !workStore.isStaticGeneration + ? ((await getDynamicStaleTime(tree)) ?? undefined) + : undefined, }) } diff --git a/packages/next/src/server/app-render/create-component-tree.tsx b/packages/next/src/server/app-render/create-component-tree.tsx index 14649442055216..3b9ff10615f46d 100644 --- a/packages/next/src/server/app-render/create-component-tree.tsx +++ b/packages/next/src/server/app-render/create-component-tree.tsx @@ -357,6 +357,48 @@ async function createComponentTreeInternal( } } + // Read unstable_dynamicStaleTime from page modules (not layouts) and track it on + // the store's stale field. This affects the segment cache stale time via + // the StaleTimeIterable. + if ( + isPage && + typeof layoutOrPageMod?.unstable_dynamicStaleTime === 'number' + ) { + const pageStaleTime = layoutOrPageMod.unstable_dynamicStaleTime + const workUnitStore = workUnitAsyncStorage.getStore() + + if (workUnitStore) { + switch (workUnitStore.type) { + case 'prerender': + case 'prerender-runtime': + case 'prerender-legacy': + case 'prerender-ppr': + if (workUnitStore.stale > pageStaleTime) { + workUnitStore.stale = pageStaleTime + } + break + case 'request': + if ( + workUnitStore.stale === undefined || + workUnitStore.stale > pageStaleTime + ) { + workUnitStore.stale = pageStaleTime + } + break + // createComponentTree is not called for these stores: + case 'cache': + case 'private-cache': + case 'prerender-client': + case 'validation-client': + case 'unstable-cache': + case 'generate-static-params': + break + default: + workUnitStore satisfies never + } + } + } + const isStaticGeneration = workStore.isStaticGeneration // Assume the segment we're rendering contains only partial data if PPR is diff --git a/packages/next/src/server/typescript/rules/config.ts b/packages/next/src/server/typescript/rules/config.ts index 88d1de7abcbfc9..ad58b9c349f1ac 100644 --- a/packages/next/src/server/typescript/rules/config.ts +++ b/packages/next/src/server/typescript/rules/config.ts @@ -156,6 +156,17 @@ const API_DOCS: Record< // `getSemanticDiagnosticsForExportVariableStatement` below, and only provide hover a tooltip + autocomplete. insertText: 'unstable_instant = { prefetch: "static" };', }, + unstable_dynamicStaleTime: { + description: `Controls how long the client-side router cache retains dynamic page data (in seconds). Pages only — not allowed in layouts. Cannot be combined with \`unstable_instant\`.`, + link: '(docs coming soon)', + type: 'number', + isValid: (value: string) => { + return Number(value.replace(/_/g, '')) >= 0 + }, + getHint: (value: any) => { + return `Set the dynamic stale time to \`${value}\` seconds.` + }, + }, } type FullAppSegmentConfig = Required diff --git a/packages/next/src/shared/lib/app-router-types.ts b/packages/next/src/shared/lib/app-router-types.ts index 1591d1574bd401..650bd824050c46 100644 --- a/packages/next/src/shared/lib/app-router-types.ts +++ b/packages/next/src/shared/lib/app-router-types.ts @@ -314,6 +314,14 @@ export type InitialRSCPayload = { l?: Promise /** runtimePrefetchStream — Embedded runtime prefetch Flight stream. */ p?: ReadableStream + /** + * dynamicStaleTime — Per-page BFCache stale time in seconds, from + * `unstable_dynamicStaleTime`. Only included for dynamic renders. Controls + * how long the client router cache retains dynamic navigation data. This is + * distinct from the `s` field, which controls segment cache (prefetch) + * staleness. + */ + d?: number } // Response from `createFromFetch` for normal rendering @@ -336,6 +344,14 @@ export type NavigationFlightResponse = { h: VaryParamsThenable | null /** runtimePrefetchStream — Embedded runtime prefetch Flight stream. */ p?: ReadableStream + /** + * dynamicStaleTime — Per-page BFCache stale time in seconds, from + * `unstable_dynamicStaleTime`. Only included for dynamic renders. Controls + * how long the client router cache retains dynamic navigation data. This is + * distinct from the `s` field, which controls segment cache (prefetch) + * staleness. + */ + d?: number } // Response from `createFromFetch` for server actions. Action's flight data can be null diff --git a/test/e2e/app-dir/segment-cache/staleness/app/per-page-config/dynamic-stale-10/page.tsx b/test/e2e/app-dir/segment-cache/staleness/app/per-page-config/dynamic-stale-10/page.tsx new file mode 100644 index 00000000000000..7bbc8d161ace1c --- /dev/null +++ b/test/e2e/app-dir/segment-cache/staleness/app/per-page-config/dynamic-stale-10/page.tsx @@ -0,0 +1,19 @@ +import { Suspense } from 'react' +import { connection } from 'next/server' + +export const unstable_dynamicStaleTime = 10 + +async function Content() { + await connection() + return ( +
Dynamic content (stale time 10s)
+ ) +} + +export default function Page() { + return ( + + + + ) +} diff --git a/test/e2e/app-dir/segment-cache/staleness/app/per-page-config/dynamic-stale-60/page.tsx b/test/e2e/app-dir/segment-cache/staleness/app/per-page-config/dynamic-stale-60/page.tsx new file mode 100644 index 00000000000000..11b4b6b837e518 --- /dev/null +++ b/test/e2e/app-dir/segment-cache/staleness/app/per-page-config/dynamic-stale-60/page.tsx @@ -0,0 +1,19 @@ +import { Suspense } from 'react' +import { connection } from 'next/server' + +export const unstable_dynamicStaleTime = 60 + +async function Content() { + await connection() + return ( +
Dynamic content (stale time 60s)
+ ) +} + +export default function Page() { + return ( + + + + ) +} diff --git a/test/e2e/app-dir/segment-cache/staleness/app/per-page-config/layout.tsx b/test/e2e/app-dir/segment-cache/staleness/app/per-page-config/layout.tsx new file mode 100644 index 00000000000000..c9a0e0bfb8fdbc --- /dev/null +++ b/test/e2e/app-dir/segment-cache/staleness/app/per-page-config/layout.tsx @@ -0,0 +1,3 @@ +export default function Layout({ children }: { children: React.ReactNode }) { + return children +} diff --git a/test/e2e/app-dir/segment-cache/staleness/app/per-page-config/page.tsx b/test/e2e/app-dir/segment-cache/staleness/app/per-page-config/page.tsx new file mode 100644 index 00000000000000..a0d8c8355eaf96 --- /dev/null +++ b/test/e2e/app-dir/segment-cache/staleness/app/per-page-config/page.tsx @@ -0,0 +1,30 @@ +import { LinkAccordion } from '../../components/link-accordion' + +export default function Page() { + return ( + <> +

+ This page tests per-page dynamic stale time configuration via{' '} + export const unstable_dynamicStaleTime. +

+
    +
  • + + Dynamic page with stale time of 60 seconds + +
  • +
  • + + Dynamic page with stale time of 10 seconds + +
  • +
  • + + Parallel routes with slots having different stale times (60s and + 15s) + +
  • +
+ + ) +} diff --git a/test/e2e/app-dir/segment-cache/staleness/app/per-page-config/parallel-slots/@slotA/page.tsx b/test/e2e/app-dir/segment-cache/staleness/app/per-page-config/parallel-slots/@slotA/page.tsx new file mode 100644 index 00000000000000..e3f7ff1698da77 --- /dev/null +++ b/test/e2e/app-dir/segment-cache/staleness/app/per-page-config/parallel-slots/@slotA/page.tsx @@ -0,0 +1,17 @@ +import { Suspense } from 'react' +import { connection } from 'next/server' + +export const unstable_dynamicStaleTime = 60 + +async function Content() { + await connection() + return
Slot A content (stale time 60s)
+} + +export default function Page() { + return ( + + + + ) +} diff --git a/test/e2e/app-dir/segment-cache/staleness/app/per-page-config/parallel-slots/@slotB/page.tsx b/test/e2e/app-dir/segment-cache/staleness/app/per-page-config/parallel-slots/@slotB/page.tsx new file mode 100644 index 00000000000000..f500f083220ea4 --- /dev/null +++ b/test/e2e/app-dir/segment-cache/staleness/app/per-page-config/parallel-slots/@slotB/page.tsx @@ -0,0 +1,17 @@ +import { Suspense } from 'react' +import { connection } from 'next/server' + +export const unstable_dynamicStaleTime = 15 + +async function Content() { + await connection() + return
Slot B content (stale time 15s)
+} + +export default function Page() { + return ( + + + + ) +} diff --git a/test/e2e/app-dir/segment-cache/staleness/app/per-page-config/parallel-slots/layout.tsx b/test/e2e/app-dir/segment-cache/staleness/app/per-page-config/parallel-slots/layout.tsx new file mode 100644 index 00000000000000..b85446d92ddf7d --- /dev/null +++ b/test/e2e/app-dir/segment-cache/staleness/app/per-page-config/parallel-slots/layout.tsx @@ -0,0 +1,17 @@ +export default function Layout({ + children, + slotA, + slotB, +}: { + children: React.ReactNode + slotA: React.ReactNode + slotB: React.ReactNode +}) { + return ( +
+ {children} + {slotA} + {slotB} +
+ ) +} diff --git a/test/e2e/app-dir/segment-cache/staleness/app/per-page-config/parallel-slots/page.tsx b/test/e2e/app-dir/segment-cache/staleness/app/per-page-config/parallel-slots/page.tsx new file mode 100644 index 00000000000000..c17431379f962f --- /dev/null +++ b/test/e2e/app-dir/segment-cache/staleness/app/per-page-config/parallel-slots/page.tsx @@ -0,0 +1,3 @@ +export default function Page() { + return null +} diff --git a/test/e2e/app-dir/segment-cache/staleness/segment-cache-per-page-dynamic-stale-time.test.ts b/test/e2e/app-dir/segment-cache/staleness/segment-cache-per-page-dynamic-stale-time.test.ts new file mode 100644 index 00000000000000..39f3467dcbc6a8 --- /dev/null +++ b/test/e2e/app-dir/segment-cache/staleness/segment-cache-per-page-dynamic-stale-time.test.ts @@ -0,0 +1,352 @@ +import { nextTestSetup } from 'e2e-utils' +import type * as Playwright from 'playwright' +import { createRouterAct } from 'router-act' + +describe('segment cache (per-page dynamic stale time)', () => { + const { next, isNextDev } = nextTestSetup({ + files: __dirname, + }) + if (isNextDev) { + test('disabled in development', () => {}) + return + } + + async function startBrowserWithFakeClock(url: string) { + let page!: Playwright.Page + const startDate = Date.now() + + const browser = await next.browser(url, { + async beforePageLoad(p: Playwright.Page) { + page = p + await page.clock.install() + await page.clock.setFixedTime(startDate) + }, + }) + + const act = createRouterAct(page) + + return { browser, page, act, startDate } + } + + it('reuses dynamic data within the per-page stale time window', async () => { + const { browser, page, act, startDate } = + await startBrowserWithFakeClock('/per-page-config') + + // Navigate to the dynamic page with unstable_dynamicStaleTime = 60 + await act( + async () => { + const toggle = await browser.elementByCss( + 'input[data-link-accordion="/per-page-config/dynamic-stale-60"]' + ) + await toggle.click() + const link = await browser.elementByCss( + 'a[href="/per-page-config/dynamic-stale-60"]' + ) + await link.click() + }, + { + includes: 'Dynamic content (stale time 60s)', + } + ) + expect(await browser.elementById('dynamic-stale-60-content').text()).toBe( + 'Dynamic content (stale time 60s)' + ) + + // Go back to the starting page + await browser.back() + + // Advance to 59 seconds. The per-page stale time is 60s (which overrides + // the global staleTimes.dynamic of 30s), so the data should still be fresh. + await page.clock.setFixedTime(startDate + 59 * 1000) + + await act(async () => { + const link = await browser.elementByCss( + 'a[href="/per-page-config/dynamic-stale-60"]' + ) + await link.click() + expect(await browser.elementById('dynamic-stale-60-content').text()).toBe( + 'Dynamic content (stale time 60s)' + ) + }, 'no-requests') + + // Go back again + await browser.back() + + // Advance to 60 seconds. The data is now stale, so a new request + // should be made. + await page.clock.setFixedTime(startDate + 60 * 1000) + + await act( + async () => { + const link = await browser.elementByCss( + 'a[href="/per-page-config/dynamic-stale-60"]' + ) + await link.click() + }, + { includes: 'Dynamic content (stale time 60s)' } + ) + expect(await browser.elementById('dynamic-stale-60-content').text()).toBe( + 'Dynamic content (stale time 60s)' + ) + }) + + it('back/forward navigation always reuses BFCache regardless of stale time', async () => { + const { browser, page, act, startDate } = + await startBrowserWithFakeClock('/per-page-config') + + // Navigate to the dynamic page with unstable_dynamicStaleTime = 60 + await act( + async () => { + const toggle = await browser.elementByCss( + 'input[data-link-accordion="/per-page-config/dynamic-stale-60"]' + ) + await toggle.click() + const link = await browser.elementByCss( + 'a[href="/per-page-config/dynamic-stale-60"]' + ) + await link.click() + }, + { + includes: 'Dynamic content (stale time 60s)', + } + ) + expect(await browser.elementById('dynamic-stale-60-content').text()).toBe( + 'Dynamic content (stale time 60s)' + ) + + // Go back to the starting page + await browser.back() + + // Advance time well past the 60s stale time + await page.clock.setFixedTime(startDate + 120 * 1000) + + // Use browser.forward() to go forward. Back/forward navigation should + // always reuse the BFCache, regardless of stale time. + await act(async () => { + await browser.forward() + expect(await browser.elementById('dynamic-stale-60-content').text()).toBe( + 'Dynamic content (stale time 60s)' + ) + }, 'no-requests') + }) + + it('two dynamic pages with different stale times behave independently', async () => { + const { browser, page, act, startDate } = + await startBrowserWithFakeClock('/per-page-config') + + // Navigate to the dynamic page with unstable_dynamicStaleTime = 60 + await act( + async () => { + const toggle = await browser.elementByCss( + 'input[data-link-accordion="/per-page-config/dynamic-stale-60"]' + ) + await toggle.click() + const link = await browser.elementByCss( + 'a[href="/per-page-config/dynamic-stale-60"]' + ) + await link.click() + }, + { + includes: 'Dynamic content (stale time 60s)', + } + ) + expect(await browser.elementById('dynamic-stale-60-content').text()).toBe( + 'Dynamic content (stale time 60s)' + ) + + // Go back to the starting page + await browser.back() + + // Navigate to the dynamic page with unstable_dynamicStaleTime = 10 + await act( + async () => { + const toggle = await browser.elementByCss( + 'input[data-link-accordion="/per-page-config/dynamic-stale-10"]' + ) + await toggle.click() + const link = await browser.elementByCss( + 'a[href="/per-page-config/dynamic-stale-10"]' + ) + await link.click() + }, + { + includes: 'Dynamic content (stale time 10s)', + } + ) + expect(await browser.elementById('dynamic-stale-10-content').text()).toBe( + 'Dynamic content (stale time 10s)' + ) + + // Go back to the starting page + await browser.back() + + // Advance to 11 seconds. The 10s page should be stale, but the 60s page + // should still be fresh. + await page.clock.setFixedTime(startDate + 11 * 1000) + + // Navigate to the 10s page — should be stale, triggering a new request + await act( + async () => { + const link = await browser.elementByCss( + 'a[href="/per-page-config/dynamic-stale-10"]' + ) + await link.click() + }, + { includes: 'Dynamic content (stale time 10s)' } + ) + expect(await browser.elementById('dynamic-stale-10-content').text()).toBe( + 'Dynamic content (stale time 10s)' + ) + + // Go back to the starting page + await browser.back() + + // Navigate to the 60s page — should still be fresh, no new request + await act(async () => { + const link = await browser.elementByCss( + 'a[href="/per-page-config/dynamic-stale-60"]' + ) + await link.click() + expect(await browser.elementById('dynamic-stale-60-content').text()).toBe( + 'Dynamic content (stale time 60s)' + ) + }, 'no-requests') + }) + + it('per-page value overrides global staleTimes.dynamic regardless of direction', async () => { + // The global staleTimes.dynamic is 30s. This test verifies that a per-page + // value of 10s (smaller) causes the data to expire sooner, and a per-page + // value of 60s (larger) causes the data to last longer. + const { browser, page, act, startDate } = + await startBrowserWithFakeClock('/per-page-config') + + // Navigate to the 10s page + await act( + async () => { + const toggle = await browser.elementByCss( + 'input[data-link-accordion="/per-page-config/dynamic-stale-10"]' + ) + await toggle.click() + const link = await browser.elementByCss( + 'a[href="/per-page-config/dynamic-stale-10"]' + ) + await link.click() + }, + { includes: 'Dynamic content (stale time 10s)' } + ) + + await browser.back() + + // At 11s the 10s page should be stale, even though the global default + // is 30s. This proves a smaller per-page value overrides the global. + await page.clock.setFixedTime(startDate + 11 * 1000) + + await act( + async () => { + const link = await browser.elementByCss( + 'a[href="/per-page-config/dynamic-stale-10"]' + ) + await link.click() + }, + { includes: 'Dynamic content (stale time 10s)' } + ) + + await browser.back() + + // Now navigate to the 60s page + await act( + async () => { + const toggle = await browser.elementByCss( + 'input[data-link-accordion="/per-page-config/dynamic-stale-60"]' + ) + await toggle.click() + const link = await browser.elementByCss( + 'a[href="/per-page-config/dynamic-stale-60"]' + ) + await link.click() + }, + { includes: 'Dynamic content (stale time 60s)' } + ) + + await browser.back() + + // At 42s from the 60s page's navigation (11s + 31s), the data should + // still be fresh — the per-page value of 60s overrides the global 30s. + await page.clock.setFixedTime(startDate + 42 * 1000) + + await act(async () => { + const link = await browser.elementByCss( + 'a[href="/per-page-config/dynamic-stale-60"]' + ) + await link.click() + expect(await browser.elementById('dynamic-stale-60-content').text()).toBe( + 'Dynamic content (stale time 60s)' + ) + }, 'no-requests') + }) + + it('with parallel routes, uses the minimum stale time across all slots', async () => { + const { browser, page, act, startDate } = + await startBrowserWithFakeClock('/per-page-config') + + // Navigate to a page with parallel routes: slot A has + // unstable_dynamicStaleTime = 60, slot B has + // unstable_dynamicStaleTime = 15. The effective stale time should be + // min(60, 15) = 15. + await act( + async () => { + const toggle = await browser.elementByCss( + 'input[data-link-accordion="/per-page-config/parallel-slots"]' + ) + await toggle.click() + const link = await browser.elementByCss( + 'a[href="/per-page-config/parallel-slots"]' + ) + await link.click() + }, + { + includes: 'Slot A content', + } + ) + expect(await browser.elementById('slot-a-content').text()).toBe( + 'Slot A content (stale time 60s)' + ) + expect(await browser.elementById('slot-b-content').text()).toBe( + 'Slot B content (stale time 15s)' + ) + + await browser.back() + + // At 14s both slots should still be fresh (min is 15s) + await page.clock.setFixedTime(startDate + 14 * 1000) + + await act(async () => { + const link = await browser.elementByCss( + 'a[href="/per-page-config/parallel-slots"]' + ) + await link.click() + expect(await browser.elementById('slot-a-content').text()).toBe( + 'Slot A content (stale time 60s)' + ) + expect(await browser.elementById('slot-b-content').text()).toBe( + 'Slot B content (stale time 15s)' + ) + }, 'no-requests') + + await browser.back() + + // At 16s the data should be stale because slot B's stale time (15s) + // has elapsed. + await page.clock.setFixedTime(startDate + 16 * 1000) + + await act( + async () => { + const link = await browser.elementByCss( + 'a[href="/per-page-config/parallel-slots"]' + ) + await link.click() + }, + { includes: 'Slot A content' } + ) + }) +}) From 63dff61bfc5ffcc39d8b7047e18d05ac5e5516d3 Mon Sep 17 00:00:00 2001 From: "Sebastian \"Sebbie\" Silbermann" Date: Mon, 16 Mar 2026 21:14:21 +0100 Subject: [PATCH 2/8] [test] More `instant-navs-devtools` deflaking (#91345) --- .../instant-navs-devtools.test.ts | 41 +++++++++++++------ 1 file changed, 28 insertions(+), 13 deletions(-) diff --git a/test/development/app-dir/instant-navs-devtools/instant-navs-devtools.test.ts b/test/development/app-dir/instant-navs-devtools/instant-navs-devtools.test.ts index 82fb93cd8e92ed..5792beccc3be5c 100644 --- a/test/development/app-dir/instant-navs-devtools/instant-navs-devtools.test.ts +++ b/test/development/app-dir/instant-navs-devtools/instant-navs-devtools.test.ts @@ -3,7 +3,7 @@ import { retry, toggleDevToolsIndicatorPopover } from 'next-test-utils' import { Playwright } from 'next-webdriver' describe('instant-nav-panel', () => { - const { next } = nextTestSetup({ + const { isNextDev, isTurbopack, next } = nextTestSetup({ files: __dirname, }) @@ -14,12 +14,19 @@ describe('instant-nav-panel', () => { await new Promise((resolve) => setTimeout( resolve, - // MENU_DURATION_MS - 200 + // MENU_DURATION_MS + some flakiness buffer + 200 + 50 ) ) } + async function waitForInstantModeCookie(browser: Playwright): Promise { + await retry(async () => { + const cookie = await browser.eval(() => document.cookie) + expect(cookie).toMatch(/next-instant-navigation-testing=[^;]+/) + }) + } + async function clearInstantModeCookie(browser: Playwright) { await browser.eval(() => { document.cookie = 'next-instant-navigation-testing=; path=/; max-age=0' @@ -32,6 +39,7 @@ describe('instant-nav-panel', () => { async function clickStartClientNav(browser: Playwright) { await browser.elementByCssInstant('[data-instant-nav-client]').click() + await waitForInstantModeCookie(browser) } async function getInstantNavPanelText(browser: Playwright): Promise { @@ -84,6 +92,11 @@ describe('instant-nav-panel', () => { }) it('should show client nav state after clicking Start and navigating', async () => { + const targetPage = '/target-page/my-post?search=foo' + if (isNextDev && !isTurbopack) { + // warmup target page compilation before clicking Start, to avoid extra flakiness. + void next.render(targetPage).catch(() => {}) + } const browser = await next.browser('/') await clearInstantModeCookie(browser) await browser.waitForElementByCss('[data-testid="home-title"]') @@ -94,10 +107,7 @@ describe('instant-nav-panel', () => { await clickStartClientNav(browser) // Cookie should now be set - await retry(async () => { - const cookie = await browser.eval(() => document.cookie) - expect(cookie).toContain('next-instant-navigation-testing=') - }) + await waitForInstantModeCookie(browser) // Panel should show client-nav-waiting state await retry(async () => { @@ -107,9 +117,9 @@ describe('instant-nav-panel', () => { }) // Navigate to target page via SPA (use eval to bypass overlay pointer interception) - await browser.eval(() => { - document.querySelector('#link-to-target')!.click() - }) + await browser.eval((page) => { + document.querySelector(`[href="${page}"]`)!.click() + }, targetPage) // Panel should transition to client-nav state await retry(async () => { @@ -124,6 +134,11 @@ describe('instant-nav-panel', () => { }) it('should show loading skeleton during SPA navigation after clicking Start', async () => { + const targetPage = '/target-page/my-post?search=foo' + if (isNextDev && !isTurbopack) { + // warmup target page compilation before clicking Start, to avoid extra flakiness. + void next.render(targetPage).catch(() => {}) + } const browser = await next.browser('/') await clearInstantModeCookie(browser) await browser.waitForElementByCss('[data-testid="home-title"]') @@ -134,9 +149,9 @@ describe('instant-nav-panel', () => { await clickStartClientNav(browser) // Navigate to target page via SPA (use eval to bypass overlay pointer interception) - await browser.eval(() => { - document.querySelector('#link-to-target')!.click() - }) + await browser.eval((page) => { + document.querySelector(`[href="${page}"]`)!.click() + }, targetPage) // The data fetching skeleton should be visible (dynamic content is locked). // Use a longer timeout because dev mode needs to compile the target page. From 672b02b27002c8ac6816eff744c15090335b0caa Mon Sep 17 00:00:00 2001 From: "Sebastian \"Sebbie\" Silbermann" Date: Mon, 16 Mar 2026 22:07:56 +0100 Subject: [PATCH 3/8] [next-playwright] Use unique cookie values for instant navigation testing lock (#91250) Co-authored-by: Cursor Agent --- bench/module-cost/package.json | 2 +- package.json | 4 +- packages/next-playwright/package.json | 1 + packages/next-playwright/src/index.ts | 13 +- packages/next/package.json | 2 +- .../segment-cache/navigation-testing-lock.ts | 30 +++-- .../instant-navs/instant-nav-cookie.ts | 13 +- .../instant-navs/instant-navs-panel.tsx | 4 +- pnpm-lock.yaml | 112 ++++++++---------- .../basic/allowed-dev-origins.test.ts | 18 ++- .../use-router-with-rewrites.test.ts | 16 ++- .../dynamic-routing/test/index.test.ts | 10 +- test/lib/browsers/playwright.ts | 30 ++++- test/lib/next-webdriver.ts | 6 +- turbopack/packages/devlow-bench/package.json | 2 +- .../packages/devlow-bench/src/browser.ts | 2 +- 16 files changed, 154 insertions(+), 111 deletions(-) diff --git a/bench/module-cost/package.json b/bench/module-cost/package.json index 96814e0e6e8759..62bf8065402483 100644 --- a/bench/module-cost/package.json +++ b/bench/module-cost/package.json @@ -12,6 +12,6 @@ "devDependencies": { "rimraf": "6.0.1", "next": "workspace:*", - "playwright": "^1.40.0" + "playwright": "1.58.2" } } diff --git a/package.json b/package.json index 88a07ea296fadc..336f6a24e9c0d8 100644 --- a/package.json +++ b/package.json @@ -255,8 +255,8 @@ "octokit": "3.1.0", "outdent": "0.8.0", "pixrem": "5.0.0", - "playwright": "1.48.0", - "playwright-chromium": "1.48.0", + "playwright": "1.58.2", + "playwright-chromium": "1.58.2", "postcss": "8.4.31", "postcss-nested": "4.2.1", "postcss-pseudoelements": "5.0.0", diff --git a/packages/next-playwright/package.json b/packages/next-playwright/package.json index d49854095f84cc..9b4bbf630e3482 100644 --- a/packages/next-playwright/package.json +++ b/packages/next-playwright/package.json @@ -26,6 +26,7 @@ } }, "devDependencies": { + "@playwright/test": "1.58.2", "typescript": "5.9.2" } } diff --git a/packages/next-playwright/src/index.ts b/packages/next-playwright/src/index.ts index 9c568ab7438dc9..da5bf342892d57 100644 --- a/packages/next-playwright/src/index.ts +++ b/packages/next-playwright/src/index.ts @@ -74,11 +74,14 @@ export async function instant( // navigation-testing-lock.ts, which acquires the in-memory navigation lock. const { hostname } = new URL(resolveURL(page, options)) await step('Acquire Instant Lock', () => - page - .context() - .addCookies([ - { name: INSTANT_COOKIE, value: '[0]', domain: hostname, path: '/' }, - ]) + page.context().addCookies([ + { + name: INSTANT_COOKIE, + value: JSON.stringify([0, `p${Math.random()}`]), + domain: hostname, + path: '/', + }, + ]) ) try { return await fn() diff --git a/packages/next/package.json b/packages/next/package.json index b40a8bb539f0da..825fd6e2014cef 100644 --- a/packages/next/package.json +++ b/packages/next/package.json @@ -167,7 +167,7 @@ "@next/react-refresh-utils": "16.2.0-canary.100", "@next/swc": "16.2.0-canary.100", "@opentelemetry/api": "1.6.0", - "@playwright/test": "1.51.1", + "@playwright/test": "1.58.2", "@rspack/core": "1.6.7", "@storybook/addon-a11y": "8.6.0", "@storybook/addon-essentials": "8.6.0", diff --git a/packages/next/src/client/components/segment-cache/navigation-testing-lock.ts b/packages/next/src/client/components/segment-cache/navigation-testing-lock.ts index fa3abedf6e98de..806308a4f32a60 100644 --- a/packages/next/src/client/components/segment-cache/navigation-testing-lock.ts +++ b/packages/next/src/client/components/segment-cache/navigation-testing-lock.ts @@ -5,11 +5,6 @@ * during instant navigation captures, and owns all cookie state * transitions (pending → captured-MPA, pending → captured-SPA). * - * The cookie value is a JSON array: - * [0] — pending (waiting to capture) - * [1, null] — captured MPA page load - * [1, { from, to }] — captured SPA navigation (from/to route trees) - * * External actors (Playwright, devtools) set [0] to start a lock scope * and delete the cookie to end one. Next.js writes captured values. * The CookieStore handler distinguishes them by value: pending = external, @@ -22,17 +17,30 @@ import { refreshOnInstantNavigationUnlock } from '../use-action-queue' type InstantNavCookieState = 'pending' | 'mpa' | 'spa' +type InstantCookie = + // pending (waiting to capture) + | [captured: 0, id: string] + // captured MPA page load + | [captured: 1, id: string, state: null] + // captured SPA navigation (from/to route trees) + | [ + captured: 1, + id: string, + state: { from: FlightRouterState; to: FlightRouterState | null }, + ] + function parseCookieValue(raw: string): InstantNavCookieState { try { const parsed = JSON.parse(raw) - if (Array.isArray(parsed) && parsed.length >= 2) { - return parsed[1] === null ? 'mpa' : 'spa' + if (Array.isArray(parsed) && parsed.length >= 3) { + const rawState = parsed[2] + return rawState === null ? 'mpa' : 'spa' } } catch {} return 'pending' } -function writeCookieValue(value: unknown[]): void { +function writeCookieValue(value: InstantCookie): void { if (typeof cookieStore === 'undefined') { return } @@ -101,7 +109,7 @@ export function startListeningForInstantNavigationCookie(): void { }) } - writeCookieValue([1, null]) + writeCookieValue([1, `c${Math.random()}`, null]) acquireLock() } @@ -151,7 +159,7 @@ export function transitionToCapturedSPA( toTree: FlightRouterState | null ): void { if (process.env.__NEXT_EXPOSE_TESTING_API) { - writeCookieValue([1, { from: fromTree, to: toTree }]) + writeCookieValue([1, `c${Math.random()}`, { from: fromTree, to: toTree }]) } } @@ -164,7 +172,7 @@ export function updateCapturedSPAToTree( toTree: FlightRouterState ): void { if (process.env.__NEXT_EXPOSE_TESTING_API) { - writeCookieValue([1, { from: fromTree, to: toTree }]) + writeCookieValue([1, `c${Math.random()}`, { from: fromTree, to: toTree }]) } } diff --git a/packages/next/src/next-devtools/dev-overlay/components/instant-navs/instant-nav-cookie.ts b/packages/next/src/next-devtools/dev-overlay/components/instant-navs/instant-nav-cookie.ts index 3ffe218f6bfbf1..66232d4e5e961a 100644 --- a/packages/next/src/next-devtools/dev-overlay/components/instant-navs/instant-nav-cookie.ts +++ b/packages/next/src/next-devtools/dev-overlay/components/instant-navs/instant-nav-cookie.ts @@ -30,14 +30,15 @@ export type InstantNavCookieData = function parseCookieValue(raw: string): InstantNavCookieData { try { const parsed = JSON.parse(raw) - if (Array.isArray(parsed) && parsed.length >= 2) { - if (parsed[1] === null) { + if (Array.isArray(parsed) && parsed.length >= 3) { + const rawState = parsed[2] + if (rawState === null) { return { state: 'mpa' } } - // SPA capture: parsed[1] is { from, to } - if (typeof parsed[1] === 'object' && parsed[1] !== null) { - const fromTree: FlightRouterState = parsed[1].from ?? ['', {}] - const toTree: FlightRouterState | null = parsed[1].to ?? null + // SPA capture: rawState is { from, to } + if (typeof rawState === 'object' && rawState !== null) { + const fromTree: FlightRouterState = rawState.from ?? ['', {}] + const toTree: FlightRouterState | null = rawState.to ?? null return { state: 'spa', fromTree, toTree } } return { state: 'spa', fromTree: ['', {}], toTree: null } diff --git a/packages/next/src/next-devtools/dev-overlay/components/instant-navs/instant-navs-panel.tsx b/packages/next/src/next-devtools/dev-overlay/components/instant-navs/instant-navs-panel.tsx index 856c119f775267..1cb9b906cce440 100644 --- a/packages/next/src/next-devtools/dev-overlay/components/instant-navs/instant-navs-panel.tsx +++ b/packages/next/src/next-devtools/dev-overlay/components/instant-navs/instant-navs-panel.tsx @@ -44,7 +44,7 @@ export function InstantNavsPanel() { if (typeof cookieStore !== 'undefined') { cookieStore.set({ name: COOKIE_NAME, - value: '[0]', + value: JSON.stringify([0, `p${Math.random()}`]), path: '/', }) } @@ -55,7 +55,7 @@ export function InstantNavsPanel() { if (typeof cookieStore !== 'undefined') { cookieStore.set({ name: COOKIE_NAME, - value: '[0]', + value: JSON.stringify([0, `p${Math.random()}`]), path: '/', }) } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 580dade7dfd83a..94dc03e757afbe 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -460,11 +460,11 @@ importers: specifier: 5.0.0 version: 5.0.0 playwright: - specifier: 1.48.0 - version: 1.48.0 + specifier: 1.58.2 + version: 1.58.2 playwright-chromium: - specifier: 1.48.0 - version: 1.48.0 + specifier: 1.58.2 + version: 1.58.2 postcss: specifier: 8.4.31 version: 8.4.31 @@ -668,7 +668,7 @@ importers: version: 0.554.0(react@19.3.0-canary-5e9eedb5-20260312) next: specifier: 16.0.8 - version: 16.0.8(@babel/core@7.26.10)(@opentelemetry/api@1.9.0)(@playwright/test@1.51.1)(babel-plugin-macros@3.1.0)(babel-plugin-react-compiler@0.0.0-experimental-1371fcb-20260227)(react-dom@19.3.0-canary-5e9eedb5-20260312(react@19.3.0-canary-5e9eedb5-20260312))(react@19.3.0-canary-5e9eedb5-20260312)(sass@1.77.8) + version: 16.0.8(@babel/core@7.26.10)(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(babel-plugin-macros@3.1.0)(babel-plugin-react-compiler@0.0.0-experimental-1371fcb-20260227)(react-dom@19.3.0-canary-5e9eedb5-20260312(react@19.3.0-canary-5e9eedb5-20260312))(react@19.3.0-canary-5e9eedb5-20260312)(sass@1.77.8) next-themes: specifier: ^0.4.6 version: 0.4.6(react-dom@19.3.0-canary-5e9eedb5-20260312(react@19.3.0-canary-5e9eedb5-20260312))(react@19.3.0-canary-5e9eedb5-20260312) @@ -726,16 +726,16 @@ importers: dependencies: fumadocs-core: specifier: 15.7.12 - version: 15.7.12(@types/react@19.2.10)(next@15.5.8(@babel/core@7.26.10)(@opentelemetry/api@1.9.0)(@playwright/test@1.51.1)(babel-plugin-macros@3.1.0)(babel-plugin-react-compiler@0.0.0-experimental-1371fcb-20260227)(react-dom@19.3.0-canary-5e9eedb5-20260312(react@19.3.0-canary-5e9eedb5-20260312))(react@19.3.0-canary-5e9eedb5-20260312)(sass@1.77.8))(react-dom@19.3.0-canary-5e9eedb5-20260312(react@19.3.0-canary-5e9eedb5-20260312))(react@19.3.0-canary-5e9eedb5-20260312) + version: 15.7.12(@types/react@19.2.10)(next@15.5.8(@babel/core@7.26.10)(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(babel-plugin-macros@3.1.0)(babel-plugin-react-compiler@0.0.0-experimental-1371fcb-20260227)(react-dom@19.3.0-canary-5e9eedb5-20260312(react@19.3.0-canary-5e9eedb5-20260312))(react@19.3.0-canary-5e9eedb5-20260312)(sass@1.77.8))(react-dom@19.3.0-canary-5e9eedb5-20260312(react@19.3.0-canary-5e9eedb5-20260312))(react@19.3.0-canary-5e9eedb5-20260312) fumadocs-mdx: specifier: 11.10.0 - version: 11.10.0(fumadocs-core@15.7.12(@types/react@19.2.10)(next@15.5.8(@babel/core@7.26.10)(@opentelemetry/api@1.9.0)(@playwright/test@1.51.1)(babel-plugin-macros@3.1.0)(babel-plugin-react-compiler@0.0.0-experimental-1371fcb-20260227)(react-dom@19.3.0-canary-5e9eedb5-20260312(react@19.3.0-canary-5e9eedb5-20260312))(react@19.3.0-canary-5e9eedb5-20260312)(sass@1.77.8))(react-dom@19.3.0-canary-5e9eedb5-20260312(react@19.3.0-canary-5e9eedb5-20260312))(react@19.3.0-canary-5e9eedb5-20260312))(next@15.5.8(@babel/core@7.26.10)(@opentelemetry/api@1.9.0)(@playwright/test@1.51.1)(babel-plugin-macros@3.1.0)(babel-plugin-react-compiler@0.0.0-experimental-1371fcb-20260227)(react-dom@19.3.0-canary-5e9eedb5-20260312(react@19.3.0-canary-5e9eedb5-20260312))(react@19.3.0-canary-5e9eedb5-20260312)(sass@1.77.8))(react@19.3.0-canary-5e9eedb5-20260312) + version: 11.10.0(fumadocs-core@15.7.12(@types/react@19.2.10)(next@15.5.8(@babel/core@7.26.10)(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(babel-plugin-macros@3.1.0)(babel-plugin-react-compiler@0.0.0-experimental-1371fcb-20260227)(react-dom@19.3.0-canary-5e9eedb5-20260312(react@19.3.0-canary-5e9eedb5-20260312))(react@19.3.0-canary-5e9eedb5-20260312)(sass@1.77.8))(react-dom@19.3.0-canary-5e9eedb5-20260312(react@19.3.0-canary-5e9eedb5-20260312))(react@19.3.0-canary-5e9eedb5-20260312))(next@15.5.8(@babel/core@7.26.10)(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(babel-plugin-macros@3.1.0)(babel-plugin-react-compiler@0.0.0-experimental-1371fcb-20260227)(react-dom@19.3.0-canary-5e9eedb5-20260312(react@19.3.0-canary-5e9eedb5-20260312))(react@19.3.0-canary-5e9eedb5-20260312)(sass@1.77.8))(react@19.3.0-canary-5e9eedb5-20260312) fumadocs-ui: specifier: 15.7.12 - version: 15.7.12(@types/react-dom@19.2.3(@types/react@19.2.10))(@types/react@19.2.10)(next@15.5.8(@babel/core@7.26.10)(@opentelemetry/api@1.9.0)(@playwright/test@1.51.1)(babel-plugin-macros@3.1.0)(babel-plugin-react-compiler@0.0.0-experimental-1371fcb-20260227)(react-dom@19.3.0-canary-5e9eedb5-20260312(react@19.3.0-canary-5e9eedb5-20260312))(react@19.3.0-canary-5e9eedb5-20260312)(sass@1.77.8))(react-dom@19.3.0-canary-5e9eedb5-20260312(react@19.3.0-canary-5e9eedb5-20260312))(react@19.3.0-canary-5e9eedb5-20260312)(tailwindcss@4.1.13) + version: 15.7.12(@types/react-dom@19.2.3(@types/react@19.2.10))(@types/react@19.2.10)(next@15.5.8(@babel/core@7.26.10)(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(babel-plugin-macros@3.1.0)(babel-plugin-react-compiler@0.0.0-experimental-1371fcb-20260227)(react-dom@19.3.0-canary-5e9eedb5-20260312(react@19.3.0-canary-5e9eedb5-20260312))(react@19.3.0-canary-5e9eedb5-20260312)(sass@1.77.8))(react-dom@19.3.0-canary-5e9eedb5-20260312(react@19.3.0-canary-5e9eedb5-20260312))(react@19.3.0-canary-5e9eedb5-20260312)(tailwindcss@4.1.13) next: specifier: 15.5.8 - version: 15.5.8(@babel/core@7.26.10)(@opentelemetry/api@1.9.0)(@playwright/test@1.51.1)(babel-plugin-macros@3.1.0)(babel-plugin-react-compiler@0.0.0-experimental-1371fcb-20260227)(react-dom@19.3.0-canary-5e9eedb5-20260312(react@19.3.0-canary-5e9eedb5-20260312))(react@19.3.0-canary-5e9eedb5-20260312)(sass@1.77.8) + version: 15.5.8(@babel/core@7.26.10)(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(babel-plugin-macros@3.1.0)(babel-plugin-react-compiler@0.0.0-experimental-1371fcb-20260227)(react-dom@19.3.0-canary-5e9eedb5-20260312(react@19.3.0-canary-5e9eedb5-20260312))(react@19.3.0-canary-5e9eedb5-20260312)(sass@1.77.8) react: specifier: npm:react@19.3.0-canary-5e9eedb5-20260312 version: 19.3.0-canary-5e9eedb5-20260312 @@ -831,8 +831,8 @@ importers: specifier: workspace:* version: link:../../packages/next playwright: - specifier: ^1.40.0 - version: 1.48.0 + specifier: 1.58.2 + version: 1.58.2 rimraf: specifier: 6.0.1 version: 6.0.1 @@ -1237,8 +1237,8 @@ importers: specifier: 1.6.0 version: 1.6.0 '@playwright/test': - specifier: 1.51.1 - version: 1.51.1 + specifier: 1.58.2 + version: 1.58.2 '@rspack/core': specifier: 1.6.7 version: 1.6.7(@swc/helpers@0.5.15) @@ -1412,7 +1412,7 @@ importers: version: 3.0.0 axe-playwright: specifier: 2.0.3 - version: 2.0.3(playwright@1.51.1) + version: 2.0.3(playwright@1.58.2) babel-loader: specifier: 10.0.0 version: 10.0.0(@babel/core@7.26.10)(webpack@5.98.0(@swc/core@1.11.24(@swc/helpers@0.5.15))(esbuild@0.25.9)) @@ -1872,11 +1872,10 @@ importers: version: 0.7.3 packages/next-playwright: - dependencies: - '@playwright/test': - specifier: '>=1.0.0' - version: 1.51.1 devDependencies: + '@playwright/test': + specifier: 1.58.2 + version: 1.58.2 typescript: specifier: 5.9.2 version: 5.9.2 @@ -2040,8 +2039,8 @@ importers: specifier: ^2.0.5 version: 2.0.5 playwright-chromium: - specifier: 1.48.0 - version: 1.48.0 + specifier: 1.58.2 + version: 1.58.2 split2: specifier: ^4.2.0 version: 4.2.0 @@ -5032,8 +5031,8 @@ packages: resolution: {integrity: sha512-cq8o4cWH0ibXh9VGi5P20Tu9XF/0fFXl9EUinr9QfTM7a7p0oTA4iJRCQWppXR1Pg8dSM0UCItCkPwsk9qWWYA==} engines: {node: ^12.20.0 || ^14.18.0 || >=16.0.0} - '@playwright/test@1.51.1': - resolution: {integrity: sha512-nM+kEaTSAoVlXmMPH10017vn3FSiFqr/bh4fKg9vmAdMfd9SDqRZNvPSiAHADc/itWak+qPvMPZQOPwCBW7k7Q==} + '@playwright/test@1.58.2': + resolution: {integrity: sha512-akea+6bHYBBfA9uQqSYmlJXn61cTa+jbO87xVLCWbTqbWadRVmhxlXATaOjOgcBaWU4ePo0wB41KMFv3o35IXA==} engines: {node: '>=18'} hasBin: true @@ -15032,13 +15031,8 @@ packages: resolution: {integrity: sha512-nDywThFk1i4BQK4twPQ6TA4RT8bDY96yeuCVBWL3ePARCiEKDRSrNGbFIgUJpLp+XeIR65v8ra7WuJOFUBtkMA==} engines: {node: '>=8'} - playwright-chromium@1.48.0: - resolution: {integrity: sha512-jPFKhB4+zGEcAwc+h1mTLa8L08Rb+FwGKFqd/o7mWSGyxAtwBu5R4QxSG/a8KcHbewOj4DLBUVVK2N/uKrYmiQ==} - engines: {node: '>=18'} - hasBin: true - - playwright-core@1.48.0: - resolution: {integrity: sha512-RBvzjM9rdpP7UUFrQzRwR8L/xR4HyC1QXMzGYTbf1vjw25/ya9NRAVnXi/0fvFopjebvyPzsmoK58xxeEOaVvA==} + playwright-chromium@1.58.2: + resolution: {integrity: sha512-SCoQ3hjBs7FfO46CoOtgAUg77BuYwCni1bzQgm47IUyLBTipnGkLxLnaUNRKXvPYO4hAyt8++Z6wVShVnhrzmw==} engines: {node: '>=18'} hasBin: true @@ -15047,13 +15041,13 @@ packages: engines: {node: '>=18'} hasBin: true - playwright@1.48.0: - resolution: {integrity: sha512-qPqFaMEHuY/ug8o0uteYJSRfMGFikhUysk8ZvAtfKmUK3kc/6oNl/y3EczF8OFGYIi/Ex2HspMfzYArk6+XQSA==} + playwright-core@1.58.2: + resolution: {integrity: sha512-yZkEtftgwS8CsfYo7nm0KE8jsvm6i/PTgVtB8DL726wNf6H2IMsDuxCpJj59KDaxCtSnrWan2AeDqM7JBaultg==} engines: {node: '>=18'} hasBin: true - playwright@1.51.1: - resolution: {integrity: sha512-kkx+MB2KQRkyxjYPc3a0wLZZoDczmppyGJIvQ43l+aZihkaVvmu/21kiyaHeHjiFxjxNNFnUncKmcGIyOojsaw==} + playwright@1.58.2: + resolution: {integrity: sha512-vA30H8Nvkq/cPBnNw4Q8TWz1EJyqgpuinBcHET0YVJVFldr8JDNiU9LaWAE1KqSkRYazuaBhTpB5ZzShOezQ6A==} engines: {node: '>=18'} hasBin: true @@ -22595,9 +22589,9 @@ snapshots: '@pkgr/core@0.1.1': {} - '@playwright/test@1.51.1': + '@playwright/test@1.58.2': dependencies: - playwright: 1.51.1 + playwright: 1.58.2 '@polka/url@1.0.0-next.11': {} @@ -23949,7 +23943,7 @@ snapshots: jest-serializer-html: 7.1.0 jest-watch-typeahead: 2.2.2(jest@29.7.0(@types/node@20.17.6(patch_hash=rvl3vkomen3tospgr67bzubfyu))(babel-plugin-macros@3.1.0)) nyc: 15.1.0 - playwright: 1.48.0 + playwright: 1.58.2 storybook: 8.6.0(prettier@3.6.2) transitivePeerDependencies: - '@swc/helpers' @@ -25615,14 +25609,14 @@ snapshots: axe-core: 4.10.0 mustache: 4.2.0 - axe-playwright@2.0.3(playwright@1.51.1): + axe-playwright@2.0.3(playwright@1.58.2): dependencies: '@types/junit-report-builder': 3.0.2 axe-core: 4.10.0 axe-html-reporter: 2.2.11(axe-core@4.10.0) junit-report-builder: 5.1.1 picocolors: 1.1.1 - playwright: 1.51.1 + playwright: 1.58.2 axios@0.26.1: dependencies: @@ -29628,7 +29622,7 @@ snapshots: fsevents@2.3.3: optional: true - fumadocs-core@15.7.12(@types/react@19.2.10)(next@15.5.8(@babel/core@7.26.10)(@opentelemetry/api@1.9.0)(@playwright/test@1.51.1)(babel-plugin-macros@3.1.0)(babel-plugin-react-compiler@0.0.0-experimental-1371fcb-20260227)(react-dom@19.3.0-canary-5e9eedb5-20260312(react@19.3.0-canary-5e9eedb5-20260312))(react@19.3.0-canary-5e9eedb5-20260312)(sass@1.77.8))(react-dom@19.3.0-canary-5e9eedb5-20260312(react@19.3.0-canary-5e9eedb5-20260312))(react@19.3.0-canary-5e9eedb5-20260312): + fumadocs-core@15.7.12(@types/react@19.2.10)(next@15.5.8(@babel/core@7.26.10)(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(babel-plugin-macros@3.1.0)(babel-plugin-react-compiler@0.0.0-experimental-1371fcb-20260227)(react-dom@19.3.0-canary-5e9eedb5-20260312(react@19.3.0-canary-5e9eedb5-20260312))(react@19.3.0-canary-5e9eedb5-20260312)(sass@1.77.8))(react-dom@19.3.0-canary-5e9eedb5-20260312(react@19.3.0-canary-5e9eedb5-20260312))(react@19.3.0-canary-5e9eedb5-20260312): dependencies: '@formatjs/intl-localematcher': 0.6.1 '@orama/orama': 3.1.13 @@ -29649,20 +29643,20 @@ snapshots: unist-util-visit: 5.0.0 optionalDependencies: '@types/react': 19.2.10 - next: 15.5.8(@babel/core@7.26.10)(@opentelemetry/api@1.9.0)(@playwright/test@1.51.1)(babel-plugin-macros@3.1.0)(babel-plugin-react-compiler@0.0.0-experimental-1371fcb-20260227)(react-dom@19.3.0-canary-5e9eedb5-20260312(react@19.3.0-canary-5e9eedb5-20260312))(react@19.3.0-canary-5e9eedb5-20260312)(sass@1.77.8) + next: 15.5.8(@babel/core@7.26.10)(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(babel-plugin-macros@3.1.0)(babel-plugin-react-compiler@0.0.0-experimental-1371fcb-20260227)(react-dom@19.3.0-canary-5e9eedb5-20260312(react@19.3.0-canary-5e9eedb5-20260312))(react@19.3.0-canary-5e9eedb5-20260312)(sass@1.77.8) react: 19.3.0-canary-5e9eedb5-20260312 react-dom: 19.3.0-canary-5e9eedb5-20260312(react@19.3.0-canary-5e9eedb5-20260312) transitivePeerDependencies: - supports-color - fumadocs-mdx@11.10.0(fumadocs-core@15.7.12(@types/react@19.2.10)(next@15.5.8(@babel/core@7.26.10)(@opentelemetry/api@1.9.0)(@playwright/test@1.51.1)(babel-plugin-macros@3.1.0)(babel-plugin-react-compiler@0.0.0-experimental-1371fcb-20260227)(react-dom@19.3.0-canary-5e9eedb5-20260312(react@19.3.0-canary-5e9eedb5-20260312))(react@19.3.0-canary-5e9eedb5-20260312)(sass@1.77.8))(react-dom@19.3.0-canary-5e9eedb5-20260312(react@19.3.0-canary-5e9eedb5-20260312))(react@19.3.0-canary-5e9eedb5-20260312))(next@15.5.8(@babel/core@7.26.10)(@opentelemetry/api@1.9.0)(@playwright/test@1.51.1)(babel-plugin-macros@3.1.0)(babel-plugin-react-compiler@0.0.0-experimental-1371fcb-20260227)(react-dom@19.3.0-canary-5e9eedb5-20260312(react@19.3.0-canary-5e9eedb5-20260312))(react@19.3.0-canary-5e9eedb5-20260312)(sass@1.77.8))(react@19.3.0-canary-5e9eedb5-20260312): + fumadocs-mdx@11.10.0(fumadocs-core@15.7.12(@types/react@19.2.10)(next@15.5.8(@babel/core@7.26.10)(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(babel-plugin-macros@3.1.0)(babel-plugin-react-compiler@0.0.0-experimental-1371fcb-20260227)(react-dom@19.3.0-canary-5e9eedb5-20260312(react@19.3.0-canary-5e9eedb5-20260312))(react@19.3.0-canary-5e9eedb5-20260312)(sass@1.77.8))(react-dom@19.3.0-canary-5e9eedb5-20260312(react@19.3.0-canary-5e9eedb5-20260312))(react@19.3.0-canary-5e9eedb5-20260312))(next@15.5.8(@babel/core@7.26.10)(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(babel-plugin-macros@3.1.0)(babel-plugin-react-compiler@0.0.0-experimental-1371fcb-20260227)(react-dom@19.3.0-canary-5e9eedb5-20260312(react@19.3.0-canary-5e9eedb5-20260312))(react@19.3.0-canary-5e9eedb5-20260312)(sass@1.77.8))(react@19.3.0-canary-5e9eedb5-20260312): dependencies: '@mdx-js/mdx': 3.1.1 '@standard-schema/spec': 1.0.0 chokidar: 4.0.3 esbuild: 0.25.9 estree-util-value-to-estree: 3.5.0 - fumadocs-core: 15.7.12(@types/react@19.2.10)(next@15.5.8(@babel/core@7.26.10)(@opentelemetry/api@1.9.0)(@playwright/test@1.51.1)(babel-plugin-macros@3.1.0)(babel-plugin-react-compiler@0.0.0-experimental-1371fcb-20260227)(react-dom@19.3.0-canary-5e9eedb5-20260312(react@19.3.0-canary-5e9eedb5-20260312))(react@19.3.0-canary-5e9eedb5-20260312)(sass@1.77.8))(react-dom@19.3.0-canary-5e9eedb5-20260312(react@19.3.0-canary-5e9eedb5-20260312))(react@19.3.0-canary-5e9eedb5-20260312) + fumadocs-core: 15.7.12(@types/react@19.2.10)(next@15.5.8(@babel/core@7.26.10)(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(babel-plugin-macros@3.1.0)(babel-plugin-react-compiler@0.0.0-experimental-1371fcb-20260227)(react-dom@19.3.0-canary-5e9eedb5-20260312(react@19.3.0-canary-5e9eedb5-20260312))(react@19.3.0-canary-5e9eedb5-20260312)(sass@1.77.8))(react-dom@19.3.0-canary-5e9eedb5-20260312(react@19.3.0-canary-5e9eedb5-20260312))(react@19.3.0-canary-5e9eedb5-20260312) js-yaml: 4.1.0 lru-cache: 11.2.1 picocolors: 1.1.1 @@ -29674,12 +29668,12 @@ snapshots: unist-util-visit: 5.0.0 zod: 4.1.13 optionalDependencies: - next: 15.5.8(@babel/core@7.26.10)(@opentelemetry/api@1.9.0)(@playwright/test@1.51.1)(babel-plugin-macros@3.1.0)(babel-plugin-react-compiler@0.0.0-experimental-1371fcb-20260227)(react-dom@19.3.0-canary-5e9eedb5-20260312(react@19.3.0-canary-5e9eedb5-20260312))(react@19.3.0-canary-5e9eedb5-20260312)(sass@1.77.8) + next: 15.5.8(@babel/core@7.26.10)(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(babel-plugin-macros@3.1.0)(babel-plugin-react-compiler@0.0.0-experimental-1371fcb-20260227)(react-dom@19.3.0-canary-5e9eedb5-20260312(react@19.3.0-canary-5e9eedb5-20260312))(react@19.3.0-canary-5e9eedb5-20260312)(sass@1.77.8) react: 19.3.0-canary-5e9eedb5-20260312 transitivePeerDependencies: - supports-color - fumadocs-ui@15.7.12(@types/react-dom@19.2.3(@types/react@19.2.10))(@types/react@19.2.10)(next@15.5.8(@babel/core@7.26.10)(@opentelemetry/api@1.9.0)(@playwright/test@1.51.1)(babel-plugin-macros@3.1.0)(babel-plugin-react-compiler@0.0.0-experimental-1371fcb-20260227)(react-dom@19.3.0-canary-5e9eedb5-20260312(react@19.3.0-canary-5e9eedb5-20260312))(react@19.3.0-canary-5e9eedb5-20260312)(sass@1.77.8))(react-dom@19.3.0-canary-5e9eedb5-20260312(react@19.3.0-canary-5e9eedb5-20260312))(react@19.3.0-canary-5e9eedb5-20260312)(tailwindcss@4.1.13): + fumadocs-ui@15.7.12(@types/react-dom@19.2.3(@types/react@19.2.10))(@types/react@19.2.10)(next@15.5.8(@babel/core@7.26.10)(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(babel-plugin-macros@3.1.0)(babel-plugin-react-compiler@0.0.0-experimental-1371fcb-20260227)(react-dom@19.3.0-canary-5e9eedb5-20260312(react@19.3.0-canary-5e9eedb5-20260312))(react@19.3.0-canary-5e9eedb5-20260312)(sass@1.77.8))(react-dom@19.3.0-canary-5e9eedb5-20260312(react@19.3.0-canary-5e9eedb5-20260312))(react@19.3.0-canary-5e9eedb5-20260312)(tailwindcss@4.1.13): dependencies: '@radix-ui/react-accordion': 1.2.12(@types/react-dom@19.2.3(@types/react@19.2.10))(@types/react@19.2.10)(react-dom@19.3.0-canary-5e9eedb5-20260312(react@19.3.0-canary-5e9eedb5-20260312))(react@19.3.0-canary-5e9eedb5-20260312) '@radix-ui/react-collapsible': 1.1.12(@types/react-dom@19.2.3(@types/react@19.2.10))(@types/react@19.2.10)(react-dom@19.3.0-canary-5e9eedb5-20260312(react@19.3.0-canary-5e9eedb5-20260312))(react@19.3.0-canary-5e9eedb5-20260312) @@ -29692,7 +29686,7 @@ snapshots: '@radix-ui/react-slot': 1.2.3(@types/react@19.2.10)(react@19.3.0-canary-5e9eedb5-20260312) '@radix-ui/react-tabs': 1.1.13(@types/react-dom@19.2.3(@types/react@19.2.10))(@types/react@19.2.10)(react-dom@19.3.0-canary-5e9eedb5-20260312(react@19.3.0-canary-5e9eedb5-20260312))(react@19.3.0-canary-5e9eedb5-20260312) class-variance-authority: 0.7.1 - fumadocs-core: 15.7.12(@types/react@19.2.10)(next@15.5.8(@babel/core@7.26.10)(@opentelemetry/api@1.9.0)(@playwright/test@1.51.1)(babel-plugin-macros@3.1.0)(babel-plugin-react-compiler@0.0.0-experimental-1371fcb-20260227)(react-dom@19.3.0-canary-5e9eedb5-20260312(react@19.3.0-canary-5e9eedb5-20260312))(react@19.3.0-canary-5e9eedb5-20260312)(sass@1.77.8))(react-dom@19.3.0-canary-5e9eedb5-20260312(react@19.3.0-canary-5e9eedb5-20260312))(react@19.3.0-canary-5e9eedb5-20260312) + fumadocs-core: 15.7.12(@types/react@19.2.10)(next@15.5.8(@babel/core@7.26.10)(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(babel-plugin-macros@3.1.0)(babel-plugin-react-compiler@0.0.0-experimental-1371fcb-20260227)(react-dom@19.3.0-canary-5e9eedb5-20260312(react@19.3.0-canary-5e9eedb5-20260312))(react@19.3.0-canary-5e9eedb5-20260312)(sass@1.77.8))(react-dom@19.3.0-canary-5e9eedb5-20260312(react@19.3.0-canary-5e9eedb5-20260312))(react@19.3.0-canary-5e9eedb5-20260312) lodash.merge: 4.6.2 next-themes: 0.4.6(react-dom@19.3.0-canary-5e9eedb5-20260312(react@19.3.0-canary-5e9eedb5-20260312))(react@19.3.0-canary-5e9eedb5-20260312) postcss-selector-parser: 7.1.0 @@ -29703,7 +29697,7 @@ snapshots: tailwind-merge: 3.3.1 optionalDependencies: '@types/react': 19.2.10 - next: 15.5.8(@babel/core@7.26.10)(@opentelemetry/api@1.9.0)(@playwright/test@1.51.1)(babel-plugin-macros@3.1.0)(babel-plugin-react-compiler@0.0.0-experimental-1371fcb-20260227)(react-dom@19.3.0-canary-5e9eedb5-20260312(react@19.3.0-canary-5e9eedb5-20260312))(react@19.3.0-canary-5e9eedb5-20260312)(sass@1.77.8) + next: 15.5.8(@babel/core@7.26.10)(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(babel-plugin-macros@3.1.0)(babel-plugin-react-compiler@0.0.0-experimental-1371fcb-20260227)(react-dom@19.3.0-canary-5e9eedb5-20260312(react@19.3.0-canary-5e9eedb5-20260312))(react@19.3.0-canary-5e9eedb5-20260312)(sass@1.77.8) tailwindcss: 4.1.13 transitivePeerDependencies: - '@mixedbread/sdk' @@ -31659,7 +31653,7 @@ snapshots: jest-process-manager: 0.4.0(debug@4.1.1) jest-runner: 29.7.0 nyc: 15.1.0 - playwright-core: 1.48.0 + playwright-core: 1.51.1 rimraf: 3.0.2 uuid: 8.3.2 transitivePeerDependencies: @@ -34031,7 +34025,7 @@ snapshots: next-tick@1.0.0: {} - next@15.5.8(@babel/core@7.26.10)(@opentelemetry/api@1.9.0)(@playwright/test@1.51.1)(babel-plugin-macros@3.1.0)(babel-plugin-react-compiler@0.0.0-experimental-1371fcb-20260227)(react-dom@19.3.0-canary-5e9eedb5-20260312(react@19.3.0-canary-5e9eedb5-20260312))(react@19.3.0-canary-5e9eedb5-20260312)(sass@1.77.8): + next@15.5.8(@babel/core@7.26.10)(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(babel-plugin-macros@3.1.0)(babel-plugin-react-compiler@0.0.0-experimental-1371fcb-20260227)(react-dom@19.3.0-canary-5e9eedb5-20260312(react@19.3.0-canary-5e9eedb5-20260312))(react@19.3.0-canary-5e9eedb5-20260312)(sass@1.77.8): dependencies: '@next/env': 15.5.8 '@swc/helpers': 0.5.15 @@ -34050,7 +34044,7 @@ snapshots: '@next/swc-win32-arm64-msvc': 15.5.7 '@next/swc-win32-x64-msvc': 15.5.7 '@opentelemetry/api': 1.9.0 - '@playwright/test': 1.51.1 + '@playwright/test': 1.58.2 babel-plugin-react-compiler: 0.0.0-experimental-1371fcb-20260227 sass: 1.77.8 sharp: 0.34.4 @@ -34058,7 +34052,7 @@ snapshots: - '@babel/core' - babel-plugin-macros - next@16.0.8(@babel/core@7.26.10)(@opentelemetry/api@1.9.0)(@playwright/test@1.51.1)(babel-plugin-macros@3.1.0)(babel-plugin-react-compiler@0.0.0-experimental-1371fcb-20260227)(react-dom@19.3.0-canary-5e9eedb5-20260312(react@19.3.0-canary-5e9eedb5-20260312))(react@19.3.0-canary-5e9eedb5-20260312)(sass@1.77.8): + next@16.0.8(@babel/core@7.26.10)(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(babel-plugin-macros@3.1.0)(babel-plugin-react-compiler@0.0.0-experimental-1371fcb-20260227)(react-dom@19.3.0-canary-5e9eedb5-20260312(react@19.3.0-canary-5e9eedb5-20260312))(react@19.3.0-canary-5e9eedb5-20260312)(sass@1.77.8): dependencies: '@next/env': 16.0.8 '@swc/helpers': 0.5.15 @@ -34077,7 +34071,7 @@ snapshots: '@next/swc-win32-arm64-msvc': 16.0.8 '@next/swc-win32-x64-msvc': 16.0.8 '@opentelemetry/api': 1.9.0 - '@playwright/test': 1.51.1 + '@playwright/test': 1.58.2 babel-plugin-react-compiler: 0.0.0-experimental-1371fcb-20260227 sass: 1.77.8 sharp: 0.34.4 @@ -35128,23 +35122,17 @@ snapshots: dependencies: find-up: 3.0.0 - playwright-chromium@1.48.0: + playwright-chromium@1.58.2: dependencies: - playwright-core: 1.48.0 - - playwright-core@1.48.0: {} + playwright-core: 1.58.2 playwright-core@1.51.1: {} - playwright@1.48.0: - dependencies: - playwright-core: 1.48.0 - optionalDependencies: - fsevents: 2.3.2 + playwright-core@1.58.2: {} - playwright@1.51.1: + playwright@1.58.2: dependencies: - playwright-core: 1.51.1 + playwright-core: 1.58.2 optionalDependencies: fsevents: 2.3.2 diff --git a/test/development/basic/allowed-dev-origins.test.ts b/test/development/basic/allowed-dev-origins.test.ts index ee010875400eea..6b9473176185bb 100644 --- a/test/development/basic/allowed-dev-origins.test.ts +++ b/test/development/basic/allowed-dev-origins.test.ts @@ -116,7 +116,8 @@ describe.each([['', '/docs']])( const script = document.createElement('script') script.src = "${next.url}/_next/static/chunks/pages/_app.js" - script.onerror = (err) => { + script.onerror = (error) => { + console.error('script error', error) statusEl.innerText = 'error' } script.onload = () => { @@ -126,7 +127,13 @@ describe.each([['', '/docs']])( })()` // ensure direct port with mismatching port is blocked - const browser = await webdriver(`http://127.0.0.1:${port}`, '/about') + const browser = await webdriver( + `http://127.0.0.1:${port}`, + '/about', + { + permissions: ['local-network-access'], + } + ) await browser.eval(scriptSnippet) await retry(async () => { @@ -136,6 +143,7 @@ describe.each([['', '/docs']])( }) // ensure different host is blocked + // Requires local-network-access permission to send a request to next.url await browser.get(`https://example.vercel.sh/`) await browser.eval(scriptSnippet) @@ -145,7 +153,11 @@ describe.each([['', '/docs']])( ) }) - expect(next.cliOutput).toContain('Cross origin request detected from') + expect(next.cliOutput).toContain( + // We're not sending an Origin header + // TODO: redundant spacing + 'Cross origin request detected to' + ) } finally { server.close() } diff --git a/test/e2e/use-router-with-rewrites/use-router-with-rewrites.test.ts b/test/e2e/use-router-with-rewrites/use-router-with-rewrites.test.ts index 62617ac6deb678..b8a8d142629889 100644 --- a/test/e2e/use-router-with-rewrites/use-router-with-rewrites.test.ts +++ b/test/e2e/use-router-with-rewrites/use-router-with-rewrites.test.ts @@ -1,4 +1,5 @@ import { nextTestSetup } from 'e2e-utils' +import { retry } from 'next-test-utils' describe('use-router-with-rewrites', () => { const { next } = nextTestSetup({ @@ -68,30 +69,33 @@ describe('use-router-with-rewrites', () => { const browser = await next.browser('/rewrite-to-same-segment/0') await browser.elementById('router-push').click() + await retry(async () => { + expect(await browser.elementByCss('p').text()).toBe('1') + }) const url = new URL(await browser.url()) expect(url.pathname + url.search).toBe('/rewrite-to-same-segment/1') - - expect(await browser.elementByCss('p').text()).toBe('1') }) it('should preserve current pathname when using useRouter.replace with rewrites on dynamic route', async () => { const browser = await next.browser('/rewrite-to-same-segment/0') await browser.elementById('router-replace').click() + await retry(async () => { + expect(await browser.elementByCss('p').text()).toBe('2') + }) const url = new URL(await browser.url()) expect(url.pathname + url.search).toBe('/rewrite-to-same-segment/2') - - expect(await browser.elementByCss('p').text()).toBe('2') }) it('should preserve current pathname when using Link with rewrites on dynamic route', async () => { const browser = await next.browser('/rewrite-to-same-segment/0') await browser.elementByCss('a').click() + await retry(async () => { + expect(await browser.elementByCss('p').text()).toBe('3') + }) const url = new URL(await browser.url()) expect(url.pathname + url.search).toBe('/rewrite-to-same-segment/3') - - expect(await browser.elementByCss('p').text()).toBe('3') }) }) }) diff --git a/test/integration/dynamic-routing/test/index.test.ts b/test/integration/dynamic-routing/test/index.test.ts index 89d486c61816a9..b89fcf32fab1a5 100644 --- a/test/integration/dynamic-routing/test/index.test.ts +++ b/test/integration/dynamic-routing/test/index.test.ts @@ -17,6 +17,7 @@ import { check, getRedboxHeader, normalizeManifest, + retry, } from 'next-test-utils' import cheerio from 'cheerio' @@ -245,11 +246,12 @@ function runTests({ dev }) { await browser.eval('window.beforeNav = 1') await browser.elementByCss(`#${id}`).click() - await check(() => browser.eval('window.location.pathname'), pathname) - expect(JSON.parse(await browser.elementByCss('#query').text())).toEqual( - navQuery - ) + await retry(async () => { + expect(JSON.parse(await browser.elementByCss('#query').text())).toEqual( + navQuery + ) + }) expect(await browser.eval('window.location.pathname')).toBe(pathname) expect(await browser.eval('window.location.hash')).toBe(hash) expect( diff --git a/test/lib/browsers/playwright.ts b/test/lib/browsers/playwright.ts index cca74133c8a089..dbd80182139dc1 100644 --- a/test/lib/browsers/playwright.ts +++ b/test/lib/browsers/playwright.ts @@ -12,6 +12,7 @@ import { Locator, Request as PlaywrightRequest, Response as PlaywrightResponse, + BrowserContextOptions, } from 'playwright' import path from 'path' @@ -19,10 +20,13 @@ type EventType = 'request' | 'response' type PageLog = { source: string; message: string; args: unknown[] } +export type Permissions = BrowserContextOptions['permissions'] + let page: Page let browser: Browser | undefined let context: BrowserContext | undefined let contextHasJSEnabled: boolean = true +let contextPermissions: Permissions = undefined let pageLogs: Array | PageLog> = [] let websocketFrames: Array<{ payload: string | Buffer }> = [] @@ -167,7 +171,8 @@ export class Playwright { javaScriptEnabled: boolean, ignoreHTTPSErrors: boolean, headless: boolean, - userAgent: string | undefined + userAgent: string | undefined, + permissions: Permissions ) { let device @@ -182,7 +187,13 @@ export class Playwright { } if (browser) { - if (contextHasJSEnabled !== javaScriptEnabled) { + if ( + contextHasJSEnabled !== javaScriptEnabled || + // Even triggers on same set of permissions, but we don't want to deal + // with the complexity of diffing them, so we just always recreate the + // context when permissions are set. + contextPermissions !== permissions + ) { // If we have switched from having JS enable/disabled we need to recreate the context. await teardown(this.teardownTracing.bind(this)) await context?.close() @@ -192,8 +203,10 @@ export class Playwright { ignoreHTTPSErrors, ...(userAgent ? { userAgent } : {}), ...device, + permissions, }) contextHasJSEnabled = javaScriptEnabled + contextPermissions = permissions } return } @@ -205,6 +218,7 @@ export class Playwright { ignoreHTTPSErrors, ...(userAgent ? { userAgent } : {}), ...device, + permissions, }) contextHasJSEnabled = javaScriptEnabled } @@ -214,14 +228,16 @@ export class Playwright { await page?.close() } - async launchBrowser(browserName: string, launchOptions: Record) { + async launchBrowser( + browserName: string, + launchOptions: { headless: boolean } + ) { if (browserName === 'safari') { return await webkit.launch(launchOptions) } else if (browserName === 'firefox') { return await firefox.launch({ ...launchOptions, firefoxUserPrefs: { - ...launchOptions.firefoxUserPrefs, // The "fission.webContentIsolationStrategy" pref must be // set to 1 on Firefox due to the bug where a new history // state is pushed on a page reload. @@ -231,9 +247,13 @@ export class Playwright { }, }) } else { + let launchArgs: string[] = [] + if (!launchOptions.headless) { + launchArgs.push('--auto-open-devtools-for-tabs') + } return await chromium.launch({ - devtools: !launchOptions.headless, ...launchOptions, + args: launchArgs, ignoreDefaultArgs: ['--disable-back-forward-cache'], }) } diff --git a/test/lib/next-webdriver.ts b/test/lib/next-webdriver.ts index c136a641f5bf50..8be60416ee0297 100644 --- a/test/lib/next-webdriver.ts +++ b/test/lib/next-webdriver.ts @@ -1,6 +1,7 @@ import { debugPrint, getFullUrl } from 'next-test-utils' import os from 'os' import { + Permissions, Playwright, PlaywrightNavigationWaitUntil, } from './browsers/playwright' @@ -48,6 +49,7 @@ if (typeof afterAll === 'function') { } export interface WebdriverOptions { + permissions?: Permissions /** * whether to wait for React hydration to finish */ @@ -121,6 +123,7 @@ export default async function webdriver( extraHTTPHeaders, locale, disableJavaScript, + permissions, ignoreHTTPSErrors, headless, cpuThrottleRate, @@ -141,7 +144,8 @@ export default async function webdriver( Boolean(ignoreHTTPSErrors), // allow headless to be overwritten for a particular test typeof headless !== 'undefined' ? headless : !!process.env.HEADLESS, - userAgent + userAgent, + permissions ) ;(global as any).browserName = browserName diff --git a/turbopack/packages/devlow-bench/package.json b/turbopack/packages/devlow-bench/package.json index 2358382d0ef919..2c2776792b3175 100644 --- a/turbopack/packages/devlow-bench/package.json +++ b/turbopack/packages/devlow-bench/package.json @@ -48,7 +48,7 @@ "minimist": "^1.2.8", "picocolors": "1.0.1", "pidusage-tree": "^2.0.5", - "playwright-chromium": "1.48.0", + "playwright-chromium": "1.58.2", "split2": "^4.2.0", "tree-kill": "^1.2.2" } diff --git a/turbopack/packages/devlow-bench/src/browser.ts b/turbopack/packages/devlow-bench/src/browser.ts index f7ef8c4b6862b5..74e4910e24afaa 100644 --- a/turbopack/packages/devlow-bench/src/browser.ts +++ b/turbopack/packages/devlow-bench/src/browser.ts @@ -348,7 +348,7 @@ export async function newBrowserSession(options: { }): Promise { const browser = await chromium.launch({ headless: options.headless ?? process.env.HEADLESS !== 'false', - devtools: true, + args: options.headless ? undefined : ['--auto-open-devtools-for-tabs'], timeout: 60000, }) const context = await browser.newContext({ From fa32daad8d7d0cfff6de16d1fc13fe8bd2d1802e Mon Sep 17 00:00:00 2001 From: Jiwon Choi Date: Mon, 16 Mar 2026 23:57:11 +0100 Subject: [PATCH 4/8] Add `unstable_catchError()` API for custom error boundary (#89688) This PR adds `unstable_catchError()` API for a granular custom error boundary. The error component generated by this API is not a true component format, as the wrapper provides additional args. Therefore, the API is a function call by design rather than a boundary component to avoid merging the `errorInfo` with the user-provided props from the wrapper. This API works for both the App/Pages Router. However, `retry()` is not allowed in Pages Router as it depends on `router.refresh()`, and will throw. Also, it's exported from `next/error` as there was already an `Error` component for the [Pages Router](https://nextjs.org/docs/pages/building-your-application/routing/custom-error#reusing-the-built-in-error-page). ### DevTools CleanShot 2026-03-13 at 03 02 46@2x Docs to follow up: https://github.com/vercel/next.js/pull/89847 Closes NAR-768 --------- Co-authored-by: Josh Story --- crates/next-core/src/next_import_map.rs | 14 +- crates/next-core/src/pages_structure.rs | 2 +- .../src/transforms/react_server_components.rs | 1 + packages/next/error.d.ts | 4 +- packages/next/error.js | 8 + packages/next/errors.json | 4 +- packages/next/src/api/error.react-server.ts | 7 + packages/next/src/api/error.ts | 6 + .../next/src/build/create-compiler-aliases.ts | 2 + .../components/builtin/global-error.tsx | 5 +- .../src/client/components/catch-error.tsx | 221 ++++++++++++++++++ .../src/client/components/error-boundary.tsx | 12 +- .../client/components/handle-isr-error.tsx | 4 +- .../acceptance-app/rsc-build-errors.test.ts | 60 +++++ .../client-component/catch-error-wrapper.tsx | 24 ++ .../app/client-component/layout.tsx | 5 + .../catch-error/app/client-component/page.tsx | 20 ++ test/e2e/app-dir/catch-error/app/layout.tsx | 11 + .../server-component/catch-error-wrapper.tsx | 23 ++ .../app/server-component/layout.tsx | 7 + .../catch-error/app/server-component/page.tsx | 23 ++ .../catch-error-react-compiler.test.ts | 113 +++++++++ .../app-dir/catch-error/catch-error.test.ts | 107 +++++++++ .../catch-error/pages/pages-router.tsx | 60 +++++ 24 files changed, 729 insertions(+), 14 deletions(-) create mode 100644 packages/next/src/api/error.react-server.ts create mode 100644 packages/next/src/api/error.ts create mode 100644 packages/next/src/client/components/catch-error.tsx create mode 100644 test/e2e/app-dir/catch-error/app/client-component/catch-error-wrapper.tsx create mode 100644 test/e2e/app-dir/catch-error/app/client-component/layout.tsx create mode 100644 test/e2e/app-dir/catch-error/app/client-component/page.tsx create mode 100644 test/e2e/app-dir/catch-error/app/layout.tsx create mode 100644 test/e2e/app-dir/catch-error/app/server-component/catch-error-wrapper.tsx create mode 100644 test/e2e/app-dir/catch-error/app/server-component/layout.tsx create mode 100644 test/e2e/app-dir/catch-error/app/server-component/page.tsx create mode 100644 test/e2e/app-dir/catch-error/catch-error-react-compiler.test.ts create mode 100644 test/e2e/app-dir/catch-error/catch-error.test.ts create mode 100644 test/e2e/app-dir/catch-error/pages/pages-router.tsx diff --git a/crates/next-core/src/next_import_map.rs b/crates/next-core/src/next_import_map.rs index 573c374c64fae9..8dfb95eb910374 100644 --- a/crates/next-core/src/next_import_map.rs +++ b/crates/next-core/src/next_import_map.rs @@ -71,7 +71,16 @@ pub async fn get_next_client_import_map( .await?; match &ty { - ClientContextType::Pages { .. } => {} + ClientContextType::Pages { .. } => { + // Resolve next/error to the ESM entry point so the bundler can + // tree-shake the error-boundary dependency chain from Pages + // Router bundles that only use the default Error component. + insert_exact_alias_or_js( + &mut import_map, + rcstr!("next/error"), + request_to_import_mapping(project_path.clone(), rcstr!("next/dist/api/error")), + ); + } ClientContextType::App { app_dir } => { // Keep in sync with file:///./../../../packages/next/src/lib/needs-experimental-react.ts let taint = *next_config.enable_taint().await?; @@ -404,6 +413,7 @@ pub async fn get_next_edge_import_map( rcstr!("next/app") => rcstr!("next/dist/api/app"), rcstr!("next/document") => rcstr!("next/dist/api/document"), rcstr!("next/dynamic") => rcstr!("next/dist/api/dynamic"), + rcstr!("next/error") => rcstr!("next/dist/api/error"), rcstr!("next/form") => rcstr!("next/dist/api/form"), rcstr!("next/head") => rcstr!("next/dist/api/head"), rcstr!("next/headers") => rcstr!("next/dist/api/headers"), @@ -953,6 +963,7 @@ async fn apply_vendored_react_aliases_server( if react_condition == "server" { // This is used in the server runtime to import React Server Components. alias.extend(fxindexmap! { + rcstr!("next/error") => rcstr!("next/dist/api/error.react-server"), rcstr!("next/navigation") => rcstr!("next/dist/api/navigation.react-server"), rcstr!("next/link") => rcstr!("next/dist/client/app-dir/link.react-server"), }); @@ -983,6 +994,7 @@ async fn rsc_aliases( if ty.should_use_react_server_condition() { // This is used in the server runtime to import React Server Components. alias.extend(fxindexmap! { + rcstr!("next/error") => rcstr!("next/dist/api/error.react-server"), rcstr!("next/navigation") => rcstr!("next/dist/api/navigation.react-server"), rcstr!("next/link") => rcstr!("next/dist/client/app-dir/link.react-server"), }); diff --git a/crates/next-core/src/pages_structure.rs b/crates/next-core/src/pages_structure.rs index 3c05755771fa13..9957f236c8c188 100644 --- a/crates/next-core/src/pages_structure.rs +++ b/crates/next-core/src/pages_structure.rs @@ -298,7 +298,7 @@ async fn get_pages_structure_for_root_directory( PagesStructureItem::new( pages_path.join("_error")?, page_extensions, - Some(next_package.join("error.js")?), + Some(next_package.join("dist/pages/_error.js")?), error_router_path.clone(), error_router_path, ) diff --git a/crates/next-custom-transforms/src/transforms/react_server_components.rs b/crates/next-custom-transforms/src/transforms/react_server_components.rs index 4ee1c2671612e1..91ab3478b49779 100644 --- a/crates/next-custom-transforms/src/transforms/react_server_components.rs +++ b/crates/next-custom-transforms/src/transforms/react_server_components.rs @@ -678,6 +678,7 @@ impl ReactServerComponentValidator { "useFormState", ], ), + (atom!("next/error").into(), vec!["unstable_catchError"]), ( atom!("next/navigation").into(), vec![ diff --git a/packages/next/error.d.ts b/packages/next/error.d.ts index 100590d637d4dd..1c621105395a48 100644 --- a/packages/next/error.d.ts +++ b/packages/next/error.d.ts @@ -1,3 +1,3 @@ -import Error from './dist/pages/_error' -export * from './dist/pages/_error' +import Error from './dist/api/error' +export * from './dist/api/error' export default Error diff --git a/packages/next/error.js b/packages/next/error.js index 899cd046627100..63681f93e0b5ab 100644 --- a/packages/next/error.js +++ b/packages/next/error.js @@ -1 +1,9 @@ module.exports = require('./dist/pages/_error') + +// Keep the catch-error graph lazy so default Error consumers do not load it. +Object.defineProperty(module.exports, 'unstable_catchError', { + enumerable: true, + get() { + return require('./dist/client/components/catch-error').unstable_catchError + }, +}) diff --git a/packages/next/errors.json b/packages/next/errors.json index f9afa9be58f531..71bf011edbd97f 100644 --- a/packages/next/errors.json +++ b/packages/next/errors.json @@ -1135,5 +1135,7 @@ "1134": "Route %s used \\`headers()\\` inside \\`generateStaticParams\\`. This is not supported because \\`generateStaticParams\\` runs at build time without an HTTP request. Read more: https://nextjs.org/docs/messages/next-dynamic-api-wrong-context", "1135": "\"use cache\" cannot be used outside of App Router. Expected a WorkUnitStore.", "1136": "Page \"%s\" cannot use both \\`export const unstable_dynamicStaleTime\\` and \\`export const unstable_instant\\`.", - "1137": "\"%s\" cannot use \\`export const unstable_dynamicStaleTime\\`. This config is only supported in page files, not layouts." + "1137": "\"%s\" cannot use \\`export const unstable_dynamicStaleTime\\`. This config is only supported in page files, not layouts.", + "1138": "`unstable_retry()` can only be used in the App Router. Use `reset()` in the Pages Router.", + "1139": "`unstable_catchError` can only be used in Client Components." } diff --git a/packages/next/src/api/error.react-server.ts b/packages/next/src/api/error.react-server.ts new file mode 100644 index 00000000000000..333f4671f8420d --- /dev/null +++ b/packages/next/src/api/error.react-server.ts @@ -0,0 +1,7 @@ +export function unstable_catchError(): never { + throw new Error( + '`unstable_catchError` can only be used in Client Components.' + ) +} + +export type { ErrorInfo } from '../client/components/error-boundary' diff --git a/packages/next/src/api/error.ts b/packages/next/src/api/error.ts new file mode 100644 index 00000000000000..acd4f8a7636698 --- /dev/null +++ b/packages/next/src/api/error.ts @@ -0,0 +1,6 @@ +// Pages Router only +export { default } from '../pages/_error' +export * from '../pages/_error' + +export { unstable_catchError } from '../client/components/catch-error' +export type { ErrorInfo } from '../client/components/error-boundary' diff --git a/packages/next/src/build/create-compiler-aliases.ts b/packages/next/src/build/create-compiler-aliases.ts index acb8fc59c57417..a258469fc5f79c 100644 --- a/packages/next/src/build/create-compiler-aliases.ts +++ b/packages/next/src/build/create-compiler-aliases.ts @@ -202,6 +202,7 @@ export function createServerOnlyClientOnlyAliases( export function createNextApiEsmAliases() { const mapping = { + error: 'next/dist/api/error', head: 'next/dist/api/head', image: 'next/dist/api/image', constants: 'next/dist/api/constants', @@ -237,6 +238,7 @@ export function createAppRouterApiAliases(isServerOnlyLayer: boolean) { } if (isServerOnlyLayer) { + mapping['error'] = 'next/dist/api/error.react-server' mapping['navigation'] = 'next/dist/api/navigation.react-server' mapping['link'] = 'next/dist/client/app-dir/link.react-server' } diff --git a/packages/next/src/client/components/builtin/global-error.tsx b/packages/next/src/client/components/builtin/global-error.tsx index 652ccab0517486..df806a89f3c3ac 100644 --- a/packages/next/src/client/components/builtin/global-error.tsx +++ b/packages/next/src/client/components/builtin/global-error.tsx @@ -1,7 +1,7 @@ 'use client' import React from 'react' -import { HandleISRError } from '../handle-isr-error' +import { handleISRError } from '../handle-isr-error' import { errorStyles, errorThemeCss, WarningIcon } from './error-styles' export type GlobalErrorComponent = React.ComponentType<{ @@ -18,13 +18,14 @@ function DefaultGlobalError({ error }: { error: any }) { ? 'A server error occurred. Reload to try again.' : 'Reload to try again, or go back.' + handleISRError({ error }) + return (