Skip to content

Commit 078fff2

Browse files
authored
Experimental bfcache: Restore state w/ <Activity> (#77992)
> [!NOTE] > This feature does not affect the caching of dynamic data — only UI state. Dynamic data is cached during back/forward navigations using a different, preexisting mechanism; during normal navigations, dynamic data is never cached, unless you opt in via `experimental.staleTimes.dynamic`. When navigating to a route that has already been visited, we should restore as much of its previous state as we reasonably can — not only state tracked by the browser, like scroll position and form inputs, but also state owned by React, like `useState`. The browser's native bfcache provides some of this automatically when you use the back/forward buttons, or the history API. But it has limitations, mainly that it can't restore state controlled by React. It also doesn't work with navigations by regular links. React has an experimental API called `<Activity>` that is designed for this purpose. Content inside an Activity boundary can be hidden from the UI without unmounting it; later, it can be restored to the UI without having lost any state. React optimizes hidden Activity boundaries by skipping over them during rendering, similar to how the browser handles `content-visibility`. Later, during idle time, React will prerender the boundaries so they are ready by the time they are revealed again. In this PR, I've added an Activity boundary around every route segment (i.e. layout or page). For each level of the route tree, the router will render the N most recently active routes, and automatically toggle their visibility as the user navigates, regardless of whether it's via the back/forward buttons or regular links. Aside from preserving state, keeping the inactive routes mounted also makes navigations faster, since by the time you navigate the next screen has already been prerendered. In the future, we'll use this same mechanism to speculatively/ optimistically prerender routes that haven't yet been visited. This is a nested bfcache — we track the history separately at each level of the route tree. The lifetime of the bfcache is tied to the lifetime of the React tree. For now, the maximum number of entries per level is hardcoded to 3. Eventually this will likely need to configurable per segment, but the plan is for the default to be some smallish non-zero number. We'll tinker with the heuristic before the feature is shipped to stable.
1 parent 3e24bdc commit 078fff2

File tree

6 files changed

+486
-82
lines changed

6 files changed

+486
-82
lines changed
Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
import type { FlightRouterState } from '../../server/app-render/types'
2+
import { useState } from 'react'
3+
4+
// When the flag is disabled, only track the currently active tree
5+
const MAX_BF_CACHE_ENTRIES = process.env.__NEXT_ROUTER_BF_CACHE ? 3 : 1
6+
7+
export type RouterBFCacheEntry = {
8+
tree: FlightRouterState
9+
stateKey: string
10+
// The entries form a linked list, sorted in order of most recently active.
11+
next: RouterBFCacheEntry | null
12+
}
13+
14+
/**
15+
* Keeps track of the most recent N trees (FlightRouterStates) that were active
16+
* at a certain segment level. E.g. for a segment "/a/b/[param]", this hook
17+
* tracks the last N param values that the router rendered for N.
18+
*
19+
* The result of this hook precisely determines the number and order of
20+
* trees that are rendered in parallel at their segment level.
21+
*
22+
* The purpose of this cache is to we can preserve the React and DOM state of
23+
* some number of inactive trees, by rendering them in an <Activity> boundary.
24+
* That means it would not make sense for the the lifetime of the cache to be
25+
* any longer than the lifetime of the React tree; e.g. if the hook were
26+
* unmounted, then the React tree would be, too. So, we use React state to
27+
* manage it.
28+
*
29+
* Note that we don't store the RSC data for the cache entries in this hook —
30+
* the data for inactive segments is stored in the parent CacheNode, which
31+
* *does* have a longer lifetime than the React tree. This hook only determines
32+
* which of those trees should have their *state* preserved, by <Activity>.
33+
*/
34+
export function useRouterBFCache(
35+
activeTree: FlightRouterState,
36+
activeStateKey: string
37+
): RouterBFCacheEntry {
38+
// The currently active entry. The entries form a linked list, sorted in
39+
// order of most recently active. This allows us to reuse parts of the list
40+
// without cloning, unless there's a reordering or removal.
41+
// TODO: Once we start tracking back/forward history at each route level,
42+
// we should use the history order instead. In other words, when traversing
43+
// to an existing entry as a result of a popstate event, we should maintain
44+
// the existing order instead of moving it to the front of the list. I think
45+
// an initial implementation of this could be to pass an incrementing id
46+
// to history.pushState/replaceState, then use that here for ordering.
47+
const [prevActiveEntry, setPrevActiveEntry] = useState<RouterBFCacheEntry>(
48+
() => {
49+
const initialEntry: RouterBFCacheEntry = {
50+
tree: activeTree,
51+
stateKey: activeStateKey,
52+
next: null,
53+
}
54+
return initialEntry
55+
}
56+
)
57+
58+
if (prevActiveEntry.tree === activeTree) {
59+
// Fast path. The active tree hasn't changed, so we can reuse the
60+
// existing state.
61+
return prevActiveEntry
62+
}
63+
64+
// The route tree changed. Note that this doesn't mean that the tree changed
65+
// *at this level* — the change may be due to a child route. Either way, we
66+
// need to either add or update the router tree in the bfcache.
67+
//
68+
// The rest of the code looks more complicated than it actually is because we
69+
// can't mutate the state in place; we have to copy-on-write.
70+
71+
// Create a new entry for the active cache key. This is the head of the new
72+
// linked list.
73+
const newActiveEntry: RouterBFCacheEntry = {
74+
tree: activeTree,
75+
stateKey: activeStateKey,
76+
next: null,
77+
}
78+
79+
// We need to append the old list onto the new list. If the head of the new
80+
// list was already present in the cache, then we'll need to clone everything
81+
// that came before it. Then we can reuse the rest.
82+
let n = 1
83+
let oldEntry: RouterBFCacheEntry | null = prevActiveEntry
84+
let clonedEntry: RouterBFCacheEntry = newActiveEntry
85+
while (oldEntry !== null && n < MAX_BF_CACHE_ENTRIES) {
86+
if (oldEntry.stateKey === activeStateKey) {
87+
// Fast path. This entry in the old list that corresponds to the key that
88+
// is now active. We've already placed a clone of this entry at the front
89+
// of the new list. We can reuse the rest of the old list without cloning.
90+
// NOTE: We don't need to worry about eviction in this case because we
91+
// haven't increased the size of the cache, and we assume the max size
92+
// is constant across renders. If we were to change it to a dynamic limit,
93+
// then the implementation would need to account for that.
94+
clonedEntry.next = oldEntry.next
95+
break
96+
} else {
97+
// Clone the entry and append it to the list.
98+
n++
99+
const entry: RouterBFCacheEntry = {
100+
tree: oldEntry.tree,
101+
stateKey: oldEntry.stateKey,
102+
next: null,
103+
}
104+
clonedEntry.next = entry
105+
clonedEntry = entry
106+
}
107+
oldEntry = oldEntry.next
108+
}
109+
110+
setPrevActiveEntry(newActiveEntry)
111+
return newActiveEntry
112+
}

packages/next/src/client/components/layout-router.tsx

Lines changed: 107 additions & 73 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,11 @@ import { HTTPAccessFallbackBoundary } from './http-access-fallback/error-boundar
3939
import { createRouterCacheKey } from './router-reducer/create-router-cache-key'
4040
import { hasInterceptionRouteInCurrentTree } from './router-reducer/reducers/has-interception-route-in-current-tree'
4141
import { dispatchAppRouterAction } from './use-action-queue'
42+
import { useRouterBFCache, type RouterBFCacheEntry } from './bfcache'
43+
44+
const Activity = process.env.__NEXT_ROUTER_BF_CACHE
45+
? require('react').unstable_Activity
46+
: null
4247

4348
/**
4449
* Add refetch marker to router state at the point of the current layout segment.
@@ -526,13 +531,7 @@ export default function OuterLayoutRouter({
526531
segmentMap = new Map()
527532
parentParallelRoutes.set(parallelRouterKey, segmentMap)
528533
}
529-
530-
// Get the active segment in the tree
531-
// The reason arrays are used in the data format is that these are transferred from the server to the browser so it's optimized to save bytes.
532534
const parentTreeSegment = parentTree[0]
533-
const tree = parentTree[1][parallelRouterKey]
534-
const treeSegment = tree[0]
535-
536535
const segmentPath =
537536
parentSegmentPath === null
538537
? // TODO: The root segment value is currently omitted from the segment
@@ -551,31 +550,49 @@ export default function OuterLayoutRouter({
551550
// it's possible that the segment accessed the search params on the server.
552551
// (This only applies to page segments; layout segments cannot access search
553552
// params on the server.)
554-
const cacheKey = createRouterCacheKey(treeSegment)
555-
const stateKey = createRouterCacheKey(treeSegment, true) // no search params
556-
557-
// Read segment path from the parallel router cache node.
558-
let cacheNode = segmentMap.get(cacheKey)
559-
if (cacheNode === undefined) {
560-
// When data is not available during rendering client-side we need to fetch
561-
// it from the server.
562-
const newLazyCacheNode: LazyCacheNode = {
563-
lazyData: null,
564-
rsc: null,
565-
prefetchRsc: null,
566-
head: null,
567-
prefetchHead: null,
568-
parallelRoutes: new Map(),
569-
loading: null,
570-
navigatedAt: -1,
571-
}
553+
const activeTree = parentTree[1][parallelRouterKey]
554+
const activeSegment = activeTree[0]
555+
const activeStateKey = createRouterCacheKey(activeSegment, true) // no search params
556+
557+
// At each level of the route tree, not only do we render the currently
558+
// active segment — we also render the last N segments that were active at
559+
// this level inside a hidden <Activity> boundary, to preserve their state
560+
// if or when the user navigates to them again.
561+
//
562+
// bfcacheEntry is a linked list of FlightRouterStates.
563+
let bfcacheEntry: RouterBFCacheEntry | null = useRouterBFCache(
564+
activeTree,
565+
activeStateKey
566+
)
567+
let children: Array<React.ReactNode> = []
568+
do {
569+
const tree = bfcacheEntry.tree
570+
const stateKey = bfcacheEntry.stateKey
571+
const segment = tree[0]
572+
const cacheKey = createRouterCacheKey(segment)
573+
574+
// Read segment path from the parallel router cache node.
575+
let cacheNode = segmentMap.get(cacheKey)
576+
if (cacheNode === undefined) {
577+
// When data is not available during rendering client-side we need to fetch
578+
// it from the server.
579+
const newLazyCacheNode: LazyCacheNode = {
580+
lazyData: null,
581+
rsc: null,
582+
prefetchRsc: null,
583+
head: null,
584+
prefetchHead: null,
585+
parallelRoutes: new Map(),
586+
loading: null,
587+
navigatedAt: -1,
588+
}
572589

573-
// Flight data fetch kicked off during render and put into the cache.
574-
cacheNode = newLazyCacheNode
575-
segmentMap.set(cacheKey, newLazyCacheNode)
576-
}
590+
// Flight data fetch kicked off during render and put into the cache.
591+
cacheNode = newLazyCacheNode
592+
segmentMap.set(cacheKey, newLazyCacheNode)
593+
}
577594

578-
/*
595+
/*
579596
- Error boundary
580597
- Only renders error boundary if error component is provided.
581598
- Rendered for each segment to ensure they have their own error state.
@@ -585,49 +602,66 @@ export default function OuterLayoutRouter({
585602
- Passed to the router during rendering to ensure it can be immediately rendered when suspending on a Flight fetch.
586603
*/
587604

588-
// TODO: The loading module data for a segment is stored on the parent, then
589-
// applied to each of that parent segment's parallel route slots. In the
590-
// simple case where there's only one parallel route (the `children` slot),
591-
// this is no different from if the loading module data where stored on the
592-
// child directly. But I'm not sure this actually makes sense when there are
593-
// multiple parallel routes. It's not a huge issue because you always have
594-
// the option to define a narrower loading boundary for a particular slot. But
595-
// this sort of smells like an implementation accident to me.
596-
const loadingModuleData = parentCacheNode.loading
605+
// TODO: The loading module data for a segment is stored on the parent, then
606+
// applied to each of that parent segment's parallel route slots. In the
607+
// simple case where there's only one parallel route (the `children` slot),
608+
// this is no different from if the loading module data where stored on the
609+
// child directly. But I'm not sure this actually makes sense when there are
610+
// multiple parallel routes. It's not a huge issue because you always have
611+
// the option to define a narrower loading boundary for a particular slot. But
612+
// this sort of smells like an implementation accident to me.
613+
const loadingModuleData = parentCacheNode.loading
614+
let child = (
615+
<TemplateContext.Provider
616+
key={stateKey}
617+
value={
618+
<ScrollAndFocusHandler segmentPath={segmentPath}>
619+
<ErrorBoundary
620+
errorComponent={error}
621+
errorStyles={errorStyles}
622+
errorScripts={errorScripts}
623+
>
624+
<LoadingBoundary loading={loadingModuleData}>
625+
<HTTPAccessFallbackBoundary
626+
notFound={notFound}
627+
forbidden={forbidden}
628+
unauthorized={unauthorized}
629+
>
630+
<RedirectBoundary>
631+
<InnerLayoutRouter
632+
url={url}
633+
tree={tree}
634+
cacheNode={cacheNode}
635+
segmentPath={segmentPath}
636+
/>
637+
</RedirectBoundary>
638+
</HTTPAccessFallbackBoundary>
639+
</LoadingBoundary>
640+
</ErrorBoundary>
641+
</ScrollAndFocusHandler>
642+
}
643+
>
644+
{templateStyles}
645+
{templateScripts}
646+
{template}
647+
</TemplateContext.Provider>
648+
)
597649

598-
return (
599-
<TemplateContext.Provider
600-
key={stateKey}
601-
value={
602-
<ScrollAndFocusHandler segmentPath={segmentPath}>
603-
<ErrorBoundary
604-
errorComponent={error}
605-
errorStyles={errorStyles}
606-
errorScripts={errorScripts}
607-
>
608-
<LoadingBoundary loading={loadingModuleData}>
609-
<HTTPAccessFallbackBoundary
610-
notFound={notFound}
611-
forbidden={forbidden}
612-
unauthorized={unauthorized}
613-
>
614-
<RedirectBoundary>
615-
<InnerLayoutRouter
616-
url={url}
617-
tree={tree}
618-
cacheNode={cacheNode}
619-
segmentPath={segmentPath}
620-
/>
621-
</RedirectBoundary>
622-
</HTTPAccessFallbackBoundary>
623-
</LoadingBoundary>
624-
</ErrorBoundary>
625-
</ScrollAndFocusHandler>
626-
}
627-
>
628-
{templateStyles}
629-
{templateScripts}
630-
{template}
631-
</TemplateContext.Provider>
632-
)
650+
if (process.env.__NEXT_ROUTER_BF_CACHE) {
651+
child = (
652+
<Activity
653+
key={stateKey}
654+
mode={stateKey === activeStateKey ? 'visible' : 'hidden'}
655+
>
656+
{child}
657+
</Activity>
658+
)
659+
}
660+
661+
children.push(child)
662+
663+
bfcacheEntry = bfcacheEntry.next
664+
} while (bfcacheEntry !== null)
665+
666+
return children
633667
}
Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,29 @@
1+
import Link from 'next/link'
2+
13
export default function RootLayout({
24
children,
35
}: {
46
children: React.ReactNode
57
}) {
8+
const links = []
9+
for (let n = 1; n <= 5; n++) {
10+
links.push(
11+
<li key={n}>
12+
<Link href={`/page/${n}`}>Page {n}</Link>
13+
</li>
14+
)
15+
links.push(
16+
<li key={n + '-with-search-param'}>
17+
<Link href={`/page/${n}?param=true`}>Page {n} (with search param)</Link>
18+
</li>
19+
)
20+
}
621
return (
722
<html lang="en">
8-
<body>{children}</body>
23+
<body>
24+
<ul>{links}</ul>
25+
<div>{children}</div>
26+
</body>
927
</html>
1028
)
1129
}
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
import { Suspense } from 'react'
2+
import { StatefulClientComponent } from './stateful-client-component'
3+
4+
export default async function Page({
5+
params,
6+
}: {
7+
params: Promise<{ n: string }>
8+
}) {
9+
const { n } = await params
10+
return (
11+
<>
12+
<h2>Page {n}</h2>
13+
<div>
14+
<Suspense fallback={<div>Loading...</div>}>
15+
<StatefulClientComponent n={n} />
16+
</Suspense>
17+
</div>
18+
</>
19+
)
20+
}

0 commit comments

Comments
 (0)