Repository: vercel/next.js
Pull request: bugfix: propagate staleTime to seeded prefetch entry

In #71280, we hooked up the server-side `staleTime` header to the client-side prefetch cache. This means that if the server responds with a staleTime value, the client router will use that rather than the previous heuristic when determining if a prefetch entry can be reused.

However, there was missing functionality to set a proper staleTime value for the initially seeded prefetch entry (aka the first page visit). In the case of customizing staleTimes, this meant that navigations back to the original page would defer back to the old stale heuristic, which would differ from subsequent navigations that were properly seeded with a staleTime.

Separately, I believe that it's problematic that if the `staleTime` header is present, that we honor that and only set a fresh/stale cache status, because the newer heuristic doesn't consider the `reusable` cache status. This should probably be flagged to the clientSegmentCache flag. However, to minimize the changes here, I've only addressed this particular case. 



Changed files:
filename: packages/next/src/client/components/router-reducer/create-initial-router-state.ts
patch: @@ -4,7 +4,10 @@ import type { FlightDataPath } from '../../../server/app-render/types'
 import { createHrefFromUrl } from './create-href-from-url'
 import { fillLazyItemsTillLeafWithHead } from './fill-lazy-items-till-leaf-with-head'
 import { extractPathFromFlightRouterState } from './compute-changed-path'
-import { createSeededPrefetchCacheEntry } from './prefetch-cache-utils'
+import {
+  createSeededPrefetchCacheEntry,
+  STATIC_STALETIME_MS,
+} from './prefetch-cache-utils'
 import { PrefetchKind, type PrefetchCacheEntry } from './router-reducer-types'
 import { addRefreshMarkerToActiveParallelSegments } from './refetch-inactive-parallel-segments'
 import { getFlightDataPartsFromPath } from '../../flight-data-helpers'
@@ -132,7 +135,13 @@ export function createInitialRouterState({
         // add a way to split a single Flight stream into static and dynamic
         // parts. But in the meantime we should at least make this work for
         // fully static pages.
-        staleTime: -1,
+        staleTime:
+          // In the old router, there was only a single configurable staleTime (experimental.staleTimes)
+          // As an abundance of caution, this will only set the initial staleTime to the configured value
+          // if we're not leveraging the segment cache, which has its own prefetching semantics.
+          prerendered && !process.env.__NEXT_CLIENT_SEGMENT_CACHE
+            ? STATIC_STALETIME_MS
+            : -1,
       },
       tree: initialState.tree,
       prefetchCache: initialState.prefetchCache,

filename: packages/next/src/client/components/router-reducer/prefetch-cache-utils.ts
patch: @@ -291,7 +291,7 @@ export function createSeededPrefetchCacheEntry({
     kind,
     prefetchTime: Date.now(),
     lastUsedTime: Date.now(),
-    staleTime: -1,
+    staleTime: data.staleTime,
     key: prefetchCacheKey,
     status: PrefetchCacheEntryStatus.fresh,
     url,

filename: test/e2e/app-dir/app-prefetch/app/page.js
patch: @@ -8,6 +8,9 @@ export default function HomePage() {
       <Link href="/static-page" id="to-static-page">
         To Static Page
       </Link>
+      <Link href="/static-page-no-prefetch" id="to-static-page-no-prefetch">
+        To Static Page No Prefetch
+      </Link>
       <Link href="/dynamic-page" id="to-dynamic-page-no-params">
         To Dynamic Page
       </Link>

filename: test/e2e/app-dir/app-prefetch/app/static-page-no-prefetch/page.js
patch: @@ -0,0 +1,14 @@
+import Link from 'next/link'
+
+export default async function Page() {
+  return (
+    <>
+      <p id="static-page-no-prefetch">Static Page No Prefetch</p>
+      <p>
+        <Link href="/" id="to-home">
+          To home
+        </Link>
+      </p>
+    </>
+  )
+}

filename: test/e2e/app-dir/app-prefetch/prefetching.stale-times.test.ts
patch: @@ -8,8 +8,8 @@ describe('app dir - prefetching (custom staleTime)', () => {
     nextConfig: {
       experimental: {
         staleTimes: {
-          static: 30,
-          dynamic: 20,
+          static: 10,
+          dynamic: 5,
         },
       },
     },
@@ -86,7 +86,7 @@ describe('app dir - prefetching (custom staleTime)', () => {
       .waitForElementByCss('#to-static-page')
 
     // Wait for the stale time to pass.
-    await waitFor(30000)
+    await waitFor(10000)
     // Click on the link to the static page again
     await linkToStaticPage.click()
     // Wait for the static page to load again
@@ -131,4 +131,37 @@ describe('app dir - prefetching (custom staleTime)', () => {
     // the initial fetch, 2 sub-page fetches, and a final fetch when reloading the page
     expect(await browser.elementById('count').text()).toBe('4')
   })
+
+  it('should fetch again when the initially visited static page is visited after the stale time has passed', async () => {
+    const browser = await next.browser('/404')
+    let requests: string[] = []
+
+    browser.on('request', (req) => {
+      // only consider requests that have RSC header but not the prefetch header
+      if (req.headers()['rsc'] && !req.headers()['next-router-prefetch']) {
+        requests.push(new URL(req.url()).pathname)
+      }
+    })
+
+    await browser.eval('location.href = "/static-page-no-prefetch"')
+
+    await browser
+      .elementByCss('#to-home')
+      .click()
+      .waitForElementByCss('#to-static-page')
+
+    // Wait for the stale time to pass.
+    await waitFor(10000)
+
+    await browser.elementByCss('#to-static-page-no-prefetch').click()
+
+    // Wait for the static page to load again
+    await browser.waitForElementByCss('#static-page-no-prefetch')
+
+    await retry(async () => {
+      expect(
+        requests.filter((request) => request === '/static-page-no-prefetch')
+      ).toHaveLength(1)
+    })
+  })
 })