vercel/next.js · #98255

fix: track dynamic accesses in final runtime prerenders

lubieowoce · merged Sep 4, 20266 files · 49 + / 23
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 redundant
test/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 () => {