mirror of
https://github.com/hanzoai/next.js.git
synced 2026-09-24 17:00:51 +00:00
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.
108 lines
4.0 KiB
TypeScript
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)
|
|
})
|
|
})
|