Skip to content

Commit b42b442

Browse files
authored
Client router should discard stale prefetch entries for static pages (#79309)
When navigating to a static page that was previously prefetched, the client router should discard a stale prefetch entry, and fetch the page again, if the stale time has passed. The selected stale time is either the default value of 5 minutes, a custom value set via `experimental.staleTimes.static`, or a stale value set via a `cacheLife` profile when using `"use cache"`. This also fixes an issue where the client router interpreted the stale time value as milliseconds, whereas the server sends it in seconds. This was previously already fixed with `clientSegmentCache` enabled in #74759. fixes #74272 fixes #79093 Closes NEXT-4109
1 parent 7e5ed3b commit b42b442

File tree

6 files changed

+78
-56
lines changed

6 files changed

+78
-56
lines changed

packages/next/src/client/components/router-reducer/fetch-server-response.ts

+6-2
Original file line numberDiff line numberDiff line change
@@ -187,9 +187,13 @@ export async function fetchServerResponse(
187187
const contentType = res.headers.get('content-type') || ''
188188
const interception = !!res.headers.get('vary')?.includes(NEXT_URL)
189189
const postponed = !!res.headers.get(NEXT_DID_POSTPONE_HEADER)
190-
const staleTimeHeader = res.headers.get(NEXT_ROUTER_STALE_TIME_HEADER)
190+
const staleTimeHeaderSeconds = res.headers.get(
191+
NEXT_ROUTER_STALE_TIME_HEADER
192+
)
191193
const staleTime =
192-
staleTimeHeader !== null ? parseInt(staleTimeHeader, 10) : -1
194+
staleTimeHeaderSeconds !== null
195+
? parseInt(staleTimeHeaderSeconds, 10) * 1000
196+
: -1
193197
let isFlightResponse = contentType.startsWith(RSC_CONTENT_TYPE_HEADER)
194198

195199
if (process.env.NODE_ENV === 'production') {

packages/next/src/export/index.ts

+1
Original file line numberDiff line numberDiff line change
@@ -391,6 +391,7 @@ async function exportAppImpl(
391391
experimental: {
392392
clientTraceMetadata: nextConfig.experimental.clientTraceMetadata,
393393
expireTime: nextConfig.expireTime,
394+
staleTimes: nextConfig.experimental.staleTimes,
394395
dynamicIO: nextConfig.experimental.dynamicIO ?? false,
395396
clientSegmentCache:
396397
nextConfig.experimental.clientSegmentCache === 'client-only'

packages/next/src/server/app-render/app-render.tsx

+16-9
Original file line numberDiff line numberDiff line change
@@ -2704,6 +2704,12 @@ async function prerenderToStream(
27042704
setMetadataHeader(name)
27052705
}
27062706

2707+
const selectStaleTime = (stale: number) =>
2708+
stale === INFINITE_CACHE &&
2709+
typeof renderOpts.experimental.staleTimes?.static === 'number'
2710+
? renderOpts.experimental.staleTimes.static
2711+
: stale
2712+
27072713
let prerenderStore: PrerenderStore | null = null
27082714

27092715
try {
@@ -3139,7 +3145,7 @@ async function prerenderToStream(
31393145
// TODO: Should this include the SSR pass?
31403146
collectedRevalidate: finalRenderPrerenderStore.revalidate,
31413147
collectedExpire: finalRenderPrerenderStore.expire,
3142-
collectedStale: finalRenderPrerenderStore.stale,
3148+
collectedStale: selectStaleTime(finalRenderPrerenderStore.stale),
31433149
collectedTags: finalRenderPrerenderStore.tags,
31443150
}
31453151
} else {
@@ -3202,7 +3208,7 @@ async function prerenderToStream(
32023208
// TODO: Should this include the SSR pass?
32033209
collectedRevalidate: finalRenderPrerenderStore.revalidate,
32043210
collectedExpire: finalRenderPrerenderStore.expire,
3205-
collectedStale: finalRenderPrerenderStore.stale,
3211+
collectedStale: selectStaleTime(finalRenderPrerenderStore.stale),
32063212
collectedTags: finalRenderPrerenderStore.tags,
32073213
}
32083214
}
@@ -3639,7 +3645,7 @@ async function prerenderToStream(
36393645
// TODO: Should this include the SSR pass?
36403646
collectedRevalidate: finalServerPrerenderStore.revalidate,
36413647
collectedExpire: finalServerPrerenderStore.expire,
3642-
collectedStale: finalServerPrerenderStore.stale,
3648+
collectedStale: selectStaleTime(finalServerPrerenderStore.stale),
36433649
collectedTags: finalServerPrerenderStore.tags,
36443650
}
36453651
}
@@ -3789,7 +3795,7 @@ async function prerenderToStream(
37893795
// TODO: Should this include the SSR pass?
37903796
collectedRevalidate: reactServerPrerenderStore.revalidate,
37913797
collectedExpire: reactServerPrerenderStore.expire,
3792-
collectedStale: reactServerPrerenderStore.stale,
3798+
collectedStale: selectStaleTime(reactServerPrerenderStore.stale),
37933799
collectedTags: reactServerPrerenderStore.tags,
37943800
}
37953801
} else if (fallbackRouteParams && fallbackRouteParams.size > 0) {
@@ -3809,7 +3815,7 @@ async function prerenderToStream(
38093815
// TODO: Should this include the SSR pass?
38103816
collectedRevalidate: reactServerPrerenderStore.revalidate,
38113817
collectedExpire: reactServerPrerenderStore.expire,
3812-
collectedStale: reactServerPrerenderStore.stale,
3818+
collectedStale: selectStaleTime(reactServerPrerenderStore.stale),
38133819
collectedTags: reactServerPrerenderStore.tags,
38143820
}
38153821
} else {
@@ -3870,7 +3876,7 @@ async function prerenderToStream(
38703876
// TODO: Should this include the SSR pass?
38713877
collectedRevalidate: reactServerPrerenderStore.revalidate,
38723878
collectedExpire: reactServerPrerenderStore.expire,
3873-
collectedStale: reactServerPrerenderStore.stale,
3879+
collectedStale: selectStaleTime(reactServerPrerenderStore.stale),
38743880
collectedTags: reactServerPrerenderStore.tags,
38753881
}
38763882
}
@@ -3964,7 +3970,7 @@ async function prerenderToStream(
39643970
// TODO: Should this include the SSR pass?
39653971
collectedRevalidate: prerenderLegacyStore.revalidate,
39663972
collectedExpire: prerenderLegacyStore.expire,
3967-
collectedStale: prerenderLegacyStore.stale,
3973+
collectedStale: selectStaleTime(prerenderLegacyStore.stale),
39683974
collectedTags: prerenderLegacyStore.tags,
39693975
}
39703976
}
@@ -4146,8 +4152,9 @@ async function prerenderToStream(
41464152
prerenderStore !== null ? prerenderStore.revalidate : INFINITE_CACHE,
41474153
collectedExpire:
41484154
prerenderStore !== null ? prerenderStore.expire : INFINITE_CACHE,
4149-
collectedStale:
4150-
prerenderStore !== null ? prerenderStore.stale : INFINITE_CACHE,
4155+
collectedStale: selectStaleTime(
4156+
prerenderStore !== null ? prerenderStore.stale : INFINITE_CACHE
4157+
),
41514158
collectedTags: prerenderStore !== null ? prerenderStore.tags : null,
41524159
}
41534160
} catch (finalErr: any) {

packages/next/src/server/app-render/types.ts

+5-1
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
11
import type { LoadComponentsReturnType } from '../load-components'
22
import type { ServerRuntime, SizeLimit } from '../../types'
3-
import type { NextConfigComplete } from '../../server/config-shared'
3+
import type {
4+
ExperimentalConfig,
5+
NextConfigComplete,
6+
} from '../../server/config-shared'
47
import type { ClientReferenceManifest } from '../../build/webpack/plugins/flight-manifest-plugin'
58
import type { NextFontManifest } from '../../build/webpack/plugins/next-font-manifest-plugin'
69
import type { ParsedUrlQuery } from 'querystring'
@@ -228,6 +231,7 @@ export interface RenderOptsPartial {
228231
*/
229232
isRoutePPREnabled?: boolean
230233
expireTime: number | undefined
234+
staleTimes: ExperimentalConfig['staleTimes'] | undefined
231235
clientTraceMetadata: string[] | undefined
232236
dynamicIO: boolean
233237
clientSegmentCache: boolean | 'client-only'

packages/next/src/server/base-server.ts

+1
Original file line numberDiff line numberDiff line change
@@ -602,6 +602,7 @@ export default abstract class Server<
602602
htmlLimitedBots: this.nextConfig.htmlLimitedBots,
603603
experimental: {
604604
expireTime: this.nextConfig.expireTime,
605+
staleTimes: this.nextConfig.experimental.staleTimes,
605606
clientTraceMetadata: this.nextConfig.experimental.clientTraceMetadata,
606607
dynamicIO: this.nextConfig.experimental.dynamicIO ?? false,
607608
clientSegmentCache:

test/e2e/app-dir/app-prefetch/prefetching.stale-times.test.ts

+49-44
Original file line numberDiff line numberDiff line change
@@ -1,31 +1,5 @@
11
import { nextTestSetup } from 'e2e-utils'
2-
import { retry } from 'next-test-utils'
3-
4-
import { NEXT_RSC_UNION_QUERY } from 'next/dist/client/components/app-router-headers'
5-
6-
const browserConfigWithFixedTime = {
7-
beforePageLoad: (page) => {
8-
page.addInitScript(() => {
9-
const startTime = new Date()
10-
const fixedTime = new Date('2023-04-17T00:00:00Z')
11-
12-
// Override the Date constructor
13-
// @ts-ignore
14-
// eslint-disable-next-line no-native-reassign
15-
Date = class extends Date {
16-
constructor() {
17-
super()
18-
// @ts-ignore
19-
return new startTime.constructor(fixedTime)
20-
}
21-
22-
static now() {
23-
return fixedTime.getTime()
24-
}
25-
}
26-
})
27-
},
28-
}
2+
import { retry, waitFor } from 'next-test-utils'
293

304
describe('app dir - prefetching (custom staleTime)', () => {
315
const { next, isNextDev } = nextTestSetup({
@@ -34,8 +8,8 @@ describe('app dir - prefetching (custom staleTime)', () => {
348
nextConfig: {
359
experimental: {
3610
staleTimes: {
37-
static: 180,
38-
dynamic: 30,
11+
static: 30,
12+
dynamic: 20,
3913
},
4014
},
4115
},
@@ -47,25 +21,18 @@ describe('app dir - prefetching (custom staleTime)', () => {
4721
}
4822

4923
it('should not fetch again when a static page was prefetched when navigating to it twice', async () => {
50-
const browser = await next.browser('/404', browserConfigWithFixedTime)
24+
const browser = await next.browser('/404')
5125
let requests: string[] = []
5226

5327
browser.on('request', (req) => {
5428
requests.push(new URL(req.url()).pathname)
5529
})
5630
await browser.eval('location.href = "/"')
5731

58-
await browser.eval(
59-
`window.nd.router.prefetch("/static-page", {kind: "auto"})`
60-
)
61-
6232
await retry(async () => {
6333
expect(
64-
requests.filter(
65-
(request) =>
66-
request === '/static-page' || request.includes(NEXT_RSC_UNION_QUERY)
67-
).length
68-
).toBe(1)
34+
requests.filter((request) => request === '/static-page')
35+
).toHaveLength(1)
6936
})
7037

7138
await browser
@@ -86,11 +53,49 @@ describe('app dir - prefetching (custom staleTime)', () => {
8653

8754
await retry(async () => {
8855
expect(
89-
requests.filter(
90-
(request) =>
91-
request === '/static-page' || request.includes(NEXT_RSC_UNION_QUERY)
92-
).length
93-
).toBe(1)
56+
requests.filter((request) => request === '/static-page')
57+
).toHaveLength(1)
58+
})
59+
})
60+
61+
it('should fetch again when a static page was prefetched when navigating to it after the stale time has passed', async () => {
62+
const browser = await next.browser('/404')
63+
let requests: string[] = []
64+
65+
browser.on('request', (req) => {
66+
requests.push(new URL(req.url()).pathname)
67+
})
68+
await browser.eval('location.href = "/"')
69+
70+
await retry(async () => {
71+
expect(
72+
requests.filter((request) => request === '/static-page')
73+
).toHaveLength(1)
74+
})
75+
76+
await browser
77+
.elementByCss('#to-static-page')
78+
.click()
79+
.waitForElementByCss('#static-page')
80+
81+
const linkToStaticPage = await browser
82+
.elementByCss('#to-home')
83+
// Go back to home page
84+
.click()
85+
// Wait for homepage to load
86+
.waitForElementByCss('#to-static-page')
87+
88+
// Wait for the stale time to pass.
89+
await waitFor(30000)
90+
// Click on the link to the static page again
91+
await linkToStaticPage.click()
92+
// Wait for the static page to load again
93+
await browser.waitForElementByCss('#static-page')
94+
95+
await retry(async () => {
96+
expect(
97+
requests.filter((request) => request === '/static-page')
98+
).toHaveLength(2)
9499
})
95100
})
96101

0 commit comments

Comments
 (0)