Files
Hendrik LiebauandGitHub 61f8830a39 Keep the resolved cache life for cacheMaxMemorySize: 0 caches in dev (#95100)
To make `cacheMaxMemorySize: 0` fast in development, the `'use cache'`
wrapper forced every built-in cache entry to a dynamic cache life
(`revalidate: 0`, a five-minute `expire`) so that warm reads would serve
the stale value and regenerate a fresh one in the background. That
forcing leaked into the stored entry, and an entry with `revalidate: 0`
is treated as too dynamic to live in a static shell. On a warm reload of
a route that is fully cached and has no `<Suspense>` above the `'use
cache'`, the dev validation prerender read the entry and threw an error
claiming that a `'use cache'` with zero `revalidate` was nested inside
another `'use cache'` that has no explicit `cacheLife`, even though
nothing was actually nested, and reported the route as a blocking route.
The cold load was unaffected because the entry did not exist yet.

With this change the size-0 case no longer forces a dynamic cache life
and keeps the entry's resolved life instead: the explicit `cacheLife()`
values when the user set them, otherwise the default (a 15-minute
`revalidate` and an infinite `expire`). That default sits well above the
dynamic thresholds, so a fully cached route can be prerendered again and
the false-positive blocking error is gone. An explicitly short
`cacheLife()` still makes the route legitimately dynamic without a
spurious throw, because the explicit-revalidate and explicit-expire
flags are then set. Private caches are unchanged and remain
forced-dynamic.

So that reloads keep showing a fresh value now that the stored life is
no longer always stale, the background revalidation in the cache-hit
path also fires for size-0 built-in entries regardless of the stored
`revalidate`, restricted to the dynamic dev request render so the dev
validation prerender does not also regenerate.
2026-06-24 09:15:45 +00:00

108 lines
4.0 KiB
TypeScript

import { nextTestSetup } from 'e2e-utils'
import { retry, waitFor, waitForNoErrorToast } from 'next-test-utils'
describe('use-cache-size-zero', () => {
const { next, skipped } = nextTestSetup({
files: __dirname,
})
if (skipped) {
return
}
it('serves the stale cached value on a warm reload, then converges to a fresh one', async () => {
const browser = await next.browser('/reload', {
waitHydration: false,
// Do not wait for "load"; inspect the page as it streams in.
waitUntil: 'commit',
})
// Cold load: the cache misses, so the loading boundary (the only `p` at
// this point) streams first, and the generated value streams in once the
// ~1s generation completes. Read at commit (`waitUntil: false`) so we don't
// wait for "load", which only fires once the value has streamed in.
expect(await browser.elementByCss('p', { waitUntil: false }).text()).toBe(
'Loading...'
)
await retry(async () => {
expect(
await browser.elementByCss('p', { waitUntil: false }).text()
).toBeDateString()
})
const coldValue = await browser
.elementByCss('p', { waitUntil: false })
.text()
// Warm reload: `cacheMaxMemorySize: 0` still caches in development, so the
// reload serves the previously cached value fast instead of regenerating
// it. The entry keeps its default (non-dynamic) cache life, so it's served
// straight from the cache rather than treated as a dynamic hole. A
// background revalidation regenerates a fresh entry for the next reload
// (asserted below).
await browser.refresh({ waitUntil: 'commit' })
await retry(async () => {
expect(
await browser.elementByCss('p', { waitUntil: false }).text()
).toBeDateString()
})
expect(await browser.elementByCss('p', { waitUntil: false }).text()).toBe(
coldValue
)
// That warm reload regenerated a fresh entry in the background, so a later
// reload converges to the new value. Read after "load" here (a plain
// refresh) since we want the settled value, not the streaming inspection
// above.
await retry(async () => {
await browser.refresh()
expect(await browser.elementById('value').text()).not.toBe(coldValue)
})
})
it('shows the Cold cache badge on an initial cold load and not on a warm reload', async () => {
const browser = await next.browser('/cold-badge')
// Cold load: the cache misses and fills while streaming, so the cold
// verdict is reported and (once the dev overlay's socket connects) the
// badge appears.
await browser.elementById('value')
await retry(async () => {
expect(await browser.hasElementByCss('[data-cold-cache-badge]')).toBe(
true
)
})
expect(
await browser
.elementByCss('[data-cold-cache-badge] [data-issues-open]')
.text()
).toBe('Cold cache')
// Warm reload: the dev cache serves the entry without a miss, so no cold
// verdict is reported. An absence can't be retried on, so wait out the
// window in which a replayed push would arrive, then assert it never did.
await browser.refresh()
await browser.elementById('value')
await waitFor(500)
expect(await browser.hasElementByCss('[data-cold-cache-badge]')).toBe(false)
})
it('does not surface a blocking-route error on a warm reload of a fully cached route', async () => {
const browser = await next.browser('/')
// Cold load: the page-level `'use cache'` misses and fills in the
// background while the result streams in immediately.
await retry(async () => {
expect(await browser.elementByCss('p').text()).toBe('Hello, world!')
})
// Warm reload (the second request): the dev cache serves the cached value.
// The route has no dynamic data, so serving it from cache must not surface
// a false-positive blocking-route red box.
await browser.refresh()
await retry(async () => {
expect(await browser.elementByCss('p').text()).toBe('Hello, world!')
})
await waitForNoErrorToast(browser)
})
})