Skip to content

Commit 4a10721

Browse files
authored
Memoize promise listeners to prevent exponential growth (#14429)
* Memoize promise listeners to prevent exponential growth Previously, React would attach a new listener every time a promise is thrown, regardless of whether the same listener was already attached during a previous render. Because React attempts to render every time a promise resolves, the number of listeners grows quickly. This was especially bad in synchronous mode because the renders that happen when the promise pings are not batched together. So if a single promise has multiple listeners for the same root, there will be multiple renders, which in turn results in more listeners being added to the remaining unresolved promises. This results in exponential growth in the number of listeners with respect to the number of IO-bound components in a single render. Fixes #14220 * Memoize on the root and Suspense fiber instead of on the promise * Add TODO to fix persistent mode tests
1 parent 535804f commit 4a10721

14 files changed

+220
-119
lines changed

Diff for: packages/react-reconciler/src/ReactFiberBeginWork.js

+1
Original file line numberDiff line numberDiff line change
@@ -1474,6 +1474,7 @@ function updateSuspenseComponent(
14741474
);
14751475
}
14761476
}
1477+
workInProgress.stateNode = current.stateNode;
14771478
}
14781479

14791480
workInProgress.memoizedState = nextState;

Diff for: packages/react-reconciler/src/ReactFiberCommitWork.js

+29
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,9 @@ import type {ExpirationTime} from './ReactFiberExpirationTime';
2020
import type {CapturedValue, CapturedError} from './ReactCapturedValue';
2121
import type {SuspenseState} from './ReactFiberSuspenseComponent';
2222
import type {FunctionComponentUpdateQueue} from './ReactFiberHooks';
23+
import type {Thenable} from './ReactFiberScheduler';
2324

25+
import {unstable_wrap as Schedule_tracing_wrap} from 'scheduler/tracing';
2426
import {
2527
enableHooks,
2628
enableSchedulerTracing,
@@ -88,6 +90,7 @@ import {
8890
import {
8991
captureCommitPhaseError,
9092
requestCurrentTime,
93+
retryTimedOutBoundary,
9194
} from './ReactFiberScheduler';
9295
import {
9396
NoEffect as NoHookEffect,
@@ -106,6 +109,8 @@ if (__DEV__) {
106109
didWarnAboutUndefinedSnapshotBeforeUpdate = new Set();
107110
}
108111

112+
const PossiblyWeakSet = typeof WeakSet === 'function' ? WeakSet : Set;
113+
109114
export function logError(boundary: Fiber, errorInfo: CapturedValue<mixed>) {
110115
const source = errorInfo.source;
111116
let stack = errorInfo.stack;
@@ -1180,6 +1185,30 @@ function commitWork(current: Fiber | null, finishedWork: Fiber): void {
11801185
if (primaryChildParent !== null) {
11811186
hideOrUnhideAllChildren(primaryChildParent, newDidTimeout);
11821187
}
1188+
1189+
// If this boundary just timed out, then it will have a set of thenables.
1190+
// For each thenable, attach a listener so that when it resolves, React
1191+
// attempts to re-render the boundary in the primary (pre-timeout) state.
1192+
const thenables: Set<Thenable> | null = (finishedWork.updateQueue: any);
1193+
if (thenables !== null) {
1194+
finishedWork.updateQueue = null;
1195+
let retryCache = finishedWork.stateNode;
1196+
if (retryCache === null) {
1197+
retryCache = finishedWork.stateNode = new PossiblyWeakSet();
1198+
}
1199+
thenables.forEach(thenable => {
1200+
// Memoize using the boundary fiber to prevent redundant listeners.
1201+
let retry = retryTimedOutBoundary.bind(null, finishedWork, thenable);
1202+
if (enableSchedulerTracing) {
1203+
retry = Schedule_tracing_wrap(retry);
1204+
}
1205+
if (!retryCache.has(thenable)) {
1206+
retryCache.add(thenable);
1207+
thenable.then(retry, retry);
1208+
}
1209+
});
1210+
}
1211+
11831212
return;
11841213
}
11851214
case IncompleteClassComponent: {

Diff for: packages/react-reconciler/src/ReactFiberPendingPriority.js

+5-3
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,10 @@ export function markCommittedPriorityLevels(
6262
return;
6363
}
6464

65+
if (earliestRemainingTime < root.latestPingedTime) {
66+
root.latestPingedTime = NoWork;
67+
}
68+
6569
// Let's see if the previous latest known pending level was just flushed.
6670
const latestPendingTime = root.latestPendingTime;
6771
if (latestPendingTime !== NoWork) {
@@ -209,10 +213,8 @@ export function markPingedPriorityLevel(
209213
}
210214

211215
function clearPing(root, completedTime) {
212-
// TODO: Track whether the root was pinged during the render phase. If so,
213-
// we need to make sure we don't lose track of it.
214216
const latestPingedTime = root.latestPingedTime;
215-
if (latestPingedTime !== NoWork && latestPingedTime >= completedTime) {
217+
if (latestPingedTime >= completedTime) {
216218
root.latestPingedTime = NoWork;
217219
}
218220
}

Diff for: packages/react-reconciler/src/ReactFiberRoot.js

+10
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
import type {Fiber} from './ReactFiber';
1111
import type {ExpirationTime} from './ReactFiberExpirationTime';
1212
import type {TimeoutHandle, NoTimeout} from './ReactFiberHostConfig';
13+
import type {Thenable} from './ReactFiberScheduler';
1314
import type {Interaction} from 'scheduler/src/Tracing';
1415

1516
import {noTimeout} from './ReactFiberHostConfig';
@@ -51,6 +52,11 @@ type BaseFiberRootProperties = {|
5152
// be retried.
5253
latestPingedTime: ExpirationTime,
5354

55+
pingCache:
56+
| WeakMap<Thenable, Set<ExpirationTime>>
57+
| Map<Thenable, Set<ExpirationTime>>
58+
| null,
59+
5460
// If an error is thrown, and there are no more updates in the queue, we try
5561
// rendering from the root one more time, synchronously, before handling
5662
// the error.
@@ -121,6 +127,8 @@ export function createFiberRoot(
121127
latestSuspendedTime: NoWork,
122128
latestPingedTime: NoWork,
123129

130+
pingCache: null,
131+
124132
didError: false,
125133

126134
pendingCommitExpirationTime: NoWork,
@@ -144,6 +152,8 @@ export function createFiberRoot(
144152
containerInfo: containerInfo,
145153
pendingChildren: null,
146154

155+
pingCache: null,
156+
147157
earliestPendingTime: NoWork,
148158
latestPendingTime: NoWork,
149159
earliestSuspendedTime: NoWork,

Diff for: packages/react-reconciler/src/ReactFiberScheduler.js

+48-52
Original file line numberDiff line numberDiff line change
@@ -125,13 +125,8 @@ import {
125125
computeAsyncExpiration,
126126
computeInteractiveExpiration,
127127
} from './ReactFiberExpirationTime';
128-
import {ConcurrentMode, ProfileMode, NoContext} from './ReactTypeOfMode';
129-
import {
130-
enqueueUpdate,
131-
resetCurrentlyProcessingQueue,
132-
ForceUpdate,
133-
createUpdate,
134-
} from './ReactUpdateQueue';
128+
import {ConcurrentMode, ProfileMode} from './ReactTypeOfMode';
129+
import {enqueueUpdate, resetCurrentlyProcessingQueue} from './ReactUpdateQueue';
135130
import {createCapturedValue} from './ReactCapturedValue';
136131
import {
137132
isContextProvider as isLegacyContextProvider,
@@ -1646,62 +1641,62 @@ function renderDidError() {
16461641
nextRenderDidError = true;
16471642
}
16481643

1649-
function retrySuspendedRoot(
1644+
function pingSuspendedRoot(
16501645
root: FiberRoot,
1651-
boundaryFiber: Fiber,
1652-
sourceFiber: Fiber,
1653-
suspendedTime: ExpirationTime,
1646+
thenable: Thenable,
1647+
pingTime: ExpirationTime,
16541648
) {
1655-
let retryTime;
1649+
// A promise that previously suspended React from committing has resolved.
1650+
// If React is still suspended, try again at the previous level (pingTime).
16561651

1657-
if (isPriorityLevelSuspended(root, suspendedTime)) {
1658-
// Ping at the original level
1659-
retryTime = suspendedTime;
1652+
const pingCache = root.pingCache;
1653+
if (pingCache !== null) {
1654+
// The thenable resolved, so we no longer need to memoize, because it will
1655+
// never be thrown again.
1656+
pingCache.delete(thenable);
1657+
}
16601658

1661-
markPingedPriorityLevel(root, retryTime);
1659+
if (nextRoot !== null && nextRenderExpirationTime === pingTime) {
1660+
// Received a ping at the same priority level at which we're currently
1661+
// rendering. Restart from the root.
1662+
nextRoot = null;
16621663
} else {
1663-
// Suspense already timed out. Compute a new expiration time
1664-
const currentTime = requestCurrentTime();
1665-
retryTime = computeExpirationForFiber(currentTime, boundaryFiber);
1666-
markPendingPriorityLevel(root, retryTime);
1664+
// Confirm that the root is still suspended at this level. Otherwise exit.
1665+
if (isPriorityLevelSuspended(root, pingTime)) {
1666+
// Ping at the original level
1667+
markPingedPriorityLevel(root, pingTime);
1668+
const rootExpirationTime = root.expirationTime;
1669+
if (rootExpirationTime !== NoWork) {
1670+
requestWork(root, rootExpirationTime);
1671+
}
1672+
}
16671673
}
1674+
}
16681675

1669-
// TODO: If the suspense fiber has already rendered the primary children
1670-
// without suspending (that is, all of the promises have already resolved),
1671-
// we should not trigger another update here. One case this happens is when
1672-
// we are in sync mode and a single promise is thrown both on initial render
1673-
// and on update; we attach two .then(retrySuspendedRoot) callbacks and each
1674-
// one performs Sync work, rerendering the Suspense.
1676+
function retryTimedOutBoundary(boundaryFiber: Fiber, thenable: Thenable) {
1677+
// The boundary fiber (a Suspense component) previously timed out and was
1678+
// rendered in its fallback state. One of the promises that suspended it has
1679+
// resolved, which means at least part of the tree was likely unblocked. Try
1680+
// rendering again, at a new expiration time.
16751681

1676-
if ((boundaryFiber.mode & ConcurrentMode) !== NoContext) {
1677-
if (root === nextRoot && nextRenderExpirationTime === suspendedTime) {
1678-
// Received a ping at the same priority level at which we're currently
1679-
// rendering. Restart from the root.
1680-
nextRoot = null;
1681-
}
1682+
const retryCache: WeakSet<Thenable> | Set<Thenable> | null =
1683+
boundaryFiber.stateNode;
1684+
if (retryCache !== null) {
1685+
// The thenable resolved, so we no longer need to memoize, because it will
1686+
// never be thrown again.
1687+
retryCache.delete(thenable);
16821688
}
16831689

1684-
scheduleWorkToRoot(boundaryFiber, retryTime);
1685-
if ((boundaryFiber.mode & ConcurrentMode) === NoContext) {
1686-
// Outside of concurrent mode, we must schedule an update on the source
1687-
// fiber, too, since it already committed in an inconsistent state and
1688-
// therefore does not have any pending work.
1689-
scheduleWorkToRoot(sourceFiber, retryTime);
1690-
const sourceTag = sourceFiber.tag;
1691-
if (sourceTag === ClassComponent && sourceFiber.stateNode !== null) {
1692-
// When we try rendering again, we should not reuse the current fiber,
1693-
// since it's known to be in an inconsistent state. Use a force updte to
1694-
// prevent a bail out.
1695-
const update = createUpdate(retryTime);
1696-
update.tag = ForceUpdate;
1697-
enqueueUpdate(sourceFiber, update);
1690+
const currentTime = requestCurrentTime();
1691+
const retryTime = computeExpirationForFiber(currentTime, boundaryFiber);
1692+
const root = scheduleWorkToRoot(boundaryFiber, retryTime);
1693+
if (root !== null) {
1694+
markPendingPriorityLevel(root, retryTime);
1695+
const rootExpirationTime = root.expirationTime;
1696+
if (rootExpirationTime !== NoWork) {
1697+
requestWork(root, rootExpirationTime);
16981698
}
16991699
}
1700-
1701-
const rootExpirationTime = root.expirationTime;
1702-
if (rootExpirationTime !== NoWork) {
1703-
requestWork(root, rootExpirationTime);
1704-
}
17051700
}
17061701

17071702
function scheduleWorkToRoot(fiber: Fiber, expirationTime): FiberRoot | null {
@@ -2550,7 +2545,8 @@ export {
25502545
onUncaughtError,
25512546
renderDidSuspend,
25522547
renderDidError,
2553-
retrySuspendedRoot,
2548+
pingSuspendedRoot,
2549+
retryTimedOutBoundary,
25542550
markLegacyErrorBoundaryAsFailed,
25552551
isAlreadyFailedLegacyErrorBoundary,
25562552
scheduleWork,

Diff for: packages/react-reconciler/src/ReactFiberSuspenseComponent.js

+1-4
Original file line numberDiff line numberDiff line change
@@ -14,10 +14,7 @@ export type SuspenseState = {|
1414
timedOutAt: ExpirationTime,
1515
|};
1616

17-
export function shouldCaptureSuspense(
18-
current: Fiber | null,
19-
workInProgress: Fiber,
20-
): boolean {
17+
export function shouldCaptureSuspense(workInProgress: Fiber): boolean {
2118
// In order to capture, the Suspense component must have a fallback prop.
2219
if (workInProgress.memoizedProps.fallback === undefined) {
2320
return false;

0 commit comments

Comments
 (0)