From f1ea005e20663b8787817fd8911f2bdbb0daa752 Mon Sep 17 00:00:00 2001 From: Hendrik Liebau Date: Tue, 4 Feb 2025 17:49:30 +0100 Subject: [PATCH] WIP: `"use cache"` page should not cause dynamic --- packages/next/errors.json | 3 +- .../next/src/server/request/search-params.ts | 213 ++++++++++++++++++ .../src/server/use-cache/use-cache-wrapper.ts | 33 +++ 3 files changed, 248 insertions(+), 1 deletion(-) diff --git a/packages/next/errors.json b/packages/next/errors.json index 9236d0b3b6707..5eeaa7b2e7977 100644 --- a/packages/next/errors.json +++ b/packages/next/errors.json @@ -630,5 +630,6 @@ "629": "Invariant (SlowModuleDetectionPlugin): Unable to find the start time for a module build. This is a Next.js internal bug.", "630": "Invariant (SlowModuleDetectionPlugin): Module is recorded after the report is generated. This is a Next.js internal bug.", "631": "Invariant (SlowModuleDetectionPlugin): Circular dependency detected in module graph. This is a Next.js internal bug.", - "632": "Invariant (SlowModuleDetectionPlugin): Module is missing a required debugId. This is a Next.js internal bug." + "632": "Invariant (SlowModuleDetectionPlugin): Module is missing a required debugId. This is a Next.js internal bug.", + "633": "Route %s used \"searchParams\" inside \"use cache\". Accessing Dynamic data sources inside a cache scope is not supported. If you need this data inside a cached function use \"searchParams\" outside of the cached function and pass the required dynamic data in as an argument. See more info here: https://nextjs.org/docs/messages/next-request-in-use-cache" } diff --git a/packages/next/src/server/request/search-params.ts b/packages/next/src/server/request/search-params.ts index af4eb518ea463..4906293668eb3 100644 --- a/packages/next/src/server/request/search-params.ts +++ b/packages/next/src/server/request/search-params.ts @@ -16,6 +16,7 @@ import { type PrerenderStoreLegacy, type PrerenderStorePPR, type PrerenderStoreModern, + type RequestStore, } from '../app-render/work-unit-async-storage.external' import { InvariantError } from '../../shared/lib/invariant-error' import { makeHangingPromise } from '../dynamic-rendering-utils' @@ -167,6 +168,11 @@ function createRenderSearchParams( interface CacheLifetime {} const CachedSearchParams = new WeakMap>() +const CachedSearchParamsForUseCache = new WeakMap< + CacheLifetime, + Promise +>() + function makeAbortingExoticSearchParams( route: string, prerenderStore: PrerenderStoreModern @@ -469,6 +475,213 @@ function makeErroringExoticSearchParams( return proxiedPromise } +// TODO: Clean this up. For now, this is a rather dumb copypasta of +// makeErroringExoticSearchParams, also allowing a request store, so that we can +// throw in this case as well. +export function makeErroringExoticSearchParamsForUseCache( + workStore: WorkStore, + workUnitStore: PrerenderStoreLegacy | PrerenderStorePPR | RequestStore +): Promise { + const cachedSearchParams = CachedSearchParamsForUseCache.get(workStore) + if (cachedSearchParams) { + return cachedSearchParams + } + + const underlyingSearchParams = {} + // For search params we don't construct a ReactPromise because we want to interrupt + // rendering on any property access that was not set from outside and so we only want + // to have properties like value and status if React sets them. + const promise = Promise.resolve(underlyingSearchParams) + + const proxiedPromise = new Proxy(promise, { + get(target, prop, receiver) { + if (Object.hasOwn(promise, prop)) { + // The promise has this property directly. we must return it. + // We know it isn't a dynamic access because it can only be something + // that was previously written to the promise and thus not an underlying searchParam value + return ReflectAdapter.get(target, prop, receiver) + } + + switch (prop) { + // Object prototype + case 'hasOwnProperty': + case 'isPrototypeOf': + case 'propertyIsEnumerable': + case 'toString': + case 'valueOf': + case 'toLocaleString': + + // Promise prototype + // fallthrough + case 'catch': + case 'finally': + + // Common tested properties + // fallthrough + case 'toJSON': + case '$$typeof': + case '__esModule': { + // These properties cannot be shadowed because they need to be the + // true underlying value for Promises to work correctly at runtime + return ReflectAdapter.get(target, prop, receiver) + } + case 'then': { + const expression = + '`await searchParams`, `searchParams.then`, or similar' + if (workStore.dynamicShouldError) { + throwWithStaticGenerationBailoutErrorWithDynamicError( + workStore.route, + expression + ) + } else if (workUnitStore.type === 'prerender-ppr') { + // PPR Prerender (no dynamicIO) + postponeWithTracking( + workStore.route, + expression, + workUnitStore.dynamicTracking + ) + } else if (workUnitStore.type === 'prerender-legacy') { + // Legacy Prerender + throwToInterruptStaticGeneration( + expression, + workStore, + workUnitStore + ) + } else { + throw new Error( + `Route ${workStore.route} used "searchParams" inside "use cache". Accessing Dynamic data sources inside a cache scope is not supported. If you need this data inside a cached function use "searchParams" outside of the cached function and pass the required dynamic data in as an argument. See more info here: https://nextjs.org/docs/messages/next-request-in-use-cache` + ) + } + return + } + case 'status': { + const expression = + '`use(searchParams)`, `searchParams.status`, or similar' + if (workStore.dynamicShouldError) { + throwWithStaticGenerationBailoutErrorWithDynamicError( + workStore.route, + expression + ) + } else if (workUnitStore.type === 'prerender-ppr') { + // PPR Prerender (no dynamicIO) + postponeWithTracking( + workStore.route, + expression, + workUnitStore.dynamicTracking + ) + } else if (workUnitStore.type === 'prerender-legacy') { + // Legacy Prerender + throwToInterruptStaticGeneration( + expression, + workStore, + workUnitStore + ) + } else { + throw new Error( + `Route ${workStore.route} used "searchParams" inside "use cache". Accessing Dynamic data sources inside a cache scope is not supported. If you need this data inside a cached function use "searchParams" outside of the cached function and pass the required dynamic data in as an argument. See more info here: https://nextjs.org/docs/messages/next-request-in-use-cache` + ) + } + return + } + default: { + if (typeof prop === 'string') { + const expression = describeStringPropertyAccess( + 'searchParams', + prop + ) + if (workStore.dynamicShouldError) { + throwWithStaticGenerationBailoutErrorWithDynamicError( + workStore.route, + expression + ) + } else if (workUnitStore.type === 'prerender-ppr') { + // PPR Prerender (no dynamicIO) + postponeWithTracking( + workStore.route, + expression, + workUnitStore.dynamicTracking + ) + } else if (workUnitStore.type === 'prerender-legacy') { + // Legacy Prerender + throwToInterruptStaticGeneration( + expression, + workStore, + workUnitStore + ) + } else { + throw new Error( + `Route ${workStore.route} used "searchParams" inside "use cache". Accessing Dynamic data sources inside a cache scope is not supported. If you need this data inside a cached function use "searchParams" outside of the cached function and pass the required dynamic data in as an argument. See more info here: https://nextjs.org/docs/messages/next-request-in-use-cache` + ) + } + } + return ReflectAdapter.get(target, prop, receiver) + } + } + }, + has(target, prop) { + // We don't expect key checking to be used except for testing the existence of + // searchParams so we make all has tests trigger dynamic. this means that `promise.then` + // can resolve to the then function on the Promise prototype but 'then' in promise will assume + // you are testing whether the searchParams has a 'then' property. + if (typeof prop === 'string') { + const expression = describeHasCheckingStringProperty( + 'searchParams', + prop + ) + if (workStore.dynamicShouldError) { + throwWithStaticGenerationBailoutErrorWithDynamicError( + workStore.route, + expression + ) + } else if (workUnitStore.type === 'prerender-ppr') { + // PPR Prerender (no dynamicIO) + postponeWithTracking( + workStore.route, + expression, + workUnitStore.dynamicTracking + ) + } else if (workUnitStore.type === 'prerender-legacy') { + // Legacy Prerender + throwToInterruptStaticGeneration(expression, workStore, workUnitStore) + } else { + throw new Error( + `Route ${workStore.route} used "searchParams" inside "use cache". Accessing Dynamic data sources inside a cache scope is not supported. If you need this data inside a cached function use "searchParams" outside of the cached function and pass the required dynamic data in as an argument. See more info here: https://nextjs.org/docs/messages/next-request-in-use-cache` + ) + } + return false + } + return ReflectAdapter.has(target, prop) + }, + ownKeys() { + const expression = + '`{...searchParams}`, `Object.keys(searchParams)`, or similar' + if (workStore.dynamicShouldError) { + throwWithStaticGenerationBailoutErrorWithDynamicError( + workStore.route, + expression + ) + } else if (workUnitStore.type === 'prerender-ppr') { + // PPR Prerender (no dynamicIO) + postponeWithTracking( + workStore.route, + expression, + workUnitStore.dynamicTracking + ) + } else if (workUnitStore.type === 'prerender-legacy') { + // Legacy Prerender + throwToInterruptStaticGeneration(expression, workStore, workUnitStore) + } else { + throw new Error( + `Route ${workStore.route} used "searchParams" inside "use cache". Accessing Dynamic data sources inside a cache scope is not supported. If you need this data inside a cached function use "searchParams" outside of the cached function and pass the required dynamic data in as an argument. See more info here: https://nextjs.org/docs/messages/next-request-in-use-cache` + ) + } + }, + }) + + CachedSearchParamsForUseCache.set(workStore, proxiedPromise) + return proxiedPromise +} + function makeUntrackedExoticSearchParams( underlyingSearchParams: SearchParams, store: WorkStore diff --git a/packages/next/src/server/use-cache/use-cache-wrapper.ts b/packages/next/src/server/use-cache/use-cache-wrapper.ts index 0d42bdb626cb7..44306bd50922c 100644 --- a/packages/next/src/server/use-cache/use-cache-wrapper.ts +++ b/packages/next/src/server/use-cache/use-cache-wrapper.ts @@ -43,6 +43,7 @@ import { getDigestForWellKnownError } from '../app-render/create-error-handler' import { cacheHandlerGlobal, DYNAMIC_EXPIRE } from './constants' import { UseCacheTimeoutError } from './use-cache-errors' import { createHangingInputAbortSignal } from '../app-render/dynamic-rendering' +import { makeErroringExoticSearchParamsForUseCache } from '../request/search-params' type CacheKeyParts = [ buildId: string, @@ -543,6 +544,38 @@ export function cache( ? createHangingInputAbortSignal(workUnitStore) : undefined + // TODO: Improve heuristic, with the help of the compiler. Try to limit to + // default exports in page files, when dynamicIO is not enabled. + if ( + args.length === 2 && + args[1] === undefined && // undefined ref of a server component + typeof args[0] === 'object' && // props of a server component + typeof args[0].params === 'object' && + typeof args[0].searchParams === 'object' && + (workUnitStore?.type === 'prerender-ppr' || + workUnitStore?.type === 'prerender-legacy' || + workUnitStore?.type === 'request') + ) { + // Overwrite to empty searchParams so that we can serialize the arg for + // the cache key, in case the searchParams are not used... + args[0].searchParams = Promise.resolve({}) + + const originalFn = fn + + fn = { + [name]: async function ({ params }: any) { + // ...but throw an error if the searchParams are indeed used + // inside of the original function. + const searchParams = makeErroringExoticSearchParamsForUseCache( + workStore, + workUnitStore + ) + + return originalFn.apply(null, [{ params, searchParams }]) + }, + }[name] + } + if (boundArgsLength > 0) { if (args.length === 0) { throw new InvariantError(