Skip to content

Commit b2c6d03

Browse files
authored
fix(use-cache): match Next.js erroring searchParams in "use cache" pages (#4028)
* fix(use-cache): fail a read of searchParams.then inside "use cache", like Next.js The erroring searchParams a public "use cache" page gets threw only when `then` was called. Next.js's makeErroringSearchParamsForUseCache throws when `then` is read, so a thenable check such as `typeof sp.then` fails too. Use the observeThenRead hook added in #4002. * fix(use-cache): keep page searchParams erroring after they escape "use cache" Next.js's makeErroringSearchParamsForUseCache throws on every access. vinext's replacement only threw inside the cache scope, so a value that escaped the cache function read as an empty query afterwards. * fix(use-cache): match Next.js for catch, finally and escaped access on page searchParams Next.js binds catch and finally to the underlying promise, so they never read `then` through the erroring proxy. Run observeThenRead only on a read of `then`, and let an empty key list through, so catch, finally and Object.keys settle as in Next.js. An access after the value escapes the cache function now records the invalid use on the request the value was made for, like Next.js's `workStore.invalidDynamicUsageError ??= error`. * fix(use-cache): port the erroring page searchParams proxy from Next.js Use a dedicated get-only proxy like makeErroringSearchParamsForUseCache instead of makeThenableParams, so `in`, Object.hasOwn and catch/finally behave as in Next.js, and revert the thenable-params change. Make it inside the cache scope and record an access on that scope and request, wherever the value is read later. * fix(use-cache): keep the first caught searchParams access on the cache scope Like Next.js's workStore.invalidDynamicUsageError ??= error. * fix(use-cache): fail a "use cache" that an escaped page searchParams is read in Record the access on the active cache scope too, so a cache that catches it can't store a fallback that a later request would reuse. * fix(use-cache): record escaped searchParams access on the request context Capture the unified request context, as throwIfInsideCacheScope records on, instead of the headers shim state. Outside a request that was the module fallback state, so an access leaked into later renders.
1 parent 31317da commit b2c6d03

3 files changed

Lines changed: 288 additions & 33 deletions

File tree

‎packages/vinext/src/shims/cache-runtime.ts‎

Lines changed: 76 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -48,17 +48,18 @@ import {
4848
runWithDetachedFetchObservations,
4949
} from "./fetch-cache.js";
5050
import { getOrCreateAls } from "./internal/als-registry.js";
51+
import { isWellKnownProperty } from "./internal/thenable-well-known-properties.js";
52+
import {
53+
createUseCacheAccessError,
54+
recordInvalidDynamicUsageError,
55+
} from "./internal/headers-state.js";
5156
import {
5257
isInsideUnifiedScope,
5358
getRequestContext,
5459
runWithUnifiedStateMutation,
5560
} from "./unified-request-context.js";
56-
import { isDraftModeEnabled, markDynamicUsage, throwIfInsideCacheScope } from "./headers.js";
57-
import {
58-
makeThenableParams,
59-
getFallbackParamsSnapshot,
60-
restoreThenableParams,
61-
} from "./thenable-params.js";
61+
import { isDraftModeEnabled, markDynamicUsage } from "./headers.js";
62+
import { getFallbackParamsSnapshot, restoreThenableParams } from "./thenable-params.js";
6263
import {
6364
createPprFallbackShellSuspensePromise,
6465
trackPprFallbackShellCacheTask,
@@ -782,19 +783,20 @@ export function registerCachedFunction<TArgs extends unknown[], TResult>(
782783
outerParams,
783784
),
784785
}) as TArgs;
785-
// The encoded args omit a public page's searchParams. Access must still
786-
// fail like Next's erroring searchParams, including on a replay.
787-
if (pagePropsIndex !== undefined) {
788-
callArgs = withErroringPageSearchParams(callArgs, pagePropsIndex) as TArgs;
789-
}
790786
}
787+
// The encoded args omit a public page's searchParams. Access must still
788+
// fail like Next's erroring searchParams, including on a replay.
789+
const callFn =
790+
segmentPropsIndex !== -1 && pagePropsIndex !== undefined
791+
? withErroringPageSearchParams(fn, pagePropsIndex)
792+
: fn;
791793

792794
if (fallbackParams) {
793795
// Speculative shell params are incomplete: a placeholder must not hit
794796
// a concrete route's entry or become an unguarded persisted replay.
795797
// Keep decoded values plus lazy framework suspension for execution;
796798
// concrete params still follow the normal cache path below.
797-
return (await executeWithContext(fn, callArgs, cacheVariant, rsc)).result;
799+
return (await executeWithContext(callFn, callArgs, cacheVariant, rsc)).result;
798800
}
799801

800802
// "use cache: private" uses per-request in-memory cache
@@ -816,7 +818,7 @@ export function registerCachedFunction<TArgs extends unknown[], TResult>(
816818
return privateHit.result;
817819
}
818820

819-
const execution = await executeWithContext(fn, callArgs, cacheVariant, rsc);
821+
const execution = await executeWithContext(callFn, callArgs, cacheVariant, rsc);
820822
if (execution.cacheable) privateCache.set(cacheKey, execution);
821823
return execution.result;
822824
}
@@ -826,7 +828,7 @@ export function registerCachedFunction<TArgs extends unknown[], TResult>(
826828
// preview request would otherwise seed unpublished content into an entry
827829
// later served to public requests. Mirrors Next.js's `isDraftMode` guard.
828830
if (isDev || isDraftModeEnabled()) {
829-
return (await executeWithContext(fn, callArgs, cacheVariant, rsc)).result;
831+
return (await executeWithContext(callFn, callArgs, cacheVariant, rsc)).result;
830832
}
831833

832834
// Shared cache ("use cache" / "use cache: remote")
@@ -879,7 +881,7 @@ export function registerCachedFunction<TArgs extends unknown[], TResult>(
879881
// propagate: the request already used the stale value.
880882
const generate = async (propagate: boolean): Promise<TResult> => {
881883
const { result, ctx, effectiveLife, collectedResult } = await runCachedFunctionWithContext(
882-
fn,
884+
callFn,
883885
callArgs,
884886
cacheVariant,
885887
(value) => serializeCacheResult(value, rsc),
@@ -1464,19 +1466,65 @@ function toReplayablePageArgs(args: readonly unknown[], index: number): unknown[
14641466
}
14651467

14661468
/**
1467-
* Give page props at `index` without `searchParams` (a Response Store replay of
1468-
* args encoded by `toReplayablePageArgs`) a value that throws on
1469-
* access inside the cache scope, like Next.js's
1470-
* `makeErroringSearchParamsForUseCache`, instead of `undefined`.
1469+
* Run `fn` with an erroring `searchParams` in the page props at `index` when
1470+
* they have none (they are omitted from a public page cache's arguments, and
1471+
* from a Response Store replay of them), instead of `undefined`.
14711472
*/
1472-
function withErroringPageSearchParams(args: readonly unknown[], index: number): readonly unknown[] {
1473-
const props = args[index];
1474-
if (!isPagePropsObject(props) || "searchParams" in props) return args;
1475-
return replaceArgument(args, index, {
1476-
...props,
1477-
searchParams: makeThenableParams(
1478-
{},
1479-
{ observeParamAccess: () => throwIfInsideCacheScope("searchParams") },
1480-
),
1473+
function withErroringPageSearchParams<TArgs extends unknown[], TResult>(
1474+
fn: (...args: TArgs) => Promise<TResult>,
1475+
index: number,
1476+
): (...args: TArgs) => Promise<TResult> {
1477+
return (...args) => {
1478+
const props = args[index];
1479+
if (!isPagePropsObject(props) || "searchParams" in props) return fn(...args);
1480+
// Made inside the cache scope, so it can record an access on this scope.
1481+
return fn(
1482+
...(replaceArgument(args, index, {
1483+
...props,
1484+
searchParams: makeErroringPageSearchParams(),
1485+
}) as TArgs),
1486+
);
1487+
};
1488+
}
1489+
1490+
// Next.js's `wellKnownProperties` (shared/lib/utils/reflect-utils.ts). Unlike
1491+
// `isWellKnownProperty`, `value` and `error` aren't reserved, so reading them
1492+
// throws unless React wrote them onto the promise.
1493+
function isNextWellKnownProperty(prop: string): boolean {
1494+
return prop !== "value" && prop !== "error" && isWellKnownProperty(prop);
1495+
}
1496+
1497+
/**
1498+
* Ported from Next.js's `makeErroringSearchParamsForUseCache`
1499+
* (server/request/search-params.ts): reading `then` or a param throws, and
1500+
* everything else reads the underlying promise, with methods bound to it.
1501+
*/
1502+
function makeErroringPageSearchParams(): Promise<Record<string, never>> {
1503+
// Like Next.js, which closes over the workStore, an access records the
1504+
// invalid use on the cache scope and request the value was made for, even
1505+
// after it escapes the cache function.
1506+
const cacheContext = cacheContextStorage.getStore() ?? null;
1507+
const requestContext = isInsideUnifiedScope() ? getRequestContext() : null;
1508+
const promise = Promise.resolve({});
1509+
return new Proxy(promise, {
1510+
get(target, prop, receiver) {
1511+
if (
1512+
!Object.hasOwn(target, prop) &&
1513+
typeof prop === "string" &&
1514+
(prop === "then" || !isNextWellKnownProperty(prop))
1515+
) {
1516+
const error = createUseCacheAccessError("searchParams");
1517+
if (cacheContext) cacheContext.invalidDynamicUsageError ??= error;
1518+
// A cache the value escaped into must not store a result either.
1519+
const activeCacheContext = cacheContextStorage.getStore();
1520+
if (activeCacheContext && activeCacheContext !== cacheContext) {
1521+
activeCacheContext.invalidDynamicUsageError ??= error;
1522+
}
1523+
if (requestContext) recordInvalidDynamicUsageError(requestContext, error);
1524+
throw error;
1525+
}
1526+
const value: unknown = Reflect.get(target, prop, receiver);
1527+
return typeof value === "function" ? value.bind(target) : value;
1528+
},
14811529
});
14821530
}

‎packages/vinext/src/shims/internal/headers-state.ts‎

Lines changed: 22 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -178,6 +178,18 @@ function propagateInvalidDynamicUsageError(state: VinextHeadersShimState, error:
178178
});
179179
}
180180

181+
/**
182+
* Record an invalid dynamic API use on `state`, keeping the first one, like
183+
* Next.js's `workStore.invalidDynamicUsageError ??= error`.
184+
*/
185+
export function recordInvalidDynamicUsageError(
186+
state: VinextHeadersShimState,
187+
error: unknown,
188+
): void {
189+
state.invalidDynamicUsageError ??= error;
190+
propagateInvalidDynamicUsageError(state, error);
191+
}
192+
181193
export function markRenderRequestApiUsage(kind: RenderRequestApiKind): void {
182194
getHeadersShimState().renderRequestApiUsage.add(kind);
183195
}
@@ -240,6 +252,15 @@ export function isInsideAnyCacheScope(): boolean {
240252
return _getUseCacheGuardContext() !== null || _isInsideUnstableCache();
241253
}
242254

255+
/** The error a dynamic request API throws inside a public "use cache". */
256+
export function createUseCacheAccessError(apiName: string): Error {
257+
return new Error(
258+
`\`${apiName}\` cannot be called inside "use cache". ` +
259+
`If you need this data inside a cached function, call \`${apiName}\` ` +
260+
"outside and pass the required data as an argument.",
261+
);
262+
}
263+
243264
/**
244265
* Throw if the current execution is inside a "use cache" or unstable_cache()
245266
* scope. Called by dynamic request APIs (headers, cookies, connection) to
@@ -249,11 +270,7 @@ export function isInsideAnyCacheScope(): boolean {
249270
*/
250271
export function throwIfInsideCacheScope(apiName: string): void {
251272
if (_isInsidePublicUseCache()) {
252-
const error = new Error(
253-
`\`${apiName}\` cannot be called inside "use cache". ` +
254-
`If you need this data inside a cached function, call \`${apiName}\` ` +
255-
"outside and pass the required data as an argument.",
256-
);
273+
const error = createUseCacheAccessError(apiName);
257274
// Record the error on the request context so it survives user try/catch
258275
// and can be forwarded to the dev overlay on client-side navigations.
259276
// Ported from Next.js: workStore.invalidDynamicUsageError assignment in

0 commit comments

Comments
 (0)