Repository: vercel/next.js
Pull request: [BF Cache]: skip lazyData fetch logic for inactive segments

When `routerBfCache` is enabled, React's Activity API renders inactive route segments offscreen to enable instant back/forward navigation. However, the layout-router's lazy data fetching logic was incorrectly triggering fetchServerResponse calls for these offscreen segments when data was missing. 

This PR adds an isActive prop to InnerLayoutRouter that tracks whether a segment is currently visible via the existing `stateKey` check. 

Changed files:
filename: packages/next/src/client/components/app-router.tsx
patch: @@ -437,6 +437,8 @@ function Router({
       // Root node always has `url`
       // Provided in AppTreeContext to ensure it can be overwritten in layout-router
       url: canonicalUrl,
+      // Root segment is always active
+      isActive: true,
     }
   }, [tree, cache, canonicalUrl])
 

filename: packages/next/src/client/components/layout-router.tsx
patch: @@ -338,11 +338,13 @@ function InnerLayoutRouter({
   segmentPath,
   cacheNode,
   url,
+  isActive,
 }: {
   tree: FlightRouterState
   segmentPath: FlightSegmentPath
   cacheNode: CacheNode
   url: string
+  isActive: boolean
 }) {
   const context = useContext(GlobalLayoutRouterContext)
   const parentNavPromises = useContext(NavigationPromisesContext)
@@ -382,44 +384,48 @@ function InnerLayoutRouter({
     // navigation that will be able to fulfill it. We need to fetch more from
     // the server and patch the cache.
 
-    // Check if there's already a pending request.
-    let lazyData = cacheNode.lazyData
-    if (lazyData === null) {
-      /**
-       * Router state with refetch marker added
-       */
-      // TODO-APP: remove ''
-      const refetchTree = walkAddRefetch(['', ...segmentPath], fullTree)
-      const includeNextUrl = hasInterceptionRouteInCurrentTree(fullTree)
-      const navigatedAt = Date.now()
-      cacheNode.lazyData = lazyData = fetchServerResponse(
-        new URL(url, location.origin),
-        {
-          flightRouterState: refetchTree,
-          nextUrl: includeNextUrl
-            ? // We always send the last next-url, not the current when
-              // performing a dynamic request. This is because we update
-              // the next-url after a navigation, but we want the same
-              // interception route to be matched that used the last
-              // next-url.
-              context.previousNextUrl || context.nextUrl
-            : null,
-        }
-      ).then((serverResponse) => {
-        startTransition(() => {
-          dispatchAppRouterAction({
-            type: ACTION_SERVER_PATCH,
-            previousTree: fullTree,
-            serverResponse,
-            navigatedAt,
+    // Only fetch data for the active segment. Inactive segments (rendered
+    // offscreen for bfcache) should not trigger fetches.
+    if (isActive) {
+      // Check if there's already a pending request.
+      let lazyData = cacheNode.lazyData
+      if (lazyData === null) {
+        /**
+         * Router state with refetch marker added
+         */
+        // TODO-APP: remove ''
+        const refetchTree = walkAddRefetch(['', ...segmentPath], fullTree)
+        const includeNextUrl = hasInterceptionRouteInCurrentTree(fullTree)
+        const navigatedAt = Date.now()
+        cacheNode.lazyData = lazyData = fetchServerResponse(
+          new URL(url, location.origin),
+          {
+            flightRouterState: refetchTree,
+            nextUrl: includeNextUrl
+              ? // We always send the last next-url, not the current when
+                // performing a dynamic request. This is because we update
+                // the next-url after a navigation, but we want the same
+                // interception route to be matched that used the last
+                // next-url.
+                context.previousNextUrl || context.nextUrl
+              : null,
+          }
+        ).then((serverResponse) => {
+          startTransition(() => {
+            dispatchAppRouterAction({
+              type: ACTION_SERVER_PATCH,
+              previousTree: fullTree,
+              serverResponse,
+              navigatedAt,
+            })
           })
-        })
 
-        return serverResponse
-      })
+          return serverResponse
+        })
 
-      // Suspend while waiting for lazyData to resolve
-      use(lazyData)
+        // Suspend while waiting for lazyData to resolve
+        use(lazyData)
+      }
     }
     // Suspend infinitely as `changeByServerResponse` will cause a different part of the tree to be rendered.
     // A falsey `resolvedRsc` indicates missing data -- we should not commit that branch, and we need to wait for the data to arrive.
@@ -461,6 +467,7 @@ function InnerLayoutRouter({
 
         // TODO-APP: overriding of url for parallel routes
         url: url,
+        isActive: isActive,
       }}
     >
       {content}
@@ -558,7 +565,8 @@ export default function OuterLayoutRouter({
     throw new Error('invariant expected layout router to be mounted')
   }
 
-  const { parentTree, parentCacheNode, parentSegmentPath, url } = context
+  const { parentTree, parentCacheNode, parentSegmentPath, url, isActive } =
+    context
 
   // Get the CacheNode for this segment by reading it from the parent segment's
   // child map.
@@ -691,6 +699,7 @@ export default function OuterLayoutRouter({
                       tree={tree}
                       cacheNode={cacheNode}
                       segmentPath={segmentPath}
+                      isActive={isActive && stateKey === activeStateKey}
                     />
                     {segmentBoundaryTriggerNode}
                   </RedirectBoundary>

filename: packages/next/src/shared/lib/app-router-context.shared-runtime.ts
patch: @@ -62,6 +62,7 @@ export const LayoutRouterContext = React.createContext<{
   parentCacheNode: CacheNode
   parentSegmentPath: FlightSegmentPath | null
   url: string
+  isActive: boolean
 } | null>(null)
 
 export const GlobalLayoutRouterContext = React.createContext<{

filename: test/development/acceptance-app/hydration-error.test.ts
patch: @@ -457,7 +457,7 @@ describe('Error overlay for hydration errors in App router', () => {
                <HTTPAccessFallbackBoundary notFound={undefined} forbidden={undefined} unauthorized={undefined}>
                  <RedirectBoundary>
                    <RedirectErrorBoundary router={{...}}>
-                     <InnerLayoutRouter url="/extra-nod..." tree={[...]} cacheNode={{lazyData:null, ...}} segmentPath={[...]}>
+                     <InnerLayoutRouter url="/extra-nod..." tree={[...]} cacheNode={{lazyData:null, ...}} segmentPath={[...]} ...>
                        <SegmentViewNode type="page" pagePath="(default)/...">
                          <SegmentTrieNode>
                          <ClientPageRoot Component={function Mismatch} searchParams={{}} params={{}}>
@@ -514,7 +514,7 @@ describe('Error overlay for hydration errors in App router', () => {
                    <HTTPAccessFallbackBoundary notFound={undefined} forbidden={undefined} unauthorized={undefined}>
                      <RedirectBoundary>
                        <RedirectErrorBoundary router={{...}}>
-                         <InnerLayoutRouter url="/p-under-p" tree={[...]} cacheNode={{lazyData:null, ...}} segmentPath={[...]}>
+                         <InnerLayoutRouter url="/p-under-p" tree={[...]} cacheNode={{lazyData:null, ...}} segmentPath={[...]} ...>
                            <SegmentViewNode type="page" pagePath="(default)/...">
                              <SegmentTrieNode>
                              <ClientPageRoot Component={function Page} searchParams={{}} params={{}}>
@@ -570,7 +570,7 @@ describe('Error overlay for hydration errors in App router', () => {
                  <HTTPAccessFallbackBoundary notFound={undefined} forbidden={undefined} unauthorized={undefined}>
                    <RedirectBoundary>
                      <RedirectErrorBoundary router={{...}}>
-                       <InnerLayoutRouter url="/div-under-p" tree={[...]} cacheNode={{lazyData:null, ...}} segmentPath={[...]}>
+                       <InnerLayoutRouter url="/div-under-p" tree={[...]} cacheNode={{lazyData:null, ...}} segmentPath={[...]} ...>
                          <SegmentViewNode type="page" pagePath="(default)/...">
                            <SegmentTrieNode>
                            <ClientPageRoot Component={function Page} searchParams={{}} params={{}}>

filename: test/development/app-dir/hydration-error-count/hydration-error-count.test.ts
patch: @@ -72,7 +72,7 @@ describe('hydration-error-count', () => {
                    <HTTPAccessFallbackBoundary notFound={undefined} forbidden={undefined} unauthorized={undefined}>
                      <RedirectBoundary>
                        <RedirectErrorBoundary router={{...}}>
-                         <InnerLayoutRouter url="/html-diff" tree={[...]} cacheNode={{lazyData:null, ...}} segmentPath={[...]}>
+                         <InnerLayoutRouter url="/html-diff" tree={[...]} cacheNode={{lazyData:null, ...}} segmentPath={[...]} ...>
                            <SegmentViewNode type="page" pagePath="html-diff/...">
                              <SegmentTrieNode>
                              <ClientPageRoot Component={function Page} searchParams={{}} params={{}}>