Skip to content

Commit 56d9513

Browse files
authored
bfcacheId: Opt out of state preservation (vercel#93633)
When `cacheComponents` is enabled, the App Router preserves state across navigations by rendering inactive routes inside React `<Activity>` boundaries. As a default behavior, this is a huge convenience because it lets you navigate between routes without resetting or losing ephemeral UI state (scroll position, expand/collapse, in-progress form edits). Previously the only way to do this was to explicitly track each state with an external state manager, or hoist it to a parent component. It's still the often the case that you should be explicitly tracking all important UI state, anyway, so that it survives a hard refresh of the app, or the browser window being accidentally closed. For example, forms draft states should be persisted to a server-side database or local storage engine. If you're already doing that, then it doesn't matter so much whether client state is preserved via `<Activity>` boundaries or not. For the long tail of ephemeral state that is not tracked, Next.js's philosophy is that it's a better UX default to preserve as much emphemeral state as possible. It's easier to model the cases where you _do_ want state to be reset on navigation as exceptions, compared to the other way around. However, this is a significant change compared to the pre-Cache Components previous behavior of Next.js, and compared to other web frameworks, and indeed the browser's own native bfcache (which implements state restoration for history traversal navigations only, not push/replace). There's are also lots of existing codebases that may rely on the current behavior, and may break subtly under these new semantics. Even if this new default unlocks better UX patterns, we don't want to force everyone to migrate all their code all at once. So, this PR introduces a drop-in mechanism for opting out of state preservation when navigating to a previously visited route. The API is exposed as `useRouter().bfcacheId`. It's intended to be passed to a React `key`: <form key={useRouter().bfcacheId}> The id is contextual: read from a layout, you get the layout's id; read from a page, you get the page's id. It's stable across back/forward navigations, `router.refresh()`, server actions that call `refresh()`, and search-param- or hash-only navigations — i.e., any time the surrounding segment is preserved. It changes when the segment is freshly created by a push or replace into a different route. An important detail is that the previous id is restored during a back/ foward navigation. So state preservation will still work if you navigate via the browser's back button. Why add this to `useRouter()` instead of giving it its own hook? The intent is communicate that `bfcacheId` is not considered an idiomatic pattern — the recommended fix for "I want this state to reset on navigation" is almost always something else: an explicit reset in a submit handler, or a key derived from the underlying data (e.g., a draft id from the server). `useRouter` is the hook where we expose low-level APIs that are supported but are only recommended for advanced or exceptional cases. (For example, instead of `router.push()`, you should almost always use a `<Link>` component instead.)
1 parent 63cd423 commit 56d9513

17 files changed

Lines changed: 564 additions & 25 deletions

File tree

docs/01-app/03-api-reference/04-functions/use-router.mdx

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@ export default function Page() {
4747
- `router.prefetch(href: string, options?: { onInvalidate?: () => void })`: [Prefetch](/docs/app/getting-started/linking-and-navigating#prefetching) the provided route for faster client-side transitions. The optional `onInvalidate` callback is called when the [prefetched data becomes stale](/docs/app/guides/prefetching#extending-or-ejecting-link).
4848
- `router.back()`: Navigate back to the previous route in the browser’s history stack.
4949
- `router.forward()`: Navigate forwards to the next page in the browser’s history stack.
50+
- `router.bfcacheId`: An opaque string identifier scoped to the current route segment. It changes when the surrounding segment is freshly created by a push or replace navigation, and stays the same for back/forward navigations, `router.refresh()`, and search-param- or hash-only navigations. See [`bfcacheId`](#bfcacheid) below for details.
5051

5152
> **Good to know**:
5253
>
@@ -156,6 +157,28 @@ export default function Page() {
156157
}
157158
```
158159

160+
### `bfcacheId`
161+
162+
`router.bfcacheId` is an opaque string identifier scoped to the current route segment. It changes when the surrounding segment is freshly created by a push or replace navigation, and stays the same for back/forward navigations, `router.refresh()`, and search-param- or hash-only navigations.
163+
164+
The recommended use is to pass it as a React `key` to opt out of state preservation on fresh navigations, while still restoring it during a back/forward navigation:
165+
166+
```tsx filename="app/example/page.tsx"
167+
'use client'
168+
169+
import { useRouter } from 'next/navigation'
170+
171+
export default function Page() {
172+
const { bfcacheId } = useRouter()
173+
return <form key={bfcacheId}>{/* ... */}</form>
174+
}
175+
```
176+
177+
When `cacheComponents` is enabled, the App Router preserves Client Component state across navigations using React `<Activity>`. Keying a component on `bfcacheId` resets it on each fresh navigation while still preserving its state across browser back/forward navigations.
178+
179+
> **Good to know**:
180+
> Instead of `bfcacheId`, prefer resetting state explicitly in an event handler (for example, `onSubmit`) or deriving a key from your data (for example, a draft id from the server). Use `bfcacheId` only as a last resort, like when migrating an existing codebase.
181+
159182
## Version History
160183

161184
| Version | Changes |

packages/next/src/client/components/app-router-instance.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -495,6 +495,9 @@ export const publicAppRouterInstance: AppRouterInstance = {
495495
})
496496
}
497497
},
498+
// Default value. Each route segment provides its own value at runtime. Refer
499+
// to `useRouter()`.
500+
bfcacheId: '0',
498501
}
499502

500503
// Conditionally add experimental_gesturePush when gestureTransition is enabled

packages/next/src/client/components/navigation.ts

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -179,7 +179,29 @@ export function useRouter(): AppRouterInstance {
179179
throw new Error('invariant expected app router to be mounted')
180180
}
181181

182-
return router
182+
// Read the bfcacheId of the closest CacheNode and merge it into the
183+
// returned router instance. This is contextual: callers in a shared
184+
// layout get the layout's id; callers in a leaf segment get the leaf's.
185+
// The id is stored on the CacheNode as a number and materialized as a
186+
// string here. The format mirrors React's `useId()` (e.g. `_r_0_`) with
187+
// a `b` prefix, so the id can be safely concatenated with other keys
188+
// without collision.
189+
const layout = useContext(LayoutRouterContext)
190+
const bfcacheIdNumber = layout?.parentCacheNode.bfcacheId ?? 0
191+
return useMemo<AppRouterInstance>(
192+
() => ({
193+
back: router.back,
194+
forward: router.forward,
195+
refresh: router.refresh,
196+
hmrRefresh: router.hmrRefresh,
197+
push: router.push,
198+
replace: router.replace,
199+
prefetch: router.prefetch,
200+
experimental_gesturePush: router.experimental_gesturePush,
201+
bfcacheId: '_b_' + bfcacheIdNumber + '_',
202+
}),
203+
[router, bfcacheIdNumber]
204+
)
183205
}
184206

185207
/**

packages/next/src/client/components/router-reducer/ppr-navigations.ts

Lines changed: 119 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -245,10 +245,14 @@ function updateCacheNodeOnNavigation(
245245
parentRefreshState: RefreshState | null,
246246
accumulation: NavigationRequestAccumulation
247247
): NavigationTask | null {
248-
// Check if this segment matches the one in the previous route.
248+
// Check if this segment matches the one in the previous route. A
249+
// search-param-only difference at a page segment falls through to the
250+
// matched branch — the CacheNode is rebuilt (so data refetches), but the
251+
// bfcacheId carries forward as if the segment had matched.
249252
const oldSegment = oldRouterState[0]
250253
const newSegment = createSegmentFromRouteTree(newRouteTree)
251-
if (!matchSegment(newSegment, oldSegment)) {
254+
const segmentMatchKind = compareSegments(newSegment, oldSegment)
255+
if (segmentMatchKind === SegmentMatchKind.Change) {
252256
// This segment does not match the previous route. We're now entering the
253257
// new part of the target route. Switch to the "create" path.
254258
if (
@@ -347,7 +351,11 @@ function updateCacheNodeOnNavigation(
347351
oldCacheNode !== undefined &&
348352
!shouldRefreshDynamicData &&
349353
// During a same-page navigation, we always refetch the page segments
350-
!(isLeafSegment && isSamePageNavigation)
354+
!(isLeafSegment && isSamePageNavigation) &&
355+
// A search-param-only change is treated as a refresh of the page segment.
356+
// The internal cache key of the data is different, but the identity of
357+
// the node in the route tree is the same.
358+
segmentMatchKind !== SegmentMatchKind.SearchParamOnlyChange
351359
) {
352360
// Reuse the existing CacheNode
353361
const dropPrefetchRsc = false
@@ -364,18 +372,34 @@ function updateCacheNodeOnNavigation(
364372
newMetadataVaryPath,
365373
seedHead,
366374
freshness,
367-
seedDynamicStaleAt
375+
seedDynamicStaleAt,
376+
// Carry forward the existing bfcacheId when there's a prior CacheNode:
377+
// even though the data is being refreshed, the state identity of the
378+
// route hasn't changed. Otherwise (no prior node) mint a fresh one.
379+
oldCacheNode !== undefined
380+
? oldCacheNode.bfcacheId
381+
: generateBFCacheId(freshness)
368382
)
369383
newCacheNode = result.cacheNode
370384
needsDynamicRequest = result.needsDynamicRequest
371385

372-
// Carry forward the old node's scrollRef. This preserves scroll
373-
// intent when a prior navigation's cache node is replaced by a
374-
// refresh before the scroll handler has had a chance to fire —
375-
// e.g. when router.push() and router.refresh() are called in the
376-
// same startTransition batch.
377-
if (oldCacheNode !== undefined) {
378-
newCacheNode.scrollRef = oldCacheNode.scrollRef
386+
// Scroll handling
387+
if (
388+
isLeafSegment &&
389+
segmentMatchKind === SegmentMatchKind.SearchParamOnlyChange
390+
) {
391+
// Special case: A search param change mostly acts the same as a
392+
// refresh, except it does trigger a scroll.
393+
accumulateScrollRef(freshness, newCacheNode, accumulation)
394+
} else {
395+
// Normal case: This is a refresh of an existing segment. Carry forward
396+
// the old node's scrollRef. This preserves scroll intent when a prior
397+
// navigation's CacheNode is replaced by a refresh before the scroll
398+
// handler has had a chance to fire — e.g. when router.push() and
399+
// router.refresh() are called in the same startTransition batch.
400+
if (oldCacheNode !== undefined) {
401+
newCacheNode.scrollRef = oldCacheNode.scrollRef
402+
}
379403
}
380404
}
381405

@@ -636,7 +660,10 @@ function createCacheNodeOnNavigation(
636660
newMetadataVaryPath,
637661
seedHead,
638662
freshness,
639-
seedDynamicStaleAt
663+
seedDynamicStaleAt,
664+
// This segment was not part of the previous route, so mint a fresh
665+
// bfcacheId.
666+
generateBFCacheId(freshness)
640667
)
641668
const newCacheNode = result.cacheNode
642669
const needsDynamicRequest = result.needsDynamicRequest
@@ -879,11 +906,14 @@ function reuseSharedCacheNode(
879906
// Clone the CacheNode that was already present in the previous tree.
880907
// Carry forward the scrollRef so scroll intent from a prior navigation
881908
// survives tree rebuilds (e.g. push + refresh in the same batch).
909+
// Carry forward the bfcacheId so shared-layout segments retain stable
910+
// identity across navigations.
882911
return createCacheNode(
883912
existingCacheNode.rsc,
884913
dropPrefetchRsc ? null : existingCacheNode.prefetchRsc,
885914
existingCacheNode.head,
886915
dropPrefetchRsc ? null : existingCacheNode.prefetchHead,
916+
existingCacheNode.bfcacheId,
887917
existingCacheNode.scrollRef
888918
)
889919
}
@@ -895,7 +925,8 @@ function createCacheNodeForSegment(
895925
metadataVaryPath: PageVaryPath | null,
896926
seedHead: HeadData | null,
897927
freshness: FreshnessPolicy,
898-
dynamicStaleAt: number
928+
dynamicStaleAt: number,
929+
bfcacheId: number
899930
): { cacheNode: CacheNode; needsDynamicRequest: boolean } {
900931
// Construct a new CacheNode using data from the BFCache, the client's
901932
// Segment Cache, or seeded from a server response.
@@ -927,12 +958,17 @@ function createCacheNodeForSegment(
927958
tree.varyPath
928959
)
929960
if (bfcacheEntry !== null) {
961+
// A regular navigation that happens to read cached data is still a
962+
// fresh navigation, so we use the caller-supplied bfcacheId — the
963+
// BFCacheEntry's id is only restored on history-traversal
964+
// navigations.
930965
return {
931966
cacheNode: createCacheNode(
932967
bfcacheEntry.rsc,
933968
bfcacheEntry.prefetchRsc,
934969
bfcacheEntry.head,
935-
bfcacheEntry.prefetchHead
970+
bfcacheEntry.prefetchHead,
971+
bfcacheId
936972
),
937973
needsDynamicRequest: false,
938974
}
@@ -967,19 +1003,27 @@ function createCacheNodeForSegment(
9671003
prefetchRsc,
9681004
head,
9691005
prefetchHead,
970-
dynamicStaleAt
1006+
dynamicStaleAt,
1007+
bfcacheId
9711008
)
9721009
if (isPage && metadataVaryPath !== null) {
9731010
writeHeadToBFCache(
9741011
now,
9751012
metadataVaryPath,
9761013
head,
9771014
prefetchHead,
978-
dynamicStaleAt
1015+
dynamicStaleAt,
1016+
bfcacheId
9791017
)
9801018
}
9811019
return {
982-
cacheNode: createCacheNode(rsc, prefetchRsc, head, prefetchHead),
1020+
cacheNode: createCacheNode(
1021+
rsc,
1022+
prefetchRsc,
1023+
head,
1024+
prefetchHead,
1025+
bfcacheId
1026+
),
9831027
needsDynamicRequest: false,
9841028
}
9851029
}
@@ -1000,12 +1044,16 @@ function createCacheNodeForSegment(
10001044
const oldRscDidResolve =
10011045
!isDeferredRsc(oldRsc) || oldRsc.status !== 'pending'
10021046
const dropPrefetchRsc = oldRscDidResolve
1047+
// Restore the bfcacheId from the cached entry so that back/forward
1048+
// navigations preserve the original id, regardless of whether
1049+
// `cacheComponents` Activity preservation is enabled.
10031050
return {
10041051
cacheNode: createCacheNode(
10051052
bfcacheEntry.rsc,
10061053
dropPrefetchRsc ? null : bfcacheEntry.prefetchRsc,
10071054
bfcacheEntry.head,
1008-
dropPrefetchRsc ? null : bfcacheEntry.prefetchHead
1055+
dropPrefetchRsc ? null : bfcacheEntry.prefetchHead,
1056+
bfcacheEntry.bfcacheId
10091057
),
10101058
needsDynamicRequest: false,
10111059
}
@@ -1208,21 +1256,23 @@ function createCacheNodeForSegment(
12081256
prefetchRsc,
12091257
head,
12101258
prefetchHead,
1211-
dynamicStaleAt
1259+
dynamicStaleAt,
1260+
bfcacheId
12121261
)
12131262
if (isPage && metadataVaryPath !== null) {
12141263
writeHeadToBFCache(
12151264
now,
12161265
metadataVaryPath,
12171266
head,
12181267
prefetchHead,
1219-
dynamicStaleAt
1268+
dynamicStaleAt,
1269+
bfcacheId
12201270
)
12211271
}
12221272
}
12231273

12241274
return {
1225-
cacheNode: createCacheNode(rsc, prefetchRsc, head, prefetchHead),
1275+
cacheNode: createCacheNode(rsc, prefetchRsc, head, prefetchHead, bfcacheId),
12261276
// TODO: We should store this field on the CacheNode itself. I think we can
12271277
// probably unify NavigationTask, CacheNode, and DeferredRsc into a
12281278
// single type. Or at least CacheNode and DeferredRsc.
@@ -1236,6 +1286,7 @@ function createCacheNode(
12361286
prefetchRsc: React.ReactNode | null,
12371287
head: React.ReactNode | null,
12381288
prefetchHead: HeadData | null,
1289+
bfcacheId: number,
12391290
scrollRef: ScrollRef | null = null
12401291
): CacheNode {
12411292
return {
@@ -1245,7 +1296,54 @@ function createCacheNode(
12451296
prefetchHead,
12461297
slots: null,
12471298
scrollRef,
1299+
bfcacheId,
1300+
}
1301+
}
1302+
1303+
// Globally-unique counter for fresh bfcacheIds. Incremented every time a new
1304+
// CacheNode is created on the client. The id surfaces to user code as a
1305+
// string via `useRouter().bfcacheId`.
1306+
let nextBFCacheId = 0
1307+
1308+
function generateBFCacheId(freshness: FreshnessPolicy): number {
1309+
// Server-side rendering and the initial client-side hydration tree both
1310+
// use a fixed sentinel so they reconcile cleanly across hydration. The
1311+
// counter only advances on real client-side navigations after hydration.
1312+
if (typeof window === 'undefined') return 0
1313+
if (freshness === FreshnessPolicy.Hydration) return 0
1314+
return ++nextBFCacheId
1315+
}
1316+
1317+
const enum SegmentMatchKind {
1318+
// Two segments are equivalent: the CacheNode can be reused as-is.
1319+
Match,
1320+
// The segments differ in the parts that determine the route (segment kind,
1321+
// dynamic param value, etc.). The CacheNode must be created fresh.
1322+
Change,
1323+
// Two page segments differ only in their search params. Conceptually this
1324+
// is a refresh of the current page rather than a navigation to a new
1325+
// route — search params don't contribute to the LayoutRouter state key,
1326+
// and they shouldn't change the bfcacheId either. The CacheNode is rebuilt
1327+
// (so data refetches) but the bfcacheId carries forward.
1328+
SearchParamOnlyChange,
1329+
}
1330+
1331+
function compareSegments(
1332+
newSegment: Segment,
1333+
oldSegment: Segment
1334+
): SegmentMatchKind {
1335+
if (matchSegment(newSegment, oldSegment)) {
1336+
return SegmentMatchKind.Match
1337+
}
1338+
if (
1339+
typeof newSegment === 'string' &&
1340+
typeof oldSegment === 'string' &&
1341+
newSegment.startsWith(PAGE_SEGMENT_KEY) &&
1342+
oldSegment.startsWith(PAGE_SEGMENT_KEY)
1343+
) {
1344+
return SegmentMatchKind.SearchParamOnlyChange
12481345
}
1346+
return SegmentMatchKind.Change
12491347
}
12501348

12511349
// Represents whether the previuos navigation resulted in a route tree mismatch.

packages/next/src/client/components/segment-cache/bfcache.ts

Lines changed: 21 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,11 @@ export type BFCacheEntry = {
3434
head: React.ReactNode | null
3535
prefetchHead: React.ReactNode | null
3636

37+
// The bfcacheId of the CacheNode that wrote this entry. Restored on
38+
// history-traversal navigations so that `useRouter().bfcacheId` is stable
39+
// across back/forward, even without `cacheComponents` Activity preservation.
40+
bfcacheId: number
41+
3742
ref: UnknownMapEntry | null
3843
size: number
3944
// The time at which this data was received. Used to compute the stale time
@@ -64,7 +69,8 @@ export function writeToBFCache(
6469
prefetchRsc: React.ReactNode,
6570
head: React.ReactNode,
6671
prefetchHead: React.ReactNode,
67-
dynamicStaleAt: number
72+
dynamicStaleAt: number,
73+
bfcacheId: number
6874
): void {
6975
if (typeof window === 'undefined') {
7076
return
@@ -79,6 +85,8 @@ export function writeToBFCache(
7985
head,
8086
prefetchHead,
8187

88+
bfcacheId,
89+
8290
ref: null,
8391
// TODO: This is just a heuristic. Getting the actual size of the segment
8492
// isn't feasible because it's part of a larger streaming response. The
@@ -104,10 +112,20 @@ export function writeHeadToBFCache(
104112
varyPath: SegmentVaryPath,
105113
head: React.ReactNode,
106114
prefetchHead: React.ReactNode,
107-
dynamicStaleAt: number
115+
dynamicStaleAt: number,
116+
bfcacheId: number
108117
): void {
109118
// Read the special "segment" that represents the head data.
110-
writeToBFCache(now, varyPath, head, prefetchHead, null, null, dynamicStaleAt)
119+
writeToBFCache(
120+
now,
121+
varyPath,
122+
head,
123+
prefetchHead,
124+
null,
125+
null,
126+
dynamicStaleAt,
127+
bfcacheId
128+
)
111129
}
112130

113131
/**

0 commit comments

Comments
 (0)