@samitouri / QOS-React-1 / commits / e1dc03492e

Expose cacheSignal() alongside cache() (#33557)

This was really meant to be there from the beginning. A `cache()`:ed entry has a life time. On the server this ends when the render finishes. On the client this ends when the cache of that scope gets refreshed. When a cache is no longer needed, it should be possible to abort any outstanding network requests or other resources. That's what `cacheSignal()` gives you. It returns an `AbortSignal` which aborts when the cache lifetime is done based on the same execution scope as a `cache()`ed function - i.e. `AsyncLocalStorage` on the server or the render scope on the client. ```js import {cacheSignal} from 'react'; async function Component() { await fetch(url, { signal: cacheSignal() }); } ``` For `fetch` in particular, a patch should really just do this automatically for you. But it's useful for other resources like database connections. Another reason it's useful to have a `cacheSignal()` is to ignore any errors that might have triggered from the act of being aborted. This is just a general useful JavaScript pattern if you have access to a signal: ```js async function getData(id, signal) { try { await queryDatabase(id, { signal }); } catch (x) { if (!signal.aborted) { logError(x); // only log if it's a real error and not due to cancellation } return null; } } ``` This just gets you a convenient way to get to it without drilling through so a more idiomatic code in React might look something like. ```js import {cacheSignal} from "react"; async function getData(id) { try { await queryDatabase(id); } catch (x) { if (!cacheSignal()?.aborted) { logError(x); } return null; } } ``` If it's called outside of a React render, we normally treat any cached functions as uncached. They're not an error call. They can still load data. It's just not cached. This is not like an aborted signal because then you couldn't issue any requests. It's also not like an infinite abort signal because it's not actually cached forever. Therefore, `cacheSignal()` returns `null` when called outside of a React render scope. Notably the `signal` option passed to `renderToReadableStream` in both SSR (Fizz) and RSC (Flight Server) is not the same instance that comes out of `cacheSignal()`. If you abort the `signal` passed in, then the `cacheSignal()` is also aborted with the same reason. However, the `cacheSignal()` can also get aborted if the render completes successfully or fatally errors during render - allowing any outstanding work that wasn't used to clean up. In the future we might also expand on this to give different [`TaskSignal`](https://developer.mozilla.org/en-US/docs/Web/API/TaskSignal) to different scopes to pass different render or network priorities. On the client version of `"react"` this exposes a noop (both for Fiber/Fizz) due to `disableClientCache` flag but it's exposed so that you can write shared code.

Sebastian Markbåge committed Jun 17, 2025 at 17:04 UTC e1dc03492eedaec517e14a6e32b8fda571d00767
24 files changed +183 -11
packages/react-noop-renderer/src/ReactNoopFlightServer.js
+13
@@ -70,6 +70,7 @@ type Options = {
70 environmentName?: string | (() => string),
71 filterStackFrame?: (url: string, functionName: string) => boolean,
72 identifierPrefix?: string,
73 + signal?: AbortSignal,
74 onError?: (error: mixed) => void,
75 onPostpone?: (reason: string) => void,
76 };
@@ -87,6 +88,18 @@ function render(model: ReactClientValue, options?: Options): Destination {
88 __DEV__ && options ? options.environmentName : undefined,
89 __DEV__ && options ? options.filterStackFrame : undefined,
90 );
91 + const signal = options ? options.signal : undefined;
92 + if (signal) {
93 + if (signal.aborted) {
94 + ReactNoopFlightServer.abort(request, (signal: any).reason);
95 + } else {
96 + const listener = () => {
97 + ReactNoopFlightServer.abort(request, (signal: any).reason);
98 + signal.removeEventListener('abort', listener);
99 + };
100 + signal.addEventListener('abort', listener);
101 + }
102 + }
103 ReactNoopFlightServer.startWork(request);
104 ReactNoopFlightServer.startFlowing(request, destination);
105 return destination;
packages/react-reconciler/src/ReactFiberAsyncDispatcher.js
+6
@@ -25,8 +25,14 @@ function getCacheForType<T>(resourceType: () => T): T {
25 return cacheForType;
26 }
27
28 +function cacheSignal(): null | AbortSignal {
29 + const cache: Cache = readContext(CacheContext);
30 + return cache.controller.signal;
31 +}
32 +
33 export const DefaultAsyncDispatcher: AsyncDispatcher = ({
34 getCacheForType,
35 + cacheSignal,
36 }: any);
37
38 if (__DEV__) {
packages/react-reconciler/src/ReactInternalTypes.js
+1
@@ -459,6 +459,7 @@ export type Dispatcher = {
459
460 export type AsyncDispatcher = {
461 getCacheForType: <T>(resourceType: () => T) => T,
462 + cacheSignal: () => null | AbortSignal,
463 // DEV-only
464 getOwner: () => null | Fiber | ReactComponentInfo | ComponentStackNode,
465 };
packages/react-reconciler/src/__tests__/ReactCache-test.js
+84
@@ -14,6 +14,7 @@ let React;
14 let ReactNoopFlightServer;
15 let ReactNoopFlightClient;
16 let cache;
17 +let cacheSignal;
18
19 describe('ReactCache', () => {
20 beforeEach(() => {
@@ -25,6 +26,7 @@ describe('ReactCache', () => {
26 ReactNoopFlightClient = require('react-noop-renderer/flight-client');
27
28 cache = React.cache;
29 + cacheSignal = React.cacheSignal;
30
31 jest.resetModules();
32 __unmockReact();
@@ -220,4 +222,86 @@ describe('ReactCache', () => {
222 expect(cachedFoo.length).toBe(0);
223 expect(cachedFoo.displayName).toBe(undefined);
224 });
225 +
226 + it('cacheSignal() returns null outside a render', async () => {
227 + expect(cacheSignal()).toBe(null);
228 + });
229 +
230 + it('cacheSignal() aborts when the render finishes normally', async () => {
231 + let renderedCacheSignal = null;
232 +
233 + let resolve;
234 + const promise = new Promise(r => (resolve = r));
235 +
236 + async function Test() {
237 + renderedCacheSignal = cacheSignal();
238 + await promise;
239 + return 'Hi';
240 + }
241 +
242 + const controller = new AbortController();
243 + const errors = [];
244 + const result = ReactNoopFlightServer.render(<Test />, {
245 + signal: controller.signal,
246 + onError(x) {
247 + errors.push(x);
248 + },
249 + });
250 + expect(errors).toEqual([]);
251 + expect(renderedCacheSignal).not.toBe(controller.signal); // In the future we might make these the same
252 + expect(renderedCacheSignal.aborted).toBe(false);
253 + await resolve();
254 + await 0;
255 + await 0;
256 +
257 + expect(await ReactNoopFlightClient.read(result)).toBe('Hi');
258 +
259 + expect(errors).toEqual([]);
260 + expect(renderedCacheSignal.aborted).toBe(true);
261 + expect(renderedCacheSignal.reason.message).toContain(
262 + 'This render completed successfully.',
263 + );
264 + });
265 +
266 + it('cacheSignal() aborts when the render is aborted', async () => {
267 + let renderedCacheSignal = null;
268 +
269 + const promise = new Promise(() => {});
270 +
271 + async function Test() {
272 + renderedCacheSignal = cacheSignal();
273 + await promise;
274 + return 'Hi';
275 + }
276 +
277 + const controller = new AbortController();
278 + const errors = [];
279 + const result = ReactNoopFlightServer.render(<Test />, {
280 + signal: controller.signal,
281 + onError(x) {
282 + errors.push(x);
283 + return 'hi';
284 + },
285 + });
286 + expect(errors).toEqual([]);
287 + expect(renderedCacheSignal).not.toBe(controller.signal); // In the future we might make these the same
288 + expect(renderedCacheSignal.aborted).toBe(false);
289 + const reason = new Error('Timed out');
290 + controller.abort(reason);
291 + expect(errors).toEqual([reason]);
292 + expect(renderedCacheSignal.aborted).toBe(true);
293 + expect(renderedCacheSignal.reason).toBe(reason);
294 +
295 + let clientError = null;
296 + try {
297 + await ReactNoopFlightClient.read(result);
298 + } catch (x) {
299 + clientError = x;
300 + }
301 + expect(clientError).not.toBe(null);
302 + if (__DEV__) {
303 + expect(clientError.message).toBe('Timed out');
304 + }
305 + expect(clientError.digest).toBe('hi');
306 + });
307 });
packages/react-server/src/ReactFizzAsyncDispatcher.js
+5
@@ -16,8 +16,13 @@ function getCacheForType<T>(resourceType: () => T): T {
16 throw new Error('Not implemented.');
17 }
18
19 +function cacheSignal(): null | AbortSignal {
20 + throw new Error('Not implemented.');
21 +}
22 +
23 export const DefaultAsyncDispatcher: AsyncDispatcher = ({
24 getCacheForType,
25 + cacheSignal,
26 }: any);
27
28 if (__DEV__) {
packages/react-server/src/ReactFlightServer.js
+18 -2
@@ -419,6 +419,7 @@ export type Request = {
419 destination: null | Destination,
420 bundlerConfig: ClientManifest,
421 cache: Map<Function, mixed>,
422 + cacheController: AbortController,
423 nextChunkId: number,
424 pendingChunks: number,
425 hints: Hints,
@@ -529,6 +530,7 @@ function RequestInstance(
530 this.destination = null;
531 this.bundlerConfig = bundlerConfig;
532 this.cache = new Map();
533 + this.cacheController = new AbortController();
534 this.nextChunkId = 0;
535 this.pendingChunks = 0;
536 this.hints = hints;
@@ -604,7 +606,7 @@ export function createRequest(
606 model: ReactClientValue,
607 bundlerConfig: ClientManifest,
608 onError: void | ((error: mixed) => ?string),
607 - identifierPrefix?: string,
609 + identifierPrefix: void | string,
610 onPostpone: void | ((reason: string) => void),
611 temporaryReferences: void | TemporaryReferenceSet,
612 environmentName: void | string | (() => string), // DEV-only
@@ -636,7 +638,7 @@ export function createPrerenderRequest(
638 onAllReady: () => void,
639 onFatalError: () => void,
640 onError: void | ((error: mixed) => ?string),
639 - identifierPrefix?: string,
641 + identifierPrefix: void | string,
642 onPostpone: void | ((reason: string) => void),
643 temporaryReferences: void | TemporaryReferenceSet,
644 environmentName: void | string | (() => string), // DEV-only
@@ -3369,6 +3371,13 @@ function fatalError(request: Request, error: mixed): void {
3371 request.status = CLOSING;
3372 request.fatalError = error;
3373 }
3374 + const abortReason = new Error(
3375 + 'The render was aborted due to a fatal error.',
3376 + {
3377 + cause: error,
3378 + },
3379 + );
3380 + request.cacheController.abort(abortReason);
3381 }
3382
3383 function emitPostponeChunk(
@@ -4840,6 +4849,12 @@ function flushCompletedChunks(
4849 if (enableTaint) {
4850 cleanupTaintQueue(request);
4851 }
4852 + if (request.status < ABORTING) {
4853 + const abortReason = new Error(
4854 + 'This render completed successfully. All cacheSignals are now aborted to allow clean up of any unused resources.',
4855 + );
4856 + request.cacheController.abort(abortReason);
4857 + }
4858 request.status = CLOSED;
4859 close(destination);
4860 request.destination = null;
@@ -4921,6 +4936,7 @@ export function abort(request: Request, reason: mixed): void {
4936 // We define any status below OPEN as OPEN equivalent
4937 if (request.status <= OPEN) {
4938 request.status = ABORTING;
4939 + request.cacheController.abort(reason);
4940 }
4941 const abortableTasks = request.abortableTasks;
4942 if (abortableTasks.size > 0) {
packages/react-server/src/flight/ReactFlightAsyncDispatcher.js
+7
@@ -31,6 +31,13 @@ export const DefaultAsyncDispatcher: AsyncDispatcher = ({
31 }
32 return entry;
33 },
34 + cacheSignal(): null | AbortSignal {
35 + const request = resolveRequest();
36 + if (request) {
37 + return request.cacheController.signal;
38 + }
39 + return null;
40 + },
41 }: any);
42
43 if (__DEV__) {
packages/react-suspense-test-utils/src/ReactSuspenseTestUtils.js
+3
@@ -22,6 +22,9 @@ export function waitForSuspense<T>(fn: () => T): Promise<T> {
22 }
23 return entry;
24 },
25 + cacheSignal(): null {
26 + return null;
27 + },
28 getOwner(): null {
29 return null;
30 },
packages/react/index.development.js
+1
@@ -44,6 +44,7 @@ export {
44 lazy,
45 memo,
46 cache,
47 + cacheSignal,
48 startTransition,
49 unstable_LegacyHidden,
50 unstable_Activity,
packages/react/index.experimental.development.js
+1
@@ -27,6 +27,7 @@ export {
27 lazy,
28 memo,
29 cache,
30 + cacheSignal,
31 startTransition,
32 unstable_Activity,
33 unstable_postpone,
packages/react/index.experimental.js
+1
@@ -27,6 +27,7 @@ export {
27 lazy,
28 memo,
29 cache,
30 + cacheSignal,
31 startTransition,
32 unstable_Activity,
33 unstable_postpone,
packages/react/index.fb.js
+1
@@ -14,6 +14,7 @@ export {
14 __COMPILER_RUNTIME,
15 act,
16 cache,
17 + cacheSignal,
18 Children,
19 cloneElement,
20 Component,
packages/react/index.js
+1
@@ -44,6 +44,7 @@ export {
44 lazy,
45 memo,
46 cache,
47 + cacheSignal,
48 startTransition,
49 unstable_LegacyHidden,
50 unstable_Activity,
packages/react/index.stable.development.js
+1
@@ -27,6 +27,7 @@ export {
27 lazy,
28 memo,
29 cache,
30 + cacheSignal,
31 unstable_useCacheRefresh,
32 startTransition,
33 useId,
packages/react/index.stable.js
+1
@@ -27,6 +27,7 @@ export {
27 lazy,
28 memo,
29 cache,
30 + cacheSignal,
31 unstable_useCacheRefresh,
32 startTransition,
33 useId,
packages/react/src/ReactCacheClient.js
+13 -2
@@ -8,9 +8,12 @@
8 */
9
10 import {disableClientCache} from 'shared/ReactFeatureFlags';
11 -import {cache as cacheImpl} from './ReactCacheImpl';
11 +import {
12 + cache as cacheImpl,
13 + cacheSignal as cacheSignalImpl,
14 +} from './ReactCacheImpl';
15
13 -export function noopCache<A: Iterable<mixed>, T>(fn: (...A) => T): (...A) => T {
16 +function noopCache<A: Iterable<mixed>, T>(fn: (...A) => T): (...A) => T {
17 // On the client (i.e. not a Server Components environment) `cache` has
18 // no caching behavior. We just return the function as-is.
19 //
@@ -32,3 +35,11 @@ export function noopCache<A: Iterable<mixed>, T>(fn: (...A) => T): (...A) => T {
35 export const cache: typeof noopCache = disableClientCache
36 ? noopCache
37 : cacheImpl;
38 +
39 +function noopCacheSignal(): null | AbortSignal {
40 + return null;
41 +}
42 +
43 +export const cacheSignal: () => null | AbortSignal = disableClientCache
44 + ? noopCacheSignal
45 + : cacheSignalImpl;
packages/react/src/ReactCacheImpl.js
+12
@@ -126,3 +126,15 @@ export function cache<A: Iterable<mixed>, T>(fn: (...A) => T): (...A) => T {
126 }
127 };
128 }
129 +
130 +export function cacheSignal(): null | AbortSignal {
131 + const dispatcher = ReactSharedInternals.A;
132 + if (!dispatcher) {
133 + // If there is no dispatcher, then we treat this as not having an AbortSignal
134 + // since in the same context, a cached function will be allowed to be called
135 + // but it won't be cached. So it's neither an infinite AbortSignal nor an
136 + // already resolved one.
137 + return null;
138 + }
139 + return dispatcher.cacheSignal();
140 +}
packages/react/src/ReactCacheServer.js
+1 -1
@@ -7,4 +7,4 @@
7 * @flow
8 */
9
10 -export {cache} from './ReactCacheImpl';
10 +export {cache, cacheSignal} from './ReactCacheImpl';
packages/react/src/ReactClient.js
+2 -1
@@ -33,7 +33,7 @@ import {createContext} from './ReactContext';
33 import {lazy} from './ReactLazy';
34 import {forwardRef} from './ReactForwardRef';
35 import {memo} from './ReactMemo';
36 -import {cache} from './ReactCacheClient';
36 +import {cache, cacheSignal} from './ReactCacheClient';
37 import {postpone} from './ReactPostpone';
38 import {
39 getCacheForType,
@@ -83,6 +83,7 @@ export {
83 lazy,
84 memo,
85 cache,
86 + cacheSignal,
87 postpone as unstable_postpone,
88 useCallback,
89 useContext,
packages/react/src/ReactServer.experimental.development.js
+2 -1
@@ -35,7 +35,7 @@ import {
35 import {forwardRef} from './ReactForwardRef';
36 import {lazy} from './ReactLazy';
37 import {memo} from './ReactMemo';
38 -import {cache} from './ReactCacheServer';
38 +import {cache, cacheSignal} from './ReactCacheServer';
39 import {startTransition} from './ReactStartTransition';
40 import {postpone} from './ReactPostpone';
41 import {captureOwnerStack} from './ReactOwnerStack';
@@ -70,6 +70,7 @@ export {
70 lazy,
71 memo,
72 cache,
73 + cacheSignal,
74 startTransition,
75 getCacheForType as unstable_getCacheForType,
76 postpone as unstable_postpone,
packages/react/src/ReactServer.experimental.js
+2 -1
@@ -36,7 +36,7 @@ import {
36 import {forwardRef} from './ReactForwardRef';
37 import {lazy} from './ReactLazy';
38 import {memo} from './ReactMemo';
39 -import {cache} from './ReactCacheServer';
39 +import {cache, cacheSignal} from './ReactCacheServer';
40 import {startTransition} from './ReactStartTransition';
41 import {postpone} from './ReactPostpone';
42 import version from 'shared/ReactVersion';
@@ -70,6 +70,7 @@ export {
70 lazy,
71 memo,
72 cache,
73 + cacheSignal,
74 startTransition,
75 getCacheForType as unstable_getCacheForType,
76 postpone as unstable_postpone,
packages/react/src/ReactServer.fb.js
+2 -1
@@ -27,7 +27,7 @@ import {use, useId, useCallback, useDebugValue, useMemo} from './ReactHooks';
27 import {forwardRef} from './ReactForwardRef';
28 import {lazy} from './ReactLazy';
29 import {memo} from './ReactMemo';
30 -import {cache} from './ReactCacheServer';
30 +import {cache, cacheSignal} from './ReactCacheServer';
31 import version from 'shared/ReactVersion';
32
33 const Children = {
@@ -58,6 +58,7 @@ export {
58 lazy,
59 memo,
60 cache,
61 + cacheSignal,
62 useId,
63 useCallback,
64 useDebugValue,
packages/react/src/ReactServer.js
+2 -1
@@ -26,7 +26,7 @@ import {use, useId, useCallback, useDebugValue, useMemo} from './ReactHooks';
26 import {forwardRef} from './ReactForwardRef';
27 import {lazy} from './ReactLazy';
28 import {memo} from './ReactMemo';
29 -import {cache} from './ReactCacheServer';
29 +import {cache, cacheSignal} from './ReactCacheServer';
30 import version from 'shared/ReactVersion';
31 import {captureOwnerStack} from './ReactOwnerStack';
32
@@ -53,6 +53,7 @@ export {
53 lazy,
54 memo,
55 cache,
56 + cacheSignal,
57 useId,
58 useCallback,
59 useDebugValue,
scripts/error-codes/codes.json
+3 -1
@@ -546,5 +546,7 @@
546 "558": "Client rendering an Activity suspended it again. This is a bug in React.",
547 "559": "Expected to find a host node. This is a bug in React.",
548 "560": "Cannot use a startGestureTransition() with a comment node root.",
549 - "561": "This rendered a large document (>%s kB) without any Suspense boundaries around most of it. That can delay initial paint longer than necessary. To improve load performance, add a <Suspense> or <SuspenseList> around the content you expect to be below the header or below the fold. In the meantime, the content will deopt to paint arbitrary incomplete pieces of HTML."
549 + "561": "This rendered a large document (>%s kB) without any Suspense boundaries around most of it. That can delay initial paint longer than necessary. To improve load performance, add a <Suspense> or <SuspenseList> around the content you expect to be below the header or below the fold. In the meantime, the content will deopt to paint arbitrary incomplete pieces of HTML.",
550 + "562": "The render was aborted due to a fatal error.",
551 + "563": "This render completed successfully. All cacheSignals are now aborted to allow clean up of any unused resources."
552 }