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." }