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

Lines changed: 220 additions & 119 deletions

‎packages/react-reconciler/src/ReactFiberBeginWork.js‎

Lines changed: 1 addition & 0 deletions
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;

‎packages/react-reconciler/src/ReactFiberCommitWork.js‎

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

25+
import{unstable_wrapasSchedule_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
NoEffectasNoHookEffect,
@@ -106,6 +109,8 @@ if (__DEV__) {
106109
didWarnAboutUndefinedSnapshotBeforeUpdate=newSet();
107110
}
108111

112+
constPossiblyWeakSet=typeofWeakSet==='function' ? WeakSet : Set;
113+
109114
exportfunctionlogError(boundary: Fiber,errorInfo: CapturedValue<mixed>){
110115
constsource=errorInfo.source;
111116
letstack=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+
constthenables: Set<Thenable> | null = (finishedWork.updateQueue: any);
1193+
if (thenables !== null) {
1194+
finishedWork.updateQueue=null;
1195+
letretryCache=finishedWork.stateNode;
1196+
if(retryCache===null){
1197+
retryCache=finishedWork.stateNode=newPossiblyWeakSet();
1198+
}
1199+
thenables.forEach(thenable=>{
1200+
// Memoize using the boundary fiber to prevent redundant listeners.
1201+
letretry=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: {

‎packages/react-reconciler/src/ReactFiberPendingPriority.js‎

Lines changed: 5 additions & 3 deletions
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
constlatestPendingTime=root.latestPendingTime;
6771
if(latestPendingTime!==NoWork){
@@ -209,10 +213,8 @@ export function markPingedPriorityLevel(
209213
}
210214

211215
functionclearPing(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
constlatestPingedTime=root.latestPingedTime;
215-
if(latestPingedTime!==NoWork&&latestPingedTime>=completedTime){
217+
if(latestPingedTime>=completedTime){
216218
root.latestPingedTime=NoWork;
217219
}
218220
}

‎packages/react-reconciler/src/ReactFiberRoot.js‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
importtype{Fiber}from'./ReactFiber';
1111
importtype{ExpirationTime}from'./ReactFiberExpirationTime';
1212
importtype{TimeoutHandle,NoTimeout}from'./ReactFiberHostConfig';
13+
importtype{Thenable}from'./ReactFiberScheduler';
1314
importtype{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,

‎packages/react-reconciler/src/ReactFiberScheduler.js‎

Lines changed: 48 additions & 52 deletions
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
isContextProviderasisLegacyContextProvider,
@@ -1646,62 +1641,62 @@ function renderDidError() {
16461641
nextRenderDidError=true;
16471642
}
16481643

1649-
functionretrySuspendedRoot(
1644+
functionpingSuspendedRoot(
16501645
root: FiberRoot,
1651-
boundaryFiber: Fiber,
1652-
sourceFiber: Fiber,
1653-
suspendedTime: ExpirationTime,
1646+
thenable: Thenable,
1647+
pingTime: ExpirationTime,
16541648
){
1655-
letretryTime;
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+
constpingCache=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-
constcurrentTime=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+
constrootExpirationTime=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+
functionretryTimedOutBoundary(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+
constretryCache: 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-
constsourceTag=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-
constupdate=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+
constrootExpirationTime=root.expirationTime;
1696+
if(rootExpirationTime!==NoWork){
1697+
requestWork(root,rootExpirationTime);
16981698
}
16991699
}
1700-
1701-
constrootExpirationTime=root.expirationTime;
1702-
if(rootExpirationTime!==NoWork){
1703-
requestWork(root,rootExpirationTime);
1704-
}
17051700
}
17061701

17071702
functionscheduleWorkToRoot(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,

‎packages/react-reconciler/src/ReactFiberSuspenseComponent.js‎

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

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

0 commit comments

Comments
 (0)