Skip to content

Commit

Permalink
prerendered pages should use static staleTime (#67868)
Browse files Browse the repository at this point in the history
### What
Prefetches for static pages currently aren't distinguished from dynamic
pages insofar as their client router cache TTL is concerned. This meant
pre v15, both static and dynamic pages leveraged the 30s cache. In v15,
this meant it would refetch the static data every navigation, since we
changed the `dynamic` staleTime value to `0`. The only way to force a
prefetch to use the static staletime was via `prefetch={true}` on a link
(or `router.prefetch`).

### Why
The router had no way of distinguishing between static or dynamic
responses. The only difference was the RSC payload between the two,
since a static response would contain the full tree, and a dynamic
response would potentially be short-circuited with `loading.js` or have
a fairly minimal response for just basic metadata.

### How
This introduces a header the client can leverage to determine if the
response is a prerender. If it is, we mark the "auto" prefetch entry as
"full" so that it respects the `static` staleTime value (current default
is 5min).

We use a new header, rather than piggybacking on `x-nextjs-cache` /
`x-vercel-cache`, because a static page can technically have a `MISS`
status if it's revalidating.

Fixes #66513

Closes NDX-65
  • Loading branch information
ztanner authored Jul 18, 2024
1 parent 669bd05 commit 8daa59e
Show file tree
Hide file tree
Showing 8 changed files with 121 additions and 60 deletions.
1 change: 1 addition & 0 deletions packages/next/src/client/components/app-router-headers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,3 +16,4 @@ export const FLIGHT_HEADERS = [
export const NEXT_RSC_UNION_QUERY = '_rsc' as const

export const NEXT_DID_POSTPONE_HEADER = 'x-nextjs-postponed' as const
export const NEXT_IS_PRERENDER_HEADER = 'x-nextjs-prerender' as const
Original file line number Diff line number Diff line change
Expand Up @@ -102,7 +102,13 @@ export function createInitialRouterState({
createPrefetchCacheEntryForInitialLoad({
url,
kind: PrefetchKind.AUTO,
data: { f: initialFlightData, c: undefined, i: !!couldBeIntercepted },
data: {
f: initialFlightData,
c: undefined,
i: !!couldBeIntercepted,
// TODO: the server should probably send a value for this. Default to false for now.
p: false,
},
tree: initialState.tree,
prefetchCache: initialState.prefetchCache,
nextUrl: initialState.nextUrl,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import {
RSC_HEADER,
RSC_CONTENT_TYPE_HEADER,
NEXT_HMR_REFRESH_HEADER,
NEXT_IS_PRERENDER_HEADER,
} from '../app-router-headers'
import { callServer } from '../../app-call-server'
import { PrefetchKind } from './router-reducer-types'
Expand Down Expand Up @@ -59,6 +60,7 @@ function doMpaNavigation(url: string): FetchServerResponseResult {
f: urlToUrlWithoutFlightMarker(url).toString(),
c: undefined,
i: false,
p: false,
}
}

Expand Down Expand Up @@ -156,6 +158,7 @@ export async function fetchServerResponse(

const contentType = res.headers.get('content-type') || ''
const interception = !!res.headers.get('vary')?.includes(NEXT_URL)
const isPrerender = !!res.headers.get(NEXT_IS_PRERENDER_HEADER)
let isFlightResponse = contentType === RSC_CONTENT_TYPE_HEADER

if (process.env.NODE_ENV === 'production') {
Expand Down Expand Up @@ -193,6 +196,7 @@ export async function fetchServerResponse(
f: response.f,
c: canonicalUrl,
i: interception,
p: isPrerender,
}
} catch (err) {
console.error(
Expand All @@ -206,6 +210,7 @@ export async function fetchServerResponse(
f: url.toString(),
c: undefined,
i: false,
p: false,
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,8 @@ function prefixExistingPrefetchCacheEntry({
const newCacheKey = createPrefetchCacheKey(url, nextUrl)
prefetchCache.set(newCacheKey, existingCacheEntry)
prefetchCache.delete(existingCacheKey)

return newCacheKey
}

/**
Expand Down Expand Up @@ -201,8 +203,28 @@ function createLazyPrefetchEntry({
// TODO: `fetchServerResponse` should be more tighly coupled to these prefetch cache operations
// to avoid drift between this cache key prefixing logic
// (which is currently directly influenced by the server response)
let newCacheKey

if (prefetchResponse.i) {
prefixExistingPrefetchCacheEntry({ url, nextUrl, prefetchCache })
// Determine if we need to prefix the cache key with the nextUrl
newCacheKey = prefixExistingPrefetchCacheEntry({
url,
nextUrl,
prefetchCache,
})
}

// If the prefetch was a cache hit, we want to update the existing cache entry to reflect that it was a full prefetch.
// This is because we know that a static response will contain the full RSC payload, and can be updated to respect the `static`
// staleTime.
if (prefetchResponse.p) {
const existingCacheEntry = prefetchCache.get(
// if we prefixed the cache key due to route interception, we want to use the new key. Otherwise we use the original key
newCacheKey ?? prefetchCacheKey
)
if (existingCacheEntry) {
existingCacheEntry.kind = PrefetchKind.FULL
}
}

return prefetchResponse
Expand Down
4 changes: 4 additions & 0 deletions packages/next/src/export/routes/app-page.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import { hasNextSupport } from '../../telemetry/ci-info'
import { lazyRenderAppPage } from '../../server/route-modules/app-page/module.render'
import { isBailoutToCSRError } from '../../shared/lib/lazy-dynamic/bailout-to-csr'
import { NodeNextRequest, NodeNextResponse } from '../../server/base-http/node'
import { NEXT_IS_PRERENDER_HEADER } from '../../client/components/app-router-headers'

export const enum ExportedAppPageFiles {
HTML = 'HTML',
Expand Down Expand Up @@ -114,6 +115,9 @@ export async function exportAppPage(

const headers: OutgoingHttpHeaders = { ...metadata.headers }

// If we're writing the file to disk, we know it's a prerender.
headers[NEXT_IS_PRERENDER_HEADER] = '1'

if (fetchTags) {
headers[NEXT_CACHE_TAGS_HEADER] = fetchTags
}
Expand Down
2 changes: 2 additions & 0 deletions packages/next/src/server/app-render/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -236,6 +236,8 @@ export type FetchServerResponseResult = {
c: URL | undefined
/** couldBeIntercepted */
i: boolean
/** isPrerender */
p: boolean
}

export type RSCPayload =
Expand Down
4 changes: 4 additions & 0 deletions packages/next/src/server/base-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,7 @@ import {
NEXT_DID_POSTPONE_HEADER,
NEXT_URL,
NEXT_ROUTER_STATE_TREE_HEADER,
NEXT_IS_PRERENDER_HEADER,
} from '../client/components/app-router-headers'
import type {
MatchOptions,
Expand Down Expand Up @@ -2914,6 +2915,9 @@ export default abstract class Server<
? 'STALE'
: 'HIT'
)
// Set a header used by the client router to signal the response is static
// and should respect the `static` cache staleTime value.
res.setHeader(NEXT_IS_PRERENDER_HEADER, '1')
}

const { value: cachedData } = cacheEntry
Expand Down
133 changes: 75 additions & 58 deletions test/e2e/app-dir/app-prefetch/prefetching.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,15 +28,10 @@ const browserConfigWithFixedTime = {
}

describe('app dir - prefetching', () => {
const { next, isNextDev, skipped } = nextTestSetup({
const { next, isNextDev, isNextDeploy } = nextTestSetup({
files: __dirname,
skipDeployment: true,
})

if (skipped) {
return
}

// TODO: re-enable for dev after https://vercel.slack.com/archives/C035J346QQL/p1663822388387959 is resolved (Sep 22nd 2022)
if (isNextDev) {
it('should skip next dev for now', () => {})
Expand Down Expand Up @@ -112,6 +107,23 @@ describe('app dir - prefetching', () => {
expect(
requests.filter((request) => request === '/static-page').length
).toBe(1)

// return to the home page
await browser.elementByCss('#to-home').click()
await browser.waitForElementByCss('#to-static-page')
// there shouldn't be any additional prefetches
expect(
requests.filter((request) => request === '/static-page').length
).toBe(1)

// navigate to the static page again
await browser.elementByCss('#to-static-page').click()
await browser.waitForElementByCss('#static-page')

// there still should only be the initial request to the static page
expect(
requests.filter((request) => request === '/static-page').length
).toBe(1)
})

it('should calculate `_rsc` query based on `Next-Url`', async () => {
Expand Down Expand Up @@ -289,60 +301,65 @@ describe('app dir - prefetching', () => {
await browser.waitForElementByCss('#prefetch-auto-page-data')
})

describe('dynamic rendering', () => {
describe.each(['/force-dynamic', '/revalidate-0'])('%s', (basePath) => {
it('should not re-render layout when navigating between sub-pages', async () => {
const logStartIndex = next.cliOutput.length

const browser = await next.browser(`${basePath}/test-page`)
let initialRandomNumber = await browser
.elementById('random-number')
.text()
await browser
.elementByCss(`[href="${basePath}/test-page/sub-page"]`)
.click()

await check(() => browser.hasElementByCssSelector('#sub-page'), true)

const newRandomNumber = await browser
.elementById('random-number')
.text()

expect(initialRandomNumber).toBe(newRandomNumber)

await check(() => {
const logOccurrences =
next.cliOutput.slice(logStartIndex).split('re-fetching in layout')
.length - 1

return logOccurrences
}, 1)
})

it('should update search params following a link click', async () => {
const browser = await next.browser(`${basePath}/search-params`)
await check(
() => browser.elementById('search-params-data').text(),
/{}/
)
await browser.elementByCss('[href="?foo=true"]').click()
await check(
() => browser.elementById('search-params-data').text(),
/{"foo":"true"}/
)
await browser.elementByCss(`[href="${basePath}/search-params"]`).click()
await check(
() => browser.elementById('search-params-data').text(),
/{}/
)
await browser.elementByCss('[href="?foo=true"]').click()
await check(
() => browser.elementById('search-params-data').text(),
/{"foo":"true"}/
)
// These tests are skipped when deployed as they rely on runtime logs
if (!isNextDeploy) {
describe('dynamic rendering', () => {
describe.each(['/force-dynamic', '/revalidate-0'])('%s', (basePath) => {
it('should not re-render layout when navigating between sub-pages', async () => {
const logStartIndex = next.cliOutput.length

const browser = await next.browser(`${basePath}/test-page`)
let initialRandomNumber = await browser
.elementById('random-number')
.text()
await browser
.elementByCss(`[href="${basePath}/test-page/sub-page"]`)
.click()

await check(() => browser.hasElementByCssSelector('#sub-page'), true)

const newRandomNumber = await browser
.elementById('random-number')
.text()

expect(initialRandomNumber).toBe(newRandomNumber)

await check(() => {
const logOccurrences =
next.cliOutput.slice(logStartIndex).split('re-fetching in layout')
.length - 1

return logOccurrences
}, 1)
})

it('should update search params following a link click', async () => {
const browser = await next.browser(`${basePath}/search-params`)
await check(
() => browser.elementById('search-params-data').text(),
/{}/
)
await browser.elementByCss('[href="?foo=true"]').click()
await check(
() => browser.elementById('search-params-data').text(),
/{"foo":"true"}/
)
await browser
.elementByCss(`[href="${basePath}/search-params"]`)
.click()
await check(
() => browser.elementById('search-params-data').text(),
/{}/
)
await browser.elementByCss('[href="?foo=true"]').click()
await check(
() => browser.elementById('search-params-data').text(),
/{"foo":"true"}/
)
})
})
})
})
}

describe('invalid URLs', () => {
it('should not throw when an invalid URL is passed to Link', async () => {
Expand Down

0 comments on commit 8daa59e

Please sign in to comment.