Files
Hendrik LiebauandGitHub 6f4d94ac88 Stream Cache Components dev render instead of restarting on cache miss (#94457)
When Cache Components is enabled, `next dev` previously simulated a
production loading experience on every cold request. The render did a
prospective pass to detect cache misses, and on any miss it waited for
every cache to fill via `cacheSignal.cacheReady()` and then restarted
the render with warm caches before streaming anything, so the browser
saw nothing until the slowest cache had filled. Every cold load blocked
on cache population.

This change replaces the restart-on-cache-miss flow with a single
non-abandoning staged render that streams immediately and fills caches
as a side effect. On a cold load the Suspense fallbacks stream right
away and the cached content resolves as its cache fills; on a warm
reload the staged progression matches the previous no-cache-miss path.
The render is split into clearly owned pieces: `setUpStagedDevRender`
builds the staged controller, cache signal, and resume cache;
`streamStagedRenderInDev{Node,Web}` runs the streaming render and
reports a result once the stream has fully finished; and
`stagedRenderWithCachesInDev{Node,Web}` returns the stream and leaves
the validation follow-up detached so it never blocks the response.

The render advances its stages in sequential tasks, and the stream is
not handed back the instant it exists: it is held until the render has
advanced through the stage whose content belongs in the shell. That is
the static stage for initial loads, HMR refreshes, and plain
navigations, or the runtime stage for client navigations to a route with
a runtime prefetch config, whose runtime-prefetchable content the
navigation's prefetch would have settled. It never waits for the dynamic
stage. Buffering the shell before the first flush keeps the streaming
renderer from emitting a premature Suspense fallback for content that
belongs in the shell, and it mirrors production, where the static shell
(plus runtime-prefetchable content where configured) is served and the
remaining holes stream in as fallbacks.

Two internal reads that would otherwise register as synchronous IO and
wrongly force the render to the dynamic stage, the cache handler's
tag-expiry clock check and the hot reloader's module-scope dev client
id, are now read untracked: via `performance.timeOrigin +
performance.now()` like the `'use cache'` handler, and only in the
browser where the HMR connection reads it, respectively.

Cache Components rules validation now runs in that background follow-up,
once the streamed render has fully settled. `planDevValidation` inspects
the finished render and picks one of three paths: forward an invalid
dynamic usage error the streamed render already recorded and stop (for
example a request API used inside `'use cache'`); validate the streamed
render's own chunks when it neither missed caches nor hit sync IO; or,
when it did either, validate a dedicated warm-cache render instead.
Because that warm render reads the filled caches back rather than
filling them, it can surface an invalid dynamic usage error the cold
streamed render cannot, such as a nested dynamic `use cache` cache life
that propagated to a parent with no explicit `cacheLife`; that error is
forwarded and validation is skipped, just as one recorded by the
streamed render is.

Since cold loads no longer block on cache fills, the transient
cache-status indicator that reflected that wait is no longer emitted; a
follow-up will instead add an indicator that tells the user whether a
render streamed with cache misses, and so wasn't representative of
production.
2026-06-09 10:38:12 +00:00

232 lines
9.6 KiB
TypeScript

import { nextTestSetup } from 'e2e-utils'
import { assertNoConsoleErrors, waitForNoRedbox } from 'next-test-utils'
import stripAnsi from 'strip-ansi'
const getExpectedErrorMessage = (route: string) =>
`Route ${route} used \`searchParams\` inside "use cache". Accessing dynamic request data inside a cache scope is not supported. If you need some search params inside a cached function await \`searchParams\` outside of the cached function and pass only the required search params as arguments to the cached function. See more info here: https://nextjs.org/docs/messages/next-request-in-use-cache`
describe('use-cache-search-params', () => {
const { next, isNextDev, skipped } = nextTestSetup({
files: __dirname,
skipDeployment: true,
skipStart: process.env.NEXT_TEST_MODE !== 'dev',
})
if (skipped) {
return
}
if (isNextDev) {
let route: string
describe('when searchParams are used inside of "use cache"', () => {
beforeAll(() => {
route = '/search-params-used'
})
it('should show an error', async () => {
const outputIndex = next.cliOutput.length
const browser = await next.browser(`${route}?foo=1`)
await expect(browser).toDisplayRedbox(`
{
"code": "E842",
"description": "Route /search-params-used used \`searchParams\` inside "use cache". Accessing dynamic request data inside a cache scope is not supported. If you need some search params inside a cached function await \`searchParams\` outside of the cached function and pass only the required search params as arguments to the cached function. See more info here: https://nextjs.org/docs/messages/next-request-in-use-cache",
"environmentLabel": "Cache",
"label": "Runtime Error",
"source": "app/search-params-used/page.tsx (8:17) @ Page
> 8 | const param = (await searchParams).foo
| ^",
"stack": [
"Page app/search-params-used/page.tsx (8:17)",
],
}
`)
const cliOutput = stripAnsi(next.cliOutput.slice(outputIndex))
expect(cliOutput).toContain(`Error: ${getExpectedErrorMessage(route)}
at Page (app/search-params-used/page.tsx:8:17)`)
})
})
describe('when searchParams are caught inside of "use cache"', () => {
beforeAll(() => {
route = '/search-params-caught'
})
it('should show an error', async () => {
const outputIndex = next.cliOutput.length
const browser = await next.browser(`${route}?foo=1`)
await expect(browser).toDisplayCollapsedRedbox(`
{
"code": "E842",
"description": "Route /search-params-caught used \`searchParams\` inside "use cache". Accessing dynamic request data inside a cache scope is not supported. If you need some search params inside a cached function await \`searchParams\` outside of the cached function and pass only the required search params as arguments to the cached function. See more info here: https://nextjs.org/docs/messages/next-request-in-use-cache",
"environmentLabel": "Server",
"label": "Console Error",
"source": "app/search-params-caught/page.tsx (11:5) @ Page
> 11 | param = (await searchParams).foo
| ^",
"stack": [
"Page app/search-params-caught/page.tsx (11:5)",
],
}
`)
const cliOutput = stripAnsi(next.cliOutput.slice(outputIndex))
expect(cliOutput).toContain(`Error: ${getExpectedErrorMessage(route)}
at Page (app/search-params-caught/page.tsx:11:5)`)
})
it('should also show an error after the second reload', async () => {
// There was an obscure bug that lead to the error not being triggered
// anymore starting with the third request. We test this scenario
// explicitly to ensure we won't regress.
const browser = await next.browser(`${route}?foo=1`)
await browser.refresh()
await browser.refresh()
await expect(browser).toDisplayCollapsedRedbox(`
{
"code": "E842",
"description": "Route /search-params-caught used \`searchParams\` inside "use cache". Accessing dynamic request data inside a cache scope is not supported. If you need some search params inside a cached function await \`searchParams\` outside of the cached function and pass only the required search params as arguments to the cached function. See more info here: https://nextjs.org/docs/messages/next-request-in-use-cache",
"environmentLabel": "Server",
"label": "Console Error",
"source": "app/search-params-caught/page.tsx (11:5) @ Page
> 11 | param = (await searchParams).foo
| ^",
"stack": [
"Page app/search-params-caught/page.tsx (11:5)",
],
}
`)
})
})
describe('when searchParams are unused inside of "use cache"', () => {
beforeAll(() => {
route = '/search-params-unused'
})
it('should not show an error', async () => {
const outputIndex = next.cliOutput.length
const browser = await next.browser(`${route}?foo=1`)
await waitForNoRedbox(browser)
const cliOutput = stripAnsi(next.cliOutput.slice(outputIndex))
expect(cliOutput).not.toContain(getExpectedErrorMessage(route))
})
})
it('should show an error when searchParams are used inside of a cached generateMetadata', async () => {
const browser = await next.browser(
'/search-params-used-generate-metadata?title=foo'
)
await expect(browser).toDisplayRedbox(`
{
"code": "E842",
"description": "Route /search-params-used-generate-metadata used \`searchParams\` inside "use cache". Accessing dynamic request data inside a cache scope is not supported. If you need some search params inside a cached function await \`searchParams\` outside of the cached function and pass only the required search params as arguments to the cached function. See more info here: https://nextjs.org/docs/messages/next-request-in-use-cache",
"environmentLabel": "Cache",
"label": "Runtime Error",
"source": "app/search-params-used-generate-metadata/page.tsx (9:17) @ generateMetadata
> 9 | const title = (await searchParams).title
| ^",
"stack": [
"generateMetadata app/search-params-used-generate-metadata/page.tsx (9:17)",
],
}
`)
})
it('should show an error when searchParams are used inside of a cached generateViewport', async () => {
const browser = await next.browser(
'/search-params-used-generate-viewport?color=red'
)
await expect(browser).toDisplayRedbox(`
{
"code": "E842",
"description": "Route /search-params-used-generate-viewport used \`searchParams\` inside "use cache". Accessing dynamic request data inside a cache scope is not supported. If you need some search params inside a cached function await \`searchParams\` outside of the cached function and pass only the required search params as arguments to the cached function. See more info here: https://nextjs.org/docs/messages/next-request-in-use-cache",
"environmentLabel": "Cache",
"label": "Runtime Error",
"source": "app/search-params-used-generate-viewport/page.tsx (9:17) @ generateViewport
> 9 | const color = (await searchParams).color
| ^",
"stack": [
"generateViewport app/search-params-used-generate-viewport/page.tsx (9:17)",
],
}
`)
})
} else {
afterEach(async () => {
await next.stop()
})
it('should fail the build with errors', async () => {
const { cliOutput } = await next.build()
expect(cliOutput).toInclude(
getExpectedErrorMessage('/search-params-used')
)
expect(cliOutput).toInclude(
getExpectedErrorMessage('/search-params-caught')
)
expect(cliOutput).not.toInclude(
getExpectedErrorMessage('/search-params-unused')
)
expect(cliOutput).toInclude(
'Error occurred prerendering page "/search-params-used"'
)
expect(cliOutput).toInclude(
'Error occurred prerendering page "/search-params-caught"'
)
expect(cliOutput).not.toInclude(
'Error occurred prerendering page "/search-params-unused"'
)
})
it('should resume a cached page that does not access search params without hydration errors', async () => {
await next.build({
args: ['--debug-build-paths', 'app/search-params-unused/page.tsx'],
})
await next.start({ skipBuild: true })
let browser = await next.browser('/search-params-unused', {
disableJavaScript: true,
})
const prerenderedPageDate = await browser.elementById('page-date').text()
await browser.close()
browser = await next.browser('/search-params-unused', {
pushErrorAsConsoleLog: true,
})
// After hydration, the resumed page date should be the prerendered date.
// Note: When cacheComponents is not enabled, the page is not actually
// prerendered, but because the page is cached on the first page load, the
// date should still be the same for the second page load.
expect(await browser.elementById('page-date').text()).toBe(
prerenderedPageDate
)
// There should also be no hydration errors due to a buildtime date being
// replaced by a new runtime date.
await assertNoConsoleErrors(browser)
})
}
})