vercel/next.js · #98255
fix: track dynamic accesses in final runtime prerenders
packages/next/src/server/request/params.ts10 + / 1 −
@@ -447,7 +447,16 @@ function createRuntimePrerenderParams( } } - return stagedRendering.delayUntilStage(paramsStage, 'params', userspaceParams)+ // If params don't resolve in this prerender, caches need to treat them as a hanging input.+ if (stagedRendering.finalStage && stagedRendering.finalStage < paramsStage) {+ return makeHangingParams(underlyingParams, workStore, workUnitStore)+ } else {+ return stagedRendering.delayUntilStage(+ paramsStage,+ 'params',+ userspaceParams+ )+ } } function createRenderParamsForPage(packages/next/src/server/request/search-params.ts14 + / 13 −
@@ -244,8 +244,6 @@ function createRuntimePrerenderSearchParams( ? createVaryingSearchParams(varyParamsAccumulator, underlyingSearchParams) : underlyingSearchParams - const result = makeUntrackedSearchParams(userspaceSearchParams)- const searchParamsStage = RENDER_STAGES_BY_DATA_KIND.runtimeLinkData const { stagedRendering } = workUnitStore@@ -255,20 +253,23 @@ function createRuntimePrerenderSearchParams( if (workUnitStore.finalStage < searchParamsStage) { return makeHangingSearchParams(workStore, workUnitStore) } else {- return result+ return makeUntrackedSearchParams(userspaceSearchParams) } } - // Unlike `createRuntimePrerenderParams`, which uses `delayUntilStage`, we- // resolve with `waitForStage(...).then(...)` here. Switching search params to- // `delayUntilStage` drops the source code frame from the instant-validation- // "URL data outside of Suspense" error when a page awaits `searchParams` at- // the top level (params, read via a nested component, is unaffected). See the- // `missing suspense around search params` cases in the instant-validation- // `suspense-boundaries` tests. The underlying reason in React's async I/O- // await tracking isn't understood yet. TODO: align search params with params- // on `delayUntilStage` once resolved.- return stagedRendering.waitForStage(searchParamsStage).then(() => result)+ // If search params don't resolve in this prerender, caches need to treat them as a hanging input.+ if (+ stagedRendering.finalStage &&+ stagedRendering.finalStage < searchParamsStage+ ) {+ return makeHangingSearchParams(workStore, workUnitStore)+ } else {+ return stagedRendering.delayUntilStage(+ searchParamsStage,+ 'searchParams',+ userspaceSearchParams+ )+ } } function createRenderSearchParams(packages/next/src/server/use-cache/use-cache-errors.ts9 + / 0 −
@@ -30,3 +30,12 @@ export class NestedDynamicUseCacheError extends Error { this.name = 'Nested dynamic "use cache"' } }++/** Exported separately because tests assert on it */+export const UNEXPECTED_CACHE_MISS_MESSAGE = `Unexpected cache miss after cache warming phase during prerendering. This is likely caused by non-deterministic arguments that differ between the cache warming phase and the final prerender phase (e.g. unstable array order). Ensure that arguments passed to cached functions are deterministic.`++export class UnexpectedCacheMissError extends Error {+ constructor(route: string) {+ super(`Route "${route}": ` + UNEXPECTED_CACHE_MISS_MESSAGE)+ }+}packages/next/src/server/use-cache/use-cache-wrapper.ts2 + / 5 −
@@ -91,6 +91,7 @@ import { } from '../web/spec-extension/adapters/headers' import { NestedDynamicUseCacheError,+ UnexpectedCacheMissError, UseCacheDeadlockError, UseCacheTimeoutError, } from './use-cache-errors'@@ -2727,11 +2728,7 @@ export async function cache( // already handled by the early return above. We return a hanging // promise so this becomes a dynamic hole rather than generating a // broken cache entry that gets aborted.- console.warn(- new Error(- `Unexpected cache miss after cache warming phase during prerendering. This is likely caused by non-deterministic arguments that differ between the cache warming phase and the final prerender phase (e.g. unstable array order). Ensure that arguments passed to cached functions are deterministic.`- )- )+ console.warn(new UnexpectedCacheMissError(workStore.route)) // This is an anomaly (non-deterministic cache key), so we can't // know whether a runtime prerender would resolve it. Treat it as // runtime data, conservatively: the cost is at most a redundanttest/e2e/app-dir/segment-cache/prefetch-runtime/prefetch-runtime.test.ts10 + / 0 −
@@ -2,6 +2,7 @@ import { nextTestSetup } from 'e2e-utils' import { waitFor } from 'next-test-utils' import type * as Playwright from 'playwright' import { createRouterAct } from 'router-act'+import { UNEXPECTED_CACHE_MISS_MESSAGE } from 'next/src/server/use-cache/use-cache-errors' describe('runtime prefetching', () => { const { next, isNextDev, isNextDeploy } = nextTestSetup({@@ -29,6 +30,15 @@ describe('runtime prefetching', () => { currentCliOutputIndex = next.cliOutput.length } + // We never expect to see this logged here.+ afterEach(() => {+ if (getCliOutput().includes(UNEXPECTED_CACHE_MISS_MESSAGE)) {+ throw new Error(+ `A test unexpectedly logged "${UNEXPECTED_CACHE_MISS_MESSAGE}"`+ )+ }+ })+ describe.each([ { description: 'in a page',test/e2e/app-dir/segment-cache/runtime-prerender-cache-warming/runtime-prerender-cache-warming.test.ts4 + / 4 −
@@ -2,7 +2,7 @@ import { nextTestSetup } from 'e2e-utils' import type * as Playwright from 'playwright' import { createRouterAct } from 'router-act' -const CACHE_MISS_WARNING = 'Unexpected cache miss after cache warming phase'+import { UNEXPECTED_CACHE_MISS_MESSAGE } from 'next/src/server/use-cache/use-cache-errors' describe('runtime prerender cache warming', () => { const { next, isNextDev, skipped } = nextTestSetup({@@ -45,7 +45,7 @@ describe('runtime prerender cache warming', () => { // leaving them hanging, the cached page's key would differ between the two // prerenders and the final prerender would log an "Unexpected cache miss" // warning and degrade the cached segment to a dynamic hole.- expect(next.cliOutput).not.toContain(CACHE_MISS_WARNING)+ expect(next.cliOutput).not.toContain(UNEXPECTED_CACHE_MISS_MESSAGE) // When we navigate, params become available. await act(() => browser.elementByCss('a[href="/slug/prerendered"]').click())@@ -76,7 +76,7 @@ describe('runtime prerender cache warming', () => { }, ]) - expect(next.cliOutput).not.toContain(CACHE_MISS_WARNING)+ expect(next.cliOutput).not.toContain(UNEXPECTED_CACHE_MISS_MESSAGE) // Navigate, but capture the result of the cache as shown in the shell. const cachedDataInShell = await act(async () => {@@ -122,7 +122,7 @@ describe('runtime prerender cache warming', () => { }, ]) - expect(next.cliOutput).not.toContain(CACHE_MISS_WARNING)+ expect(next.cliOutput).not.toContain(UNEXPECTED_CACHE_MISS_MESSAGE) // Navigate, but capture the result of the cache as shown in the shell. const cachedDataInShell = await act(async () => {