[Breaking] Disable automatic fetch caching (#66004)

## Background

Previously we introduced automatic caching for `fetch` based on certain
heuristics that were a bit tricky to grasp all scenarios. The scenarios
we would automatically cache were no dynamic data access before the
fetch call e.g. `headers()` or `cookies()`, the fetch call is inside of
a dynamic page e.g. `POST` method or `export const revalidate = 0` page
and the fetch is a non-`GET` request or has `Authorization` or `Cookie`
headers, or the fetch had `cache: 'no-store' | 'no-cache'` or
`revalidate: 0`.

## New Behavior

By default fetches will no longer automatically be cached. Instead they
need to be opted-in to caching via `export const fetchCache =
'default-cache' | 'force-cache',` `next: { revalidate: false or value >
0 }` or `cache: 'force-cache' | 'default-cache'`.

When the fetch call is automatically skipping the cache it won't impact
the page level ISR cacheability although if a fetch call manually
specifies `cache: 'no-store'` or `revalidate: 0` it will still bail from
the page being statically generated as it was before.

To achieve the previous behavior of automatic fetch caching all that
needs to be added is `export const fetchCache = 'default-cache'` in the
root layout(s) of your project.
This commit is contained in:
JJ Kasper 2024-05-22 21:30:28 -05:00 committed by GitHub
parent 6c1c004953
commit 4d14e83173
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 167 additions and 58 deletions

View file

@ -1438,6 +1438,21 @@ export async function buildAppStaticPaths({
const newParams: Params[] = []
if (curGenerate.generateStaticParams) {
const curStore =
ComponentMod.staticGenerationAsyncStorage.getStore()
if (curStore) {
if (typeof curGenerate?.config?.fetchCache !== 'undefined') {
curStore.fetchCache = curGenerate.config.fetchCache
}
if (typeof curGenerate?.config?.revalidate !== 'undefined') {
curStore.revalidate = curGenerate.config.revalidate
}
if (curGenerate?.config?.dynamic === 'force-dynamic') {
curStore.forceDynamic = true
}
}
for (const params of paramsItems) {
const result = await curGenerate.generateStaticParams({
params,

View file

@ -289,7 +289,7 @@ function createPatchedFetcher(
return value || (isRequestInput ? (input as any)[field] : null)
}
let revalidate: number | undefined | false = undefined
let finalRevalidate: number | undefined | false = undefined
const getNextField = (field: 'revalidate' | 'tags') => {
return typeof init?.next?.[field] !== 'undefined'
? init?.next?.[field]
@ -299,7 +299,7 @@ function createPatchedFetcher(
}
// RequestInit doesn't keep extra fields e.g. next so it's
// only available if init is used separate
let curRevalidate = getNextField('revalidate')
let currentFetchRevalidate = getNextField('revalidate')
const tags: string[] = validateTags(
getNextField('tags') || [],
`fetch ${input.toString()}`
@ -317,49 +317,52 @@ function createPatchedFetcher(
}
const implicitTags = addImplicitTags(staticGenerationStore)
const fetchCacheMode = staticGenerationStore.fetchCache
const pageFetchCacheMode = staticGenerationStore.fetchCache
const isUsingNoStore = !!staticGenerationStore.isUnstableNoStore
let _cache = getRequestMeta('cache')
let currentFetchCacheConfig = getRequestMeta('cache')
let cacheReason = ''
if (
typeof _cache === 'string' &&
typeof curRevalidate !== 'undefined'
typeof currentFetchCacheConfig === 'string' &&
typeof currentFetchRevalidate !== 'undefined'
) {
// when providing fetch with a Request input, it'll automatically set a cache value of 'default'
// we only want to warn if the user is explicitly setting a cache value
if (!(isRequestInput && _cache === 'default')) {
if (!(isRequestInput && currentFetchCacheConfig === 'default')) {
Log.warn(
`fetch for ${fetchUrl} on ${staticGenerationStore.urlPathname} specified "cache: ${_cache}" and "revalidate: ${curRevalidate}", only one should be specified.`
`fetch for ${fetchUrl} on ${staticGenerationStore.urlPathname} specified "cache: ${currentFetchCacheConfig}" and "revalidate: ${currentFetchRevalidate}", only one should be specified.`
)
}
_cache = undefined
currentFetchCacheConfig = undefined
}
if (_cache === 'force-cache') {
curRevalidate = false
if (currentFetchCacheConfig === 'force-cache') {
currentFetchRevalidate = false
} else if (
_cache === 'no-cache' ||
_cache === 'no-store' ||
fetchCacheMode === 'force-no-store' ||
fetchCacheMode === 'only-no-store' ||
currentFetchCacheConfig === 'no-cache' ||
currentFetchCacheConfig === 'no-store' ||
pageFetchCacheMode === 'force-no-store' ||
pageFetchCacheMode === 'only-no-store' ||
// If no explicit fetch cache mode is set, but dynamic = `force-dynamic` is set,
// we shouldn't consider caching the fetch. This is because the `dynamic` cache
// is considered a "top-level" cache mode, whereas something like `fetchCache` is more
// fine-grained. Top-level modes are responsible for setting reasonable defaults for the
// other configurations.
(!fetchCacheMode && staticGenerationStore.forceDynamic)
(!pageFetchCacheMode && staticGenerationStore.forceDynamic)
) {
curRevalidate = 0
currentFetchRevalidate = 0
}
if (_cache === 'no-cache' || _cache === 'no-store') {
cacheReason = `cache: ${_cache}`
if (
currentFetchCacheConfig === 'no-cache' ||
currentFetchCacheConfig === 'no-store'
) {
cacheReason = `cache: ${currentFetchCacheConfig}`
}
revalidate = validateRevalidate(
curRevalidate,
finalRevalidate = validateRevalidate(
currentFetchRevalidate,
staticGenerationStore.urlPathname
)
@ -379,20 +382,29 @@ function createPatchedFetcher(
// if there are authorized headers or a POST method and
// dynamic data usage was present above the tree we bail
// e.g. if cookies() is used before an authed/POST fetch
// or no user provided fetch cache config or revalidate
// is provided we don't cache
const autoNoCache =
(hasUnCacheableHeader || isUnCacheableMethod) &&
staticGenerationStore.revalidate === 0
// this condition is hit for null/undefined
// eslint-disable-next-line eqeqeq
(pageFetchCacheMode == undefined &&
// eslint-disable-next-line eqeqeq
currentFetchCacheConfig == undefined &&
// eslint-disable-next-line eqeqeq
currentFetchRevalidate == undefined) ||
((hasUnCacheableHeader || isUnCacheableMethod) &&
staticGenerationStore.revalidate === 0)
switch (fetchCacheMode) {
switch (pageFetchCacheMode) {
case 'force-no-store': {
cacheReason = 'fetchCache = force-no-store'
break
}
case 'only-no-store': {
if (
_cache === 'force-cache' ||
(typeof revalidate !== 'undefined' &&
(revalidate === false || revalidate > 0))
currentFetchCacheConfig === 'force-cache' ||
(typeof finalRevalidate !== 'undefined' &&
(finalRevalidate === false || finalRevalidate > 0))
) {
throw new Error(
`cache: 'force-cache' used on fetch for ${fetchUrl} with 'export const fetchCache = 'only-no-store'`
@ -402,7 +414,7 @@ function createPatchedFetcher(
break
}
case 'only-cache': {
if (_cache === 'no-store') {
if (currentFetchCacheConfig === 'no-store') {
throw new Error(
`cache: 'no-store' used on fetch for ${fetchUrl} with 'export const fetchCache = 'only-cache'`
)
@ -410,9 +422,12 @@ function createPatchedFetcher(
break
}
case 'force-cache': {
if (typeof curRevalidate === 'undefined' || curRevalidate === 0) {
if (
typeof currentFetchRevalidate === 'undefined' ||
currentFetchRevalidate === 0
) {
cacheReason = 'fetchCache = force-cache'
revalidate = false
finalRevalidate = false
}
break
}
@ -423,59 +438,59 @@ function createPatchedFetcher(
// simplify the switch case and ensure we have an exhaustive switch handling all modes
}
if (typeof revalidate === 'undefined') {
if (fetchCacheMode === 'default-cache') {
revalidate = false
if (typeof finalRevalidate === 'undefined') {
if (pageFetchCacheMode === 'default-cache' && !isUsingNoStore) {
finalRevalidate = false
cacheReason = 'fetchCache = default-cache'
} else if (autoNoCache) {
revalidate = 0
cacheReason = 'auto no cache'
} else if (fetchCacheMode === 'default-no-store') {
revalidate = 0
} else if (pageFetchCacheMode === 'default-no-store') {
finalRevalidate = 0
cacheReason = 'fetchCache = default-no-store'
} else if (isUsingNoStore) {
revalidate = 0
finalRevalidate = 0
cacheReason = 'noStore call'
} else if (autoNoCache) {
finalRevalidate = 0
cacheReason = 'auto no cache'
} else {
// TODO: should we consider this case an invariant?
cacheReason = 'auto cache'
revalidate =
finalRevalidate =
typeof staticGenerationStore.revalidate === 'boolean' ||
typeof staticGenerationStore.revalidate === 'undefined'
? false
: staticGenerationStore.revalidate
}
} else if (!cacheReason) {
cacheReason = `revalidate: ${revalidate}`
cacheReason = `revalidate: ${finalRevalidate}`
}
if (
// when force static is configured we don't bail from
// `revalidate: 0` values
!(staticGenerationStore.forceStatic && revalidate === 0) &&
// we don't consider autoNoCache to switch to dynamic during
// revalidate although if it occurs during build we do
!(staticGenerationStore.forceStatic && finalRevalidate === 0) &&
// we don't consider autoNoCache to switch to dynamic for ISR
!autoNoCache &&
// If the revalidate value isn't currently set or the value is less
// than the current revalidate value, we should update the revalidate
// value.
(typeof staticGenerationStore.revalidate === 'undefined' ||
(typeof revalidate === 'number' &&
(typeof finalRevalidate === 'number' &&
(staticGenerationStore.revalidate === false ||
(typeof staticGenerationStore.revalidate === 'number' &&
revalidate < staticGenerationStore.revalidate))))
finalRevalidate < staticGenerationStore.revalidate))))
) {
// If we were setting the revalidate value to 0, we should try to
// postpone instead first.
if (revalidate === 0) {
if (finalRevalidate === 0) {
trackDynamicFetch(staticGenerationStore, 'revalidate: 0')
}
staticGenerationStore.revalidate = revalidate
staticGenerationStore.revalidate = finalRevalidate
}
const isCacheableRevalidate =
(typeof revalidate === 'number' && revalidate > 0) ||
revalidate === false
(typeof finalRevalidate === 'number' && finalRevalidate > 0) ||
finalRevalidate === false
let cacheKey: string | undefined
if (staticGenerationStore.incrementalCache && isCacheableRevalidate) {
@ -494,7 +509,7 @@ function createPatchedFetcher(
staticGenerationStore.nextFetchId = fetchIdx + 1
const normalizedRevalidate =
typeof revalidate !== 'number' ? CACHE_ONE_YEAR : revalidate
typeof finalRevalidate !== 'number' ? CACHE_ONE_YEAR : finalRevalidate
const doOriginalFetch = async (
isStale?: boolean,
@ -552,7 +567,9 @@ function createPatchedFetcher(
url: fetchUrl,
cacheReason: cacheReasonOverride || cacheReason,
cacheStatus:
revalidate === 0 || cacheReasonOverride ? 'skip' : 'miss',
finalRevalidate === 0 || cacheReasonOverride
? 'skip'
: 'miss',
status: res.status,
method: clonedInit.method || 'GET',
})
@ -580,7 +597,7 @@ function createPatchedFetcher(
},
{
fetchCache: true,
revalidate,
revalidate: finalRevalidate,
fetchUrl,
fetchIdx,
tags,
@ -613,7 +630,7 @@ function createPatchedFetcher(
? null
: await staticGenerationStore.incrementalCache.get(cacheKey, {
kindHint: 'fetch',
revalidate,
revalidate: finalRevalidate,
fetchUrl,
fetchIdx,
tags,

View file

@ -1,3 +1,5 @@
export const fetchCache = 'default-cache'
export const metadata = {
title: 'Next.js',
description: 'Generated by Next.js',

View file

@ -1,3 +1,5 @@
export const fetchCache = 'default-cache'
export default function Layout({ children }) {
return (
<html lang="en">

View file

@ -39,6 +39,33 @@ describe('app-dir static/dynamic handling', () => {
}
})
it('should use auto no cache when no fetch config', async () => {
const res = await next.fetch('/no-config-fetch')
expect(res.status).toBe(200)
const html = await res.text()
const $ = cheerio.load(html)
const data = $('#data').text()
expect(data).toBeTruthy()
const res2 = await next.fetch('/no-config-fetch')
const html2 = await res2.text()
const data2 = cheerio.load(html2)('#data').text()
if (isNextDev) {
expect(data).not.toBe(data2)
} else {
const pageCache = (
res.headers.get('x-vercel-cache') || res.headers.get('x-nextjs-cache')
).toLowerCase()
expect(pageCache).toBeTruthy()
expect(pageCache).not.toBe('MISS')
expect(data).toBe(data2)
}
})
it('should still cache even though the `traceparent` header was different', async () => {
const res = await next.fetch('/strip-header-traceparent')
expect(res.status).toBe(200)
@ -642,6 +669,8 @@ describe('app-dir static/dynamic handling', () => {
[
"(new)/custom/page.js",
"(new)/custom/page_client-reference-manifest.js",
"(new)/no-config-fetch/page.js",
"(new)/no-config-fetch/page_client-reference-manifest.js",
"_not-found.html",
"_not-found.rsc",
"_not-found/page.js",
@ -744,6 +773,8 @@ describe('app-dir static/dynamic handling', () => {
"isr-error-handling.rsc",
"isr-error-handling/page.js",
"isr-error-handling/page_client-reference-manifest.js",
"no-config-fetch.html",
"no-config-fetch.rsc",
"no-store/dynamic/page.js",
"no-store/dynamic/page_client-reference-manifest.js",
"no-store/static.html",
@ -1233,6 +1264,22 @@ describe('app-dir static/dynamic handling', () => {
"initialRevalidateSeconds": 3,
"srcRoute": "/isr-error-handling",
},
"/no-config-fetch": {
"dataRoute": "/no-config-fetch.rsc",
"experimentalBypassFor": [
{
"key": "Next-Action",
"type": "header",
},
{
"key": "content-type",
"type": "header",
"value": "multipart/form-data;.*",
},
],
"initialRevalidateSeconds": false,
"srcRoute": "/no-config-fetch",
},
"/no-store/static": {
"dataRoute": "/no-store/static.rsc",
"experimentalBypassFor": [
@ -2465,8 +2512,12 @@ describe('app-dir static/dynamic handling', () => {
const html2 = await res2.text()
const $2 = cheerio.load(html2)
expect($2('#layout-data').text()).toBe(layoutData)
expect($2('#page-data').text()).toBe(pageData)
// this relies on ISR level cache which isn't
// applied in dev
if (!isNextDev) {
expect($2('#layout-data').text()).toBe(layoutData)
expect($2('#page-data').text()).toBe(pageData)
}
return 'success'
}, 'success')
})
@ -2610,8 +2661,12 @@ describe('app-dir static/dynamic handling', () => {
const html2 = await res2.text()
const $2 = cheerio.load(html2)
expect($2('#layout-data').text()).toBe(layoutData)
expect($2('#page-data').text()).toBe(pageData)
// this relies on ISR level cache which isn't
// applied in dev
if (!isNextDev) {
expect($2('#layout-data').text()).toBe(layoutData)
expect($2('#page-data').text()).toBe(pageData)
}
return 'success'
}, 'success')
})

View file

@ -0,0 +1,12 @@
export default async function Page() {
const data = await fetch(
`https://next-data-api-endpoint.vercel.app/api/random`
).then((res) => res.text())
return (
<>
<p>/no-config-fetch</p>
<p id="data">{data}</p>
</>
)
}

View file

@ -1,4 +1,5 @@
export const dynamicParams = true
export const fetchCache = 'default-cache'
export async function generateStaticParams() {
const res = await fetch(

View file

@ -1,4 +1,5 @@
export const dynamicParams = false
export const fetchCache = 'default-cache'
export async function generateStaticParams() {
const res = await fetch(

View file

@ -1,5 +1,7 @@
import Link from 'next/link'
export const fetchCache = 'default-cache'
export default function Layout({ children }) {
return (
<html>

View file

@ -1,3 +1,5 @@
export const fetchCache = 'default-cache'
export default function Layout({ children }) {
return (
<html lang="en">