Skip to content

Commit

Permalink
WIP: "use cache" page should not cause dynamic
Browse files Browse the repository at this point in the history
  • Loading branch information
unstubbable committed Feb 4, 2025
1 parent 47bece4 commit f1ea005
Show file tree
Hide file tree
Showing 3 changed files with 248 additions and 1 deletion.
3 changes: 2 additions & 1 deletion packages/next/errors.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
213 changes: 213 additions & 0 deletions packages/next/src/server/request/search-params.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -167,6 +168,11 @@ function createRenderSearchParams(
interface CacheLifetime {}
const CachedSearchParams = new WeakMap<CacheLifetime, Promise<SearchParams>>()

const CachedSearchParamsForUseCache = new WeakMap<
CacheLifetime,
Promise<SearchParams>
>()

function makeAbortingExoticSearchParams(
route: string,
prerenderStore: PrerenderStoreModern
Expand Down Expand Up @@ -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<SearchParams> {
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
Expand Down
33 changes: 33 additions & 0 deletions packages/next/src/server/use-cache/use-cache-wrapper.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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(
Expand Down

0 comments on commit f1ea005

Please sign in to comment.