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.
This commit is contained in:
Sebastian Markbåge 2025-06-17 17:04:40 -04:00 committed by GitHub
parent 90bee81902
commit e1dc03492e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
24 changed files with 183 additions and 11 deletions

View File

@ -70,6 +70,7 @@ type Options = {
environmentName?: string | (() => string),
filterStackFrame?: (url: string, functionName: string) => boolean,
identifierPrefix?: string,
signal?: AbortSignal,
onError?: (error: mixed) => void,
onPostpone?: (reason: string) => void,
};
@ -87,6 +88,18 @@ function render(model: ReactClientValue, options?: Options): Destination {
__DEV__ && options ? options.environmentName : undefined,
__DEV__ && options ? options.filterStackFrame : undefined,
);
const signal = options ? options.signal : undefined;
if (signal) {
if (signal.aborted) {
ReactNoopFlightServer.abort(request, (signal: any).reason);
} else {
const listener = () => {
ReactNoopFlightServer.abort(request, (signal: any).reason);
signal.removeEventListener('abort', listener);
};
signal.addEventListener('abort', listener);
}
}
ReactNoopFlightServer.startWork(request);
ReactNoopFlightServer.startFlowing(request, destination);
return destination;

View File

@ -25,8 +25,14 @@ function getCacheForType<T>(resourceType: () => T): T {
return cacheForType;
}
function cacheSignal(): null | AbortSignal {
const cache: Cache = readContext(CacheContext);
return cache.controller.signal;
}
export const DefaultAsyncDispatcher: AsyncDispatcher = ({
getCacheForType,
cacheSignal,
}: any);
if (__DEV__) {

View File

@ -459,6 +459,7 @@ export type Dispatcher = {
export type AsyncDispatcher = {
getCacheForType: <T>(resourceType: () => T) => T,
cacheSignal: () => null | AbortSignal,
// DEV-only
getOwner: () => null | Fiber | ReactComponentInfo | ComponentStackNode,
};

View File

@ -14,6 +14,7 @@ let React;
let ReactNoopFlightServer;
let ReactNoopFlightClient;
let cache;
let cacheSignal;
describe('ReactCache', () => {
beforeEach(() => {
@ -25,6 +26,7 @@ describe('ReactCache', () => {
ReactNoopFlightClient = require('react-noop-renderer/flight-client');
cache = React.cache;
cacheSignal = React.cacheSignal;
jest.resetModules();
__unmockReact();
@ -220,4 +222,86 @@ describe('ReactCache', () => {
expect(cachedFoo.length).toBe(0);
expect(cachedFoo.displayName).toBe(undefined);
});
it('cacheSignal() returns null outside a render', async () => {
expect(cacheSignal()).toBe(null);
});
it('cacheSignal() aborts when the render finishes normally', async () => {
let renderedCacheSignal = null;
let resolve;
const promise = new Promise(r => (resolve = r));
async function Test() {
renderedCacheSignal = cacheSignal();
await promise;
return 'Hi';
}
const controller = new AbortController();
const errors = [];
const result = ReactNoopFlightServer.render(<Test />, {
signal: controller.signal,
onError(x) {
errors.push(x);
},
});
expect(errors).toEqual([]);
expect(renderedCacheSignal).not.toBe(controller.signal); // In the future we might make these the same
expect(renderedCacheSignal.aborted).toBe(false);
await resolve();
await 0;
await 0;
expect(await ReactNoopFlightClient.read(result)).toBe('Hi');
expect(errors).toEqual([]);
expect(renderedCacheSignal.aborted).toBe(true);
expect(renderedCacheSignal.reason.message).toContain(
'This render completed successfully.',
);
});
it('cacheSignal() aborts when the render is aborted', async () => {
let renderedCacheSignal = null;
const promise = new Promise(() => {});
async function Test() {
renderedCacheSignal = cacheSignal();
await promise;
return 'Hi';
}
const controller = new AbortController();
const errors = [];
const result = ReactNoopFlightServer.render(<Test />, {
signal: controller.signal,
onError(x) {
errors.push(x);
return 'hi';
},
});
expect(errors).toEqual([]);
expect(renderedCacheSignal).not.toBe(controller.signal); // In the future we might make these the same
expect(renderedCacheSignal.aborted).toBe(false);
const reason = new Error('Timed out');
controller.abort(reason);
expect(errors).toEqual([reason]);
expect(renderedCacheSignal.aborted).toBe(true);
expect(renderedCacheSignal.reason).toBe(reason);
let clientError = null;
try {
await ReactNoopFlightClient.read(result);
} catch (x) {
clientError = x;
}
expect(clientError).not.toBe(null);
if (__DEV__) {
expect(clientError.message).toBe('Timed out');
}
expect(clientError.digest).toBe('hi');
});
});

View File

@ -16,8 +16,13 @@ function getCacheForType<T>(resourceType: () => T): T {
throw new Error('Not implemented.');
}
function cacheSignal(): null | AbortSignal {
throw new Error('Not implemented.');
}
export const DefaultAsyncDispatcher: AsyncDispatcher = ({
getCacheForType,
cacheSignal,
}: any);
if (__DEV__) {

View File

@ -419,6 +419,7 @@ export type Request = {
destination: null | Destination,
bundlerConfig: ClientManifest,
cache: Map<Function, mixed>,
cacheController: AbortController,
nextChunkId: number,
pendingChunks: number,
hints: Hints,
@ -529,6 +530,7 @@ function RequestInstance(
this.destination = null;
this.bundlerConfig = bundlerConfig;
this.cache = new Map();
this.cacheController = new AbortController();
this.nextChunkId = 0;
this.pendingChunks = 0;
this.hints = hints;
@ -604,7 +606,7 @@ export function createRequest(
model: ReactClientValue,
bundlerConfig: ClientManifest,
onError: void | ((error: mixed) => ?string),
identifierPrefix?: string,
identifierPrefix: void | string,
onPostpone: void | ((reason: string) => void),
temporaryReferences: void | TemporaryReferenceSet,
environmentName: void | string | (() => string), // DEV-only
@ -636,7 +638,7 @@ export function createPrerenderRequest(
onAllReady: () => void,
onFatalError: () => void,
onError: void | ((error: mixed) => ?string),
identifierPrefix?: string,
identifierPrefix: void | string,
onPostpone: void | ((reason: string) => void),
temporaryReferences: void | TemporaryReferenceSet,
environmentName: void | string | (() => string), // DEV-only
@ -3369,6 +3371,13 @@ function fatalError(request: Request, error: mixed): void {
request.status = CLOSING;
request.fatalError = error;
}
const abortReason = new Error(
'The render was aborted due to a fatal error.',
{
cause: error,
},
);
request.cacheController.abort(abortReason);
}
function emitPostponeChunk(
@ -4840,6 +4849,12 @@ function flushCompletedChunks(
if (enableTaint) {
cleanupTaintQueue(request);
}
if (request.status < ABORTING) {
const abortReason = new Error(
'This render completed successfully. All cacheSignals are now aborted to allow clean up of any unused resources.',
);
request.cacheController.abort(abortReason);
}
request.status = CLOSED;
close(destination);
request.destination = null;
@ -4921,6 +4936,7 @@ export function abort(request: Request, reason: mixed): void {
// We define any status below OPEN as OPEN equivalent
if (request.status <= OPEN) {
request.status = ABORTING;
request.cacheController.abort(reason);
}
const abortableTasks = request.abortableTasks;
if (abortableTasks.size > 0) {

View File

@ -31,6 +31,13 @@ export const DefaultAsyncDispatcher: AsyncDispatcher = ({
}
return entry;
},
cacheSignal(): null | AbortSignal {
const request = resolveRequest();
if (request) {
return request.cacheController.signal;
}
return null;
},
}: any);
if (__DEV__) {

View File

@ -22,6 +22,9 @@ export function waitForSuspense<T>(fn: () => T): Promise<T> {
}
return entry;
},
cacheSignal(): null {
return null;
},
getOwner(): null {
return null;
},

View File

@ -44,6 +44,7 @@ export {
lazy,
memo,
cache,
cacheSignal,
startTransition,
unstable_LegacyHidden,
unstable_Activity,

View File

@ -27,6 +27,7 @@ export {
lazy,
memo,
cache,
cacheSignal,
startTransition,
unstable_Activity,
unstable_postpone,

View File

@ -27,6 +27,7 @@ export {
lazy,
memo,
cache,
cacheSignal,
startTransition,
unstable_Activity,
unstable_postpone,

View File

@ -14,6 +14,7 @@ export {
__COMPILER_RUNTIME,
act,
cache,
cacheSignal,
Children,
cloneElement,
Component,

View File

@ -44,6 +44,7 @@ export {
lazy,
memo,
cache,
cacheSignal,
startTransition,
unstable_LegacyHidden,
unstable_Activity,

View File

@ -27,6 +27,7 @@ export {
lazy,
memo,
cache,
cacheSignal,
unstable_useCacheRefresh,
startTransition,
useId,

View File

@ -27,6 +27,7 @@ export {
lazy,
memo,
cache,
cacheSignal,
unstable_useCacheRefresh,
startTransition,
useId,

View File

@ -8,9 +8,12 @@
*/
import {disableClientCache} from 'shared/ReactFeatureFlags';
import {cache as cacheImpl} from './ReactCacheImpl';
import {
cache as cacheImpl,
cacheSignal as cacheSignalImpl,
} from './ReactCacheImpl';
export function noopCache<A: Iterable<mixed>, T>(fn: (...A) => T): (...A) => T {
function noopCache<A: Iterable<mixed>, T>(fn: (...A) => T): (...A) => T {
// On the client (i.e. not a Server Components environment) `cache` has
// no caching behavior. We just return the function as-is.
//
@ -32,3 +35,11 @@ export function noopCache<A: Iterable<mixed>, T>(fn: (...A) => T): (...A) => T {
export const cache: typeof noopCache = disableClientCache
? noopCache
: cacheImpl;
function noopCacheSignal(): null | AbortSignal {
return null;
}
export const cacheSignal: () => null | AbortSignal = disableClientCache
? noopCacheSignal
: cacheSignalImpl;

View File

@ -126,3 +126,15 @@ export function cache<A: Iterable<mixed>, T>(fn: (...A) => T): (...A) => T {
}
};
}
export function cacheSignal(): null | AbortSignal {
const dispatcher = ReactSharedInternals.A;
if (!dispatcher) {
// If there is no dispatcher, then we treat this as not having an AbortSignal
// since in the same context, a cached function will be allowed to be called
// but it won't be cached. So it's neither an infinite AbortSignal nor an
// already resolved one.
return null;
}
return dispatcher.cacheSignal();
}

View File

@ -7,4 +7,4 @@
* @flow
*/
export {cache} from './ReactCacheImpl';
export {cache, cacheSignal} from './ReactCacheImpl';

View File

@ -33,7 +33,7 @@ import {createContext} from './ReactContext';
import {lazy} from './ReactLazy';
import {forwardRef} from './ReactForwardRef';
import {memo} from './ReactMemo';
import {cache} from './ReactCacheClient';
import {cache, cacheSignal} from './ReactCacheClient';
import {postpone} from './ReactPostpone';
import {
getCacheForType,
@ -83,6 +83,7 @@ export {
lazy,
memo,
cache,
cacheSignal,
postpone as unstable_postpone,
useCallback,
useContext,

View File

@ -35,7 +35,7 @@ import {
import {forwardRef} from './ReactForwardRef';
import {lazy} from './ReactLazy';
import {memo} from './ReactMemo';
import {cache} from './ReactCacheServer';
import {cache, cacheSignal} from './ReactCacheServer';
import {startTransition} from './ReactStartTransition';
import {postpone} from './ReactPostpone';
import {captureOwnerStack} from './ReactOwnerStack';
@ -70,6 +70,7 @@ export {
lazy,
memo,
cache,
cacheSignal,
startTransition,
getCacheForType as unstable_getCacheForType,
postpone as unstable_postpone,

View File

@ -36,7 +36,7 @@ import {
import {forwardRef} from './ReactForwardRef';
import {lazy} from './ReactLazy';
import {memo} from './ReactMemo';
import {cache} from './ReactCacheServer';
import {cache, cacheSignal} from './ReactCacheServer';
import {startTransition} from './ReactStartTransition';
import {postpone} from './ReactPostpone';
import version from 'shared/ReactVersion';
@ -70,6 +70,7 @@ export {
lazy,
memo,
cache,
cacheSignal,
startTransition,
getCacheForType as unstable_getCacheForType,
postpone as unstable_postpone,

View File

@ -27,7 +27,7 @@ import {use, useId, useCallback, useDebugValue, useMemo} from './ReactHooks';
import {forwardRef} from './ReactForwardRef';
import {lazy} from './ReactLazy';
import {memo} from './ReactMemo';
import {cache} from './ReactCacheServer';
import {cache, cacheSignal} from './ReactCacheServer';
import version from 'shared/ReactVersion';
const Children = {
@ -58,6 +58,7 @@ export {
lazy,
memo,
cache,
cacheSignal,
useId,
useCallback,
useDebugValue,

View File

@ -26,7 +26,7 @@ import {use, useId, useCallback, useDebugValue, useMemo} from './ReactHooks';
import {forwardRef} from './ReactForwardRef';
import {lazy} from './ReactLazy';
import {memo} from './ReactMemo';
import {cache} from './ReactCacheServer';
import {cache, cacheSignal} from './ReactCacheServer';
import version from 'shared/ReactVersion';
import {captureOwnerStack} from './ReactOwnerStack';
@ -53,6 +53,7 @@ export {
lazy,
memo,
cache,
cacheSignal,
useId,
useCallback,
useDebugValue,

View File

@ -546,5 +546,7 @@
"558": "Client rendering an Activity suspended it again. This is a bug in React.",
"559": "Expected to find a host node. This is a bug in React.",
"560": "Cannot use a startGestureTransition() with a comment node root.",
"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."
"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.",
"562": "The render was aborted due to a fatal error.",
"563": "This render completed successfully. All cacheSignals are now aborted to allow clean up of any unused resources."
}