Repository: vercel/next.js
Pull request: bugfix: static resources staleTime should be renewed once refetched

When the `x-nextjs-staletime` header is sent from the server, the client router was discarding all of its staleTime heuristics (such as lastUsedTime and "reusable" cache entries). This caused an issue where after the static staleTime window expired and the prefetch data was renewed, the static staleTime itself was not being renewed.

As a result, once the static staleTime expired, the client would continuously refetch the resource on every navigation, even though it had just been renewed.

This removes the `x-nextjs-staleTime` logic from this heuristic because it was only intended to be considered for the new `clientSegmentCache` & cache components work

Changed files:
filename: packages/next/src/client/components/router-reducer/prefetch-cache-utils.ts
patch: @@ -404,22 +404,7 @@ function getPrefetchEntryCacheStatus({
   kind,
   prefetchTime,
   lastUsedTime,
-  staleTime,
 }: PrefetchCacheEntry): PrefetchCacheEntryStatus {
-  if (staleTime !== -1) {
-    // `staleTime` is the value sent by the server during static generation.
-    // When this is available, it takes precedence over any of the heuristics
-    // that follow.
-    //
-    // TODO: When PPR is enabled, the server will *always* return a stale time
-    // when prefetching. We should never use a prefetch entry that hasn't yet
-    // received data from the server. So the only two cases should be 1) we use
-    // the server-generated stale time 2) the unresolved entry is discarded.
-    return Date.now() < prefetchTime + staleTime
-      ? PrefetchCacheEntryStatus.fresh
-      : PrefetchCacheEntryStatus.stale
-  }
-
   // We will re-use the cache entry data for up to the `dynamic` staletime window.
   if (Date.now() < (lastUsedTime ?? prefetchTime) + DYNAMIC_STALETIME_MS) {
     return lastUsedTime

filename: test/e2e/app-dir/app-prefetch/prefetching.stale-times.test.ts
patch: @@ -137,9 +137,11 @@ describe('app dir - prefetching (custom staleTime)', () => {
     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)
+      const path = new URL(req.url()).pathname
+      const headers = req.headers()
+
+      if (headers['rsc']) {
+        requests.push(path)
       }
     })
 
@@ -164,4 +166,73 @@ describe('app dir - prefetching (custom staleTime)', () => {
       ).toHaveLength(1)
     })
   })
+
+  it('should renew the stale time after refetching expired RSC data', async () => {
+    const browser = await next.browser('/404')
+    let requests: string[] = []
+
+    browser.on('request', (req) => {
+      requests.push(new URL(req.url()).pathname)
+    })
+
+    // Navigate to home and wait for static page to be prefetched
+    await browser.eval('location.href = "/"')
+
+    await retry(async () => {
+      expect(
+        requests.filter((request) => request === '/static-page')
+      ).toHaveLength(1)
+    })
+
+    // Navigate to static page (should use cached data)
+    await browser
+      .elementByCss('#to-static-page')
+      .click()
+      .waitForElementByCss('#static-page')
+
+    // Go back to home
+    await browser
+      .elementByCss('#to-home')
+      .click()
+      .waitForElementByCss('#to-static-page')
+
+    // Wait for stale time to expire (10 seconds)
+    await waitFor(10000)
+
+    // Navigate to static page again (should refetch due to expired cache)
+    await browser
+      .elementByCss('#to-static-page')
+      .click()
+      .waitForElementByCss('#static-page')
+
+    // Verify that refetch happened
+    await retry(async () => {
+      expect(
+        requests.filter((request) => request === '/static-page')
+      ).toHaveLength(2)
+    })
+
+    // Go back to home
+    await browser
+      .elementByCss('#to-home')
+      .click()
+      .waitForElementByCss('#to-static-page')
+
+    // Wait less than the stale time (5 seconds - should still be fresh)
+    await waitFor(5000)
+
+    // Navigate to static page again (should NOT refetch - stale time should be renewed)
+    await browser
+      .elementByCss('#to-static-page')
+      .click()
+      .waitForElementByCss('#static-page')
+
+    // This should still be 2 - no new request should have been made
+    // If this fails, it means the stale time was not renewed after the refetch
+    await retry(async () => {
+      expect(
+        requests.filter((request) => request === '/static-page')
+      ).toHaveLength(2)
+    })
+  })
 })