From e1dc03492eedaec517e14a6e32b8fda571d00767 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sebastian=20Markb=C3=A5ge?= Date: Tue, 17 Jun 2025 17:04:40 -0400 Subject: [PATCH] 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. --- .../src/ReactNoopFlightServer.js | 13 +++ .../src/ReactFiberAsyncDispatcher.js | 6 ++ .../src/ReactInternalTypes.js | 1 + .../src/__tests__/ReactCache-test.js | 84 +++++++++++++++++++ .../src/ReactFizzAsyncDispatcher.js | 5 ++ .../react-server/src/ReactFlightServer.js | 20 ++++- .../src/flight/ReactFlightAsyncDispatcher.js | 7 ++ .../src/ReactSuspenseTestUtils.js | 3 + packages/react/index.development.js | 1 + .../react/index.experimental.development.js | 1 + packages/react/index.experimental.js | 1 + packages/react/index.fb.js | 1 + packages/react/index.js | 1 + packages/react/index.stable.development.js | 1 + packages/react/index.stable.js | 1 + packages/react/src/ReactCacheClient.js | 15 +++- packages/react/src/ReactCacheImpl.js | 12 +++ packages/react/src/ReactCacheServer.js | 2 +- packages/react/src/ReactClient.js | 3 +- .../ReactServer.experimental.development.js | 3 +- .../react/src/ReactServer.experimental.js | 3 +- packages/react/src/ReactServer.fb.js | 3 +- packages/react/src/ReactServer.js | 3 +- scripts/error-codes/codes.json | 4 +- 24 files changed, 183 insertions(+), 11 deletions(-) diff --git a/packages/react-noop-renderer/src/ReactNoopFlightServer.js b/packages/react-noop-renderer/src/ReactNoopFlightServer.js index d3920331b2..7c3790e9cf 100644 --- a/packages/react-noop-renderer/src/ReactNoopFlightServer.js +++ b/packages/react-noop-renderer/src/ReactNoopFlightServer.js @@ -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; diff --git a/packages/react-reconciler/src/ReactFiberAsyncDispatcher.js b/packages/react-reconciler/src/ReactFiberAsyncDispatcher.js index 4ff65fb900..2dfee307e2 100644 --- a/packages/react-reconciler/src/ReactFiberAsyncDispatcher.js +++ b/packages/react-reconciler/src/ReactFiberAsyncDispatcher.js @@ -25,8 +25,14 @@ function getCacheForType(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__) { diff --git a/packages/react-reconciler/src/ReactInternalTypes.js b/packages/react-reconciler/src/ReactInternalTypes.js index 25840749a1..ec75513892 100644 --- a/packages/react-reconciler/src/ReactInternalTypes.js +++ b/packages/react-reconciler/src/ReactInternalTypes.js @@ -459,6 +459,7 @@ export type Dispatcher = { export type AsyncDispatcher = { getCacheForType: (resourceType: () => T) => T, + cacheSignal: () => null | AbortSignal, // DEV-only getOwner: () => null | Fiber | ReactComponentInfo | ComponentStackNode, }; diff --git a/packages/react-reconciler/src/__tests__/ReactCache-test.js b/packages/react-reconciler/src/__tests__/ReactCache-test.js index e93b68cec3..4803ab5283 100644 --- a/packages/react-reconciler/src/__tests__/ReactCache-test.js +++ b/packages/react-reconciler/src/__tests__/ReactCache-test.js @@ -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(, { + 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(, { + 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'); + }); }); diff --git a/packages/react-server/src/ReactFizzAsyncDispatcher.js b/packages/react-server/src/ReactFizzAsyncDispatcher.js index e4d940d463..d20acdd5e9 100644 --- a/packages/react-server/src/ReactFizzAsyncDispatcher.js +++ b/packages/react-server/src/ReactFizzAsyncDispatcher.js @@ -16,8 +16,13 @@ function getCacheForType(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__) { diff --git a/packages/react-server/src/ReactFlightServer.js b/packages/react-server/src/ReactFlightServer.js index e51197c5b2..7b63b9f6f3 100644 --- a/packages/react-server/src/ReactFlightServer.js +++ b/packages/react-server/src/ReactFlightServer.js @@ -419,6 +419,7 @@ export type Request = { destination: null | Destination, bundlerConfig: ClientManifest, cache: Map, + 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) { diff --git a/packages/react-server/src/flight/ReactFlightAsyncDispatcher.js b/packages/react-server/src/flight/ReactFlightAsyncDispatcher.js index 00c1abf332..958b92c9cc 100644 --- a/packages/react-server/src/flight/ReactFlightAsyncDispatcher.js +++ b/packages/react-server/src/flight/ReactFlightAsyncDispatcher.js @@ -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__) { diff --git a/packages/react-suspense-test-utils/src/ReactSuspenseTestUtils.js b/packages/react-suspense-test-utils/src/ReactSuspenseTestUtils.js index bc2d4bee79..0e8e5c5b68 100644 --- a/packages/react-suspense-test-utils/src/ReactSuspenseTestUtils.js +++ b/packages/react-suspense-test-utils/src/ReactSuspenseTestUtils.js @@ -22,6 +22,9 @@ export function waitForSuspense(fn: () => T): Promise { } return entry; }, + cacheSignal(): null { + return null; + }, getOwner(): null { return null; }, diff --git a/packages/react/index.development.js b/packages/react/index.development.js index fa79633001..0f7703e511 100644 --- a/packages/react/index.development.js +++ b/packages/react/index.development.js @@ -44,6 +44,7 @@ export { lazy, memo, cache, + cacheSignal, startTransition, unstable_LegacyHidden, unstable_Activity, diff --git a/packages/react/index.experimental.development.js b/packages/react/index.experimental.development.js index cfa916dd67..7f0d03a0b2 100644 --- a/packages/react/index.experimental.development.js +++ b/packages/react/index.experimental.development.js @@ -27,6 +27,7 @@ export { lazy, memo, cache, + cacheSignal, startTransition, unstable_Activity, unstable_postpone, diff --git a/packages/react/index.experimental.js b/packages/react/index.experimental.js index 628746716e..dfaeca747e 100644 --- a/packages/react/index.experimental.js +++ b/packages/react/index.experimental.js @@ -27,6 +27,7 @@ export { lazy, memo, cache, + cacheSignal, startTransition, unstable_Activity, unstable_postpone, diff --git a/packages/react/index.fb.js b/packages/react/index.fb.js index 828db7a48a..fb637b799b 100644 --- a/packages/react/index.fb.js +++ b/packages/react/index.fb.js @@ -14,6 +14,7 @@ export { __COMPILER_RUNTIME, act, cache, + cacheSignal, Children, cloneElement, Component, diff --git a/packages/react/index.js b/packages/react/index.js index 92e1cf181e..5228ae8868 100644 --- a/packages/react/index.js +++ b/packages/react/index.js @@ -44,6 +44,7 @@ export { lazy, memo, cache, + cacheSignal, startTransition, unstable_LegacyHidden, unstable_Activity, diff --git a/packages/react/index.stable.development.js b/packages/react/index.stable.development.js index 2397010cf5..80fc4d7cac 100644 --- a/packages/react/index.stable.development.js +++ b/packages/react/index.stable.development.js @@ -27,6 +27,7 @@ export { lazy, memo, cache, + cacheSignal, unstable_useCacheRefresh, startTransition, useId, diff --git a/packages/react/index.stable.js b/packages/react/index.stable.js index 6f25c7a37d..1cb9de1e37 100644 --- a/packages/react/index.stable.js +++ b/packages/react/index.stable.js @@ -27,6 +27,7 @@ export { lazy, memo, cache, + cacheSignal, unstable_useCacheRefresh, startTransition, useId, diff --git a/packages/react/src/ReactCacheClient.js b/packages/react/src/ReactCacheClient.js index 64fef23c6c..ef1e8d6d1d 100644 --- a/packages/react/src/ReactCacheClient.js +++ b/packages/react/src/ReactCacheClient.js @@ -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, T>(fn: (...A) => T): (...A) => T { +function noopCache, 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, 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; diff --git a/packages/react/src/ReactCacheImpl.js b/packages/react/src/ReactCacheImpl.js index cc3136897e..2ff7431fc5 100644 --- a/packages/react/src/ReactCacheImpl.js +++ b/packages/react/src/ReactCacheImpl.js @@ -126,3 +126,15 @@ export function cache, 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(); +} diff --git a/packages/react/src/ReactCacheServer.js b/packages/react/src/ReactCacheServer.js index dd90d8de2a..ec68dc7c27 100644 --- a/packages/react/src/ReactCacheServer.js +++ b/packages/react/src/ReactCacheServer.js @@ -7,4 +7,4 @@ * @flow */ -export {cache} from './ReactCacheImpl'; +export {cache, cacheSignal} from './ReactCacheImpl'; diff --git a/packages/react/src/ReactClient.js b/packages/react/src/ReactClient.js index 3ead64acf6..b9b34e2188 100644 --- a/packages/react/src/ReactClient.js +++ b/packages/react/src/ReactClient.js @@ -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, diff --git a/packages/react/src/ReactServer.experimental.development.js b/packages/react/src/ReactServer.experimental.development.js index 0176e0e94f..7b50481231 100644 --- a/packages/react/src/ReactServer.experimental.development.js +++ b/packages/react/src/ReactServer.experimental.development.js @@ -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, diff --git a/packages/react/src/ReactServer.experimental.js b/packages/react/src/ReactServer.experimental.js index d84672d5f4..ad885f0968 100644 --- a/packages/react/src/ReactServer.experimental.js +++ b/packages/react/src/ReactServer.experimental.js @@ -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, diff --git a/packages/react/src/ReactServer.fb.js b/packages/react/src/ReactServer.fb.js index 634cb077af..998a786798 100644 --- a/packages/react/src/ReactServer.fb.js +++ b/packages/react/src/ReactServer.fb.js @@ -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, diff --git a/packages/react/src/ReactServer.js b/packages/react/src/ReactServer.js index a00e192bd3..be5cd22d91 100644 --- a/packages/react/src/ReactServer.js +++ b/packages/react/src/ReactServer.js @@ -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, diff --git a/scripts/error-codes/codes.json b/scripts/error-codes/codes.json index a3147f4dde..ef0bad4c09 100644 --- a/scripts/error-codes/codes.json +++ b/scripts/error-codes/codes.json @@ -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 or 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 or 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." }