diff --git a/packages/next/src/client/components/router-reducer/fetch-server-response.ts b/packages/next/src/client/components/router-reducer/fetch-server-response.ts index 481fcfa7ad3a2..a1749859259e1 100644 --- a/packages/next/src/client/components/router-reducer/fetch-server-response.ts +++ b/packages/next/src/client/components/router-reducer/fetch-server-response.ts @@ -187,9 +187,13 @@ export async function fetchServerResponse( const contentType = res.headers.get('content-type') || '' const interception = !!res.headers.get('vary')?.includes(NEXT_URL) const postponed = !!res.headers.get(NEXT_DID_POSTPONE_HEADER) - const staleTimeHeader = res.headers.get(NEXT_ROUTER_STALE_TIME_HEADER) + const staleTimeHeaderSeconds = res.headers.get( + NEXT_ROUTER_STALE_TIME_HEADER + ) const staleTime = - staleTimeHeader !== null ? parseInt(staleTimeHeader, 10) : -1 + staleTimeHeaderSeconds !== null + ? parseInt(staleTimeHeaderSeconds, 10) * 1000 + : -1 let isFlightResponse = contentType.startsWith(RSC_CONTENT_TYPE_HEADER) if (process.env.NODE_ENV === 'production') { diff --git a/packages/next/src/export/index.ts b/packages/next/src/export/index.ts index b5f3daee1c165..f99e8b56807fd 100644 --- a/packages/next/src/export/index.ts +++ b/packages/next/src/export/index.ts @@ -391,6 +391,7 @@ async function exportAppImpl( experimental: { clientTraceMetadata: nextConfig.experimental.clientTraceMetadata, expireTime: nextConfig.expireTime, + staleTimes: nextConfig.experimental.staleTimes, dynamicIO: nextConfig.experimental.dynamicIO ?? false, clientSegmentCache: nextConfig.experimental.clientSegmentCache === 'client-only' diff --git a/packages/next/src/server/app-render/app-render.tsx b/packages/next/src/server/app-render/app-render.tsx index 4b2a98aa85233..890ba1e91859f 100644 --- a/packages/next/src/server/app-render/app-render.tsx +++ b/packages/next/src/server/app-render/app-render.tsx @@ -2704,6 +2704,12 @@ async function prerenderToStream( setMetadataHeader(name) } + const selectStaleTime = (stale: number) => + stale === INFINITE_CACHE && + typeof renderOpts.experimental.staleTimes?.static === 'number' + ? renderOpts.experimental.staleTimes.static + : stale + let prerenderStore: PrerenderStore | null = null try { @@ -3139,7 +3145,7 @@ async function prerenderToStream( // TODO: Should this include the SSR pass? collectedRevalidate: finalRenderPrerenderStore.revalidate, collectedExpire: finalRenderPrerenderStore.expire, - collectedStale: finalRenderPrerenderStore.stale, + collectedStale: selectStaleTime(finalRenderPrerenderStore.stale), collectedTags: finalRenderPrerenderStore.tags, } } else { @@ -3202,7 +3208,7 @@ async function prerenderToStream( // TODO: Should this include the SSR pass? collectedRevalidate: finalRenderPrerenderStore.revalidate, collectedExpire: finalRenderPrerenderStore.expire, - collectedStale: finalRenderPrerenderStore.stale, + collectedStale: selectStaleTime(finalRenderPrerenderStore.stale), collectedTags: finalRenderPrerenderStore.tags, } } @@ -3639,7 +3645,7 @@ async function prerenderToStream( // TODO: Should this include the SSR pass? collectedRevalidate: finalServerPrerenderStore.revalidate, collectedExpire: finalServerPrerenderStore.expire, - collectedStale: finalServerPrerenderStore.stale, + collectedStale: selectStaleTime(finalServerPrerenderStore.stale), collectedTags: finalServerPrerenderStore.tags, } } @@ -3789,7 +3795,7 @@ async function prerenderToStream( // TODO: Should this include the SSR pass? collectedRevalidate: reactServerPrerenderStore.revalidate, collectedExpire: reactServerPrerenderStore.expire, - collectedStale: reactServerPrerenderStore.stale, + collectedStale: selectStaleTime(reactServerPrerenderStore.stale), collectedTags: reactServerPrerenderStore.tags, } } else if (fallbackRouteParams && fallbackRouteParams.size > 0) { @@ -3809,7 +3815,7 @@ async function prerenderToStream( // TODO: Should this include the SSR pass? collectedRevalidate: reactServerPrerenderStore.revalidate, collectedExpire: reactServerPrerenderStore.expire, - collectedStale: reactServerPrerenderStore.stale, + collectedStale: selectStaleTime(reactServerPrerenderStore.stale), collectedTags: reactServerPrerenderStore.tags, } } else { @@ -3870,7 +3876,7 @@ async function prerenderToStream( // TODO: Should this include the SSR pass? collectedRevalidate: reactServerPrerenderStore.revalidate, collectedExpire: reactServerPrerenderStore.expire, - collectedStale: reactServerPrerenderStore.stale, + collectedStale: selectStaleTime(reactServerPrerenderStore.stale), collectedTags: reactServerPrerenderStore.tags, } } @@ -3964,7 +3970,7 @@ async function prerenderToStream( // TODO: Should this include the SSR pass? collectedRevalidate: prerenderLegacyStore.revalidate, collectedExpire: prerenderLegacyStore.expire, - collectedStale: prerenderLegacyStore.stale, + collectedStale: selectStaleTime(prerenderLegacyStore.stale), collectedTags: prerenderLegacyStore.tags, } } @@ -4146,8 +4152,9 @@ async function prerenderToStream( prerenderStore !== null ? prerenderStore.revalidate : INFINITE_CACHE, collectedExpire: prerenderStore !== null ? prerenderStore.expire : INFINITE_CACHE, - collectedStale: - prerenderStore !== null ? prerenderStore.stale : INFINITE_CACHE, + collectedStale: selectStaleTime( + prerenderStore !== null ? prerenderStore.stale : INFINITE_CACHE + ), collectedTags: prerenderStore !== null ? prerenderStore.tags : null, } } catch (finalErr: any) { diff --git a/packages/next/src/server/app-render/types.ts b/packages/next/src/server/app-render/types.ts index 602a68d7b3a8d..f997afe93ba1c 100644 --- a/packages/next/src/server/app-render/types.ts +++ b/packages/next/src/server/app-render/types.ts @@ -1,6 +1,9 @@ import type { LoadComponentsReturnType } from '../load-components' import type { ServerRuntime, SizeLimit } from '../../types' -import type { NextConfigComplete } from '../../server/config-shared' +import type { + ExperimentalConfig, + NextConfigComplete, +} from '../../server/config-shared' import type { ClientReferenceManifest } from '../../build/webpack/plugins/flight-manifest-plugin' import type { NextFontManifest } from '../../build/webpack/plugins/next-font-manifest-plugin' import type { ParsedUrlQuery } from 'querystring' @@ -228,6 +231,7 @@ export interface RenderOptsPartial { */ isRoutePPREnabled?: boolean expireTime: number | undefined + staleTimes: ExperimentalConfig['staleTimes'] | undefined clientTraceMetadata: string[] | undefined dynamicIO: boolean clientSegmentCache: boolean | 'client-only' diff --git a/packages/next/src/server/base-server.ts b/packages/next/src/server/base-server.ts index 5aa4a6b62eff8..02d0e24cc8352 100644 --- a/packages/next/src/server/base-server.ts +++ b/packages/next/src/server/base-server.ts @@ -602,6 +602,7 @@ export default abstract class Server< htmlLimitedBots: this.nextConfig.htmlLimitedBots, experimental: { expireTime: this.nextConfig.expireTime, + staleTimes: this.nextConfig.experimental.staleTimes, clientTraceMetadata: this.nextConfig.experimental.clientTraceMetadata, dynamicIO: this.nextConfig.experimental.dynamicIO ?? false, clientSegmentCache: diff --git a/test/e2e/app-dir/app-prefetch/prefetching.stale-times.test.ts b/test/e2e/app-dir/app-prefetch/prefetching.stale-times.test.ts index 08286b26b98fc..2dc02b1853d9c 100644 --- a/test/e2e/app-dir/app-prefetch/prefetching.stale-times.test.ts +++ b/test/e2e/app-dir/app-prefetch/prefetching.stale-times.test.ts @@ -1,31 +1,5 @@ import { nextTestSetup } from 'e2e-utils' -import { retry } from 'next-test-utils' - -import { NEXT_RSC_UNION_QUERY } from 'next/dist/client/components/app-router-headers' - -const browserConfigWithFixedTime = { - beforePageLoad: (page) => { - page.addInitScript(() => { - const startTime = new Date() - const fixedTime = new Date('2023-04-17T00:00:00Z') - - // Override the Date constructor - // @ts-ignore - // eslint-disable-next-line no-native-reassign - Date = class extends Date { - constructor() { - super() - // @ts-ignore - return new startTime.constructor(fixedTime) - } - - static now() { - return fixedTime.getTime() - } - } - }) - }, -} +import { retry, waitFor } from 'next-test-utils' describe('app dir - prefetching (custom staleTime)', () => { const { next, isNextDev } = nextTestSetup({ @@ -34,8 +8,8 @@ describe('app dir - prefetching (custom staleTime)', () => { nextConfig: { experimental: { staleTimes: { - static: 180, - dynamic: 30, + static: 30, + dynamic: 20, }, }, }, @@ -47,7 +21,7 @@ describe('app dir - prefetching (custom staleTime)', () => { } it('should not fetch again when a static page was prefetched when navigating to it twice', async () => { - const browser = await next.browser('/404', browserConfigWithFixedTime) + const browser = await next.browser('/404') let requests: string[] = [] browser.on('request', (req) => { @@ -55,17 +29,10 @@ describe('app dir - prefetching (custom staleTime)', () => { }) await browser.eval('location.href = "/"') - await browser.eval( - `window.nd.router.prefetch("/static-page", {kind: "auto"})` - ) - await retry(async () => { expect( - requests.filter( - (request) => - request === '/static-page' || request.includes(NEXT_RSC_UNION_QUERY) - ).length - ).toBe(1) + requests.filter((request) => request === '/static-page') + ).toHaveLength(1) }) await browser @@ -86,11 +53,49 @@ describe('app dir - prefetching (custom staleTime)', () => { await retry(async () => { expect( - requests.filter( - (request) => - request === '/static-page' || request.includes(NEXT_RSC_UNION_QUERY) - ).length - ).toBe(1) + requests.filter((request) => request === '/static-page') + ).toHaveLength(1) + }) + }) + + it('should fetch again when a static page was prefetched when navigating to it after the stale time has passed', async () => { + const browser = await next.browser('/404') + let requests: string[] = [] + + browser.on('request', (req) => { + requests.push(new URL(req.url()).pathname) + }) + await browser.eval('location.href = "/"') + + await retry(async () => { + expect( + requests.filter((request) => request === '/static-page') + ).toHaveLength(1) + }) + + await browser + .elementByCss('#to-static-page') + .click() + .waitForElementByCss('#static-page') + + const linkToStaticPage = await browser + .elementByCss('#to-home') + // Go back to home page + .click() + // Wait for homepage to load + .waitForElementByCss('#to-static-page') + + // Wait for the stale time to pass. + await waitFor(30000) + // Click on the link to the static page again + await linkToStaticPage.click() + // Wait for the static page to load again + await browser.waitForElementByCss('#static-page') + + await retry(async () => { + expect( + requests.filter((request) => request === '/static-page') + ).toHaveLength(2) }) })