Skip to content

Commit

Permalink
Memoize promise listeners to prevent exponential growth
Browse files Browse the repository at this point in the history
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
  • Loading branch information
acdlite committed Dec 14, 2018
1 parent 535804f commit 53e8639
Show file tree
Hide file tree
Showing 8 changed files with 186 additions and 122 deletions.
35 changes: 35 additions & 0 deletions packages/react-reconciler/src/ReactFiberCommitWork.js
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,9 @@ import type {ExpirationTime} from './ReactFiberExpirationTime';
import type {CapturedValue, CapturedError} from './ReactCapturedValue';
import type {SuspenseState} from './ReactFiberSuspenseComponent';
import type {FunctionComponentUpdateQueue} from './ReactFiberHooks';
import type {Thenable} from './ReactFiberScheduler';

import {unstable_wrap as Schedule_tracing_wrap} from 'scheduler/tracing';
import {
enableHooks,
enableSchedulerTracing,
Expand Down Expand Up @@ -88,6 +90,7 @@ import {
import {
captureCommitPhaseError,
requestCurrentTime,
retryTimedOutBoundary,
} from './ReactFiberScheduler';
import {
NoEffect as NoHookEffect,
Expand Down Expand Up @@ -1180,6 +1183,38 @@ function commitWork(current: Fiber | null, finishedWork: Fiber): void {
if (primaryChildParent !== null) {
hideOrUnhideAllChildren(primaryChildParent, newDidTimeout);
}

// If this boundary just timed out, then it will have a set of thenables.
// For each thenable, attach a listener so that when it resolves, React
// attempts to re-render the boundary in the primary (pre-timeout) state.
const thenables: Set<Thenable> | null = (finishedWork.updateQueue: any);
if (thenables !== null) {
finishedWork.updateQueue = null;
let retry = retryTimedOutBoundary.bind(null, finishedWork);

if (enableSchedulerTracing) {
retry = Schedule_tracing_wrap(retry);
}

thenables.forEach(thenable => {
// Memoize using the boundary fiber to prevent redundant listeners.
let retryCache: Set<Fiber> | void = thenable._reactRetryCache;
if (retryCache === undefined) {
retryCache = thenable._reactRetryCache = new Set();
} else if (
// Check both the fiber and its alternate. Only a single listener
// is needed per fiber pair.
retryCache.has(finishedWork) ||
retryCache.has((finishedWork.alternate: any))
) {
// Already attached a retry listener to this promise.
return;
}
retryCache.add(finishedWork);
thenable.then(retry, retry);
});
}

return;
}
case IncompleteClassComponent: {
Expand Down
8 changes: 5 additions & 3 deletions packages/react-reconciler/src/ReactFiberPendingPriority.js
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,10 @@ export function markCommittedPriorityLevels(
return;
}

if (earliestRemainingTime < root.latestPingedTime) {
root.latestPingedTime = NoWork;
}

// Let's see if the previous latest known pending level was just flushed.
const latestPendingTime = root.latestPendingTime;
if (latestPendingTime !== NoWork) {
Expand Down Expand Up @@ -209,10 +213,8 @@ export function markPingedPriorityLevel(
}

function clearPing(root, completedTime) {
// TODO: Track whether the root was pinged during the render phase. If so,
// we need to make sure we don't lose track of it.
const latestPingedTime = root.latestPingedTime;
if (latestPingedTime !== NoWork && latestPingedTime >= completedTime) {
if (latestPingedTime >= completedTime) {
root.latestPingedTime = NoWork;
}
}
Expand Down
93 changes: 35 additions & 58 deletions packages/react-reconciler/src/ReactFiberScheduler.js
Original file line number Diff line number Diff line change
Expand Up @@ -125,13 +125,8 @@ import {
computeAsyncExpiration,
computeInteractiveExpiration,
} from './ReactFiberExpirationTime';
import {ConcurrentMode, ProfileMode, NoContext} from './ReactTypeOfMode';
import {
enqueueUpdate,
resetCurrentlyProcessingQueue,
ForceUpdate,
createUpdate,
} from './ReactUpdateQueue';
import {ConcurrentMode, ProfileMode} from './ReactTypeOfMode';
import {enqueueUpdate, resetCurrentlyProcessingQueue} from './ReactUpdateQueue';
import {createCapturedValue} from './ReactCapturedValue';
import {
isContextProvider as isLegacyContextProvider,
Expand Down Expand Up @@ -174,6 +169,8 @@ import {Dispatcher, DispatcherWithoutHooks} from './ReactFiberDispatcher';

export type Thenable = {
then(resolve: () => mixed, reject?: () => mixed): mixed,
_reactPingCache: Map<FiberRoot, Set<ExpirationTime>> | void,
_reactRetryCache: Set<Fiber> | void,
};

const {ReactCurrentOwner} = ReactSharedInternals;
Expand Down Expand Up @@ -1646,62 +1643,41 @@ function renderDidError() {
nextRenderDidError = true;
}

function retrySuspendedRoot(
root: FiberRoot,
boundaryFiber: Fiber,
sourceFiber: Fiber,
suspendedTime: ExpirationTime,
) {
let retryTime;

if (isPriorityLevelSuspended(root, suspendedTime)) {
// Ping at the original level
retryTime = suspendedTime;

markPingedPriorityLevel(root, retryTime);
function pingSuspendedRoot(root: FiberRoot, pingTime: ExpirationTime) {
// A promise that previously suspended React from committing has resolved.
// If React is still suspended, try again at the previous level (pingTime).
if (nextRoot !== null && nextRenderExpirationTime === pingTime) {
// Received a ping at the same priority level at which we're currently
// rendering. Restart from the root.
nextRoot = null;
} else {
// Suspense already timed out. Compute a new expiration time
const currentTime = requestCurrentTime();
retryTime = computeExpirationForFiber(currentTime, boundaryFiber);
markPendingPriorityLevel(root, retryTime);
}

// TODO: If the suspense fiber has already rendered the primary children
// without suspending (that is, all of the promises have already resolved),
// we should not trigger another update here. One case this happens is when
// we are in sync mode and a single promise is thrown both on initial render
// and on update; we attach two .then(retrySuspendedRoot) callbacks and each
// one performs Sync work, rerendering the Suspense.

if ((boundaryFiber.mode & ConcurrentMode) !== NoContext) {
if (root === nextRoot && nextRenderExpirationTime === suspendedTime) {
// Received a ping at the same priority level at which we're currently
// rendering. Restart from the root.
nextRoot = null;
// Confirm that the root is still suspended at this level. Otherwise exit.
if (isPriorityLevelSuspended(root, pingTime)) {
// Ping at the original level
markPingedPriorityLevel(root, pingTime);
const rootExpirationTime = root.expirationTime;
if (rootExpirationTime !== NoWork) {
requestWork(root, rootExpirationTime);
}
}
}
}

scheduleWorkToRoot(boundaryFiber, retryTime);
if ((boundaryFiber.mode & ConcurrentMode) === NoContext) {
// Outside of concurrent mode, we must schedule an update on the source
// fiber, too, since it already committed in an inconsistent state and
// therefore does not have any pending work.
scheduleWorkToRoot(sourceFiber, retryTime);
const sourceTag = sourceFiber.tag;
if (sourceTag === ClassComponent && sourceFiber.stateNode !== null) {
// When we try rendering again, we should not reuse the current fiber,
// since it's known to be in an inconsistent state. Use a force updte to
// prevent a bail out.
const update = createUpdate(retryTime);
update.tag = ForceUpdate;
enqueueUpdate(sourceFiber, update);
function retryTimedOutBoundary(boundaryFiber: Fiber) {
// The boundary fiber (a Suspense component) previously timed out and was
// rendered in its fallback state. One of the promises that suspended it has
// resolved, which means at least part of the tree was likely unblocked. Try
// rendering again, at a new expiration time.
const currentTime = requestCurrentTime();
const retryTime = computeExpirationForFiber(currentTime, boundaryFiber);
const root = scheduleWorkToRoot(boundaryFiber, retryTime);
if (root !== null) {
markPendingPriorityLevel(root, retryTime);
const rootExpirationTime = root.expirationTime;
if (rootExpirationTime !== NoWork) {
requestWork(root, rootExpirationTime);
}
}

const rootExpirationTime = root.expirationTime;
if (rootExpirationTime !== NoWork) {
requestWork(root, rootExpirationTime);
}
}

function scheduleWorkToRoot(fiber: Fiber, expirationTime): FiberRoot | null {
Expand Down Expand Up @@ -2550,7 +2526,8 @@ export {
onUncaughtError,
renderDidSuspend,
renderDidError,
retrySuspendedRoot,
pingSuspendedRoot,
retryTimedOutBoundary,
markLegacyErrorBoundaryAsFailed,
isAlreadyFailedLegacyErrorBoundary,
scheduleWork,
Expand Down
5 changes: 1 addition & 4 deletions packages/react-reconciler/src/ReactFiberSuspenseComponent.js
Original file line number Diff line number Diff line change
Expand Up @@ -14,10 +14,7 @@ export type SuspenseState = {|
timedOutAt: ExpirationTime,
|};

export function shouldCaptureSuspense(
current: Fiber | null,
workInProgress: Fiber,
): boolean {
export function shouldCaptureSuspense(workInProgress: Fiber): boolean {
// In order to capture, the Suspense component must have a fallback prop.
if (workInProgress.memoizedProps.fallback === undefined) {
return false;
Expand Down
75 changes: 50 additions & 25 deletions packages/react-reconciler/src/ReactFiberUnwindWork.js
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,8 @@ import {
enqueueCapturedUpdate,
createUpdate,
CaptureUpdate,
ForceUpdate,
enqueueUpdate,
} from './ReactUpdateQueue';
import {logError} from './ReactFiberCommitWork';
import {getStackByFiberInDevAndProd} from './ReactCurrentFiber';
Expand All @@ -59,7 +61,7 @@ import {
onUncaughtError,
markLegacyErrorBoundaryAsFailed,
isAlreadyFailedLegacyErrorBoundary,
retrySuspendedRoot,
pingSuspendedRoot,
} from './ReactFiberScheduler';

import invariant from 'shared/invariant';
Expand Down Expand Up @@ -202,29 +204,18 @@ function throwException(
do {
if (
workInProgress.tag === SuspenseComponent &&
shouldCaptureSuspense(workInProgress.alternate, workInProgress)
shouldCaptureSuspense(workInProgress)
) {
// Found the nearest boundary.

// If the boundary is not in concurrent mode, we should not suspend, and
// likewise, when the promise resolves, we should ping synchronously.
const pingTime =
(workInProgress.mode & ConcurrentMode) === NoEffect
? Sync
: renderExpirationTime;

// Attach a listener to the promise to "ping" the root and retry.
let onResolveOrReject = retrySuspendedRoot.bind(
null,
root,
workInProgress,
sourceFiber,
pingTime,
);
if (enableSchedulerTracing) {
onResolveOrReject = Schedule_tracing_wrap(onResolveOrReject);
// Stash the promise on the boundary fiber. If the boundary times out, we'll
// attach another listener to flip the boundary back to its normal state.
const thenables: Set<Thenable> = (workInProgress.updateQueue: any);
if (thenables === null) {
workInProgress.updateQueue = (new Set([thenable]): any);
} else {
thenables.add(thenable);
}
thenable.then(onResolveOrReject, onResolveOrReject);

// If the boundary is outside of concurrent mode, we should *not*
// suspend the commit. Pretend as if the suspended component rendered
Expand All @@ -243,18 +234,25 @@ function throwException(
sourceFiber.effectTag &= ~(LifecycleEffectMask | Incomplete);

if (sourceFiber.tag === ClassComponent) {
const current = sourceFiber.alternate;
if (current === null) {
const currentSourceFiber = sourceFiber.alternate;
if (currentSourceFiber === null) {
// This is a new mount. Change the tag so it's not mistaken for a
// completed class component. For example, we should not call
// componentWillUnmount if it is deleted.
sourceFiber.tag = IncompleteClassComponent;
} else {
// When we try rendering again, we should not reuse the current fiber,
// since it's known to be in an inconsistent state. Use a force updte to
// prevent a bail out.
const update = createUpdate(Sync);
update.tag = ForceUpdate;
enqueueUpdate(sourceFiber, update);
}
}

// The source fiber did not complete. Mark it with the current
// render priority to indicate that it still has pending work.
sourceFiber.expirationTime = renderExpirationTime;
// The source fiber did not complete. Mark it with Sync priority to
// indicate that it still has pending work.
sourceFiber.expirationTime = Sync;

// Exit without suspending.
return;
Expand All @@ -263,6 +261,33 @@ function throwException(
// Confirmed that the boundary is in a concurrent mode tree. Continue
// with the normal suspend path.

// Attach a listener to the promise to "ping" the root and retry. But
// only if one does not already exist for the current render expiration
// time (which acts like a "thread ID" here).
let pingCache: Map<FiberRoot, Set<ExpirationTime>> | void =
thenable._reactPingCache;
let threadIDs;
if (pingCache === undefined) {
pingCache = thenable._reactPingCache = new Map();
threadIDs = new Set();
pingCache.set(root, threadIDs);
} else {
threadIDs = pingCache.get(root);
if (threadIDs === undefined) {
threadIDs = new Set();
pingCache.set(root, threadIDs);
}
}
if (!threadIDs.has(renderExpirationTime)) {
// Memoize using the thread ID to prevent redundant listeners.
threadIDs.add(renderExpirationTime);
let ping = pingSuspendedRoot.bind(null, root, renderExpirationTime);
if (enableSchedulerTracing) {
ping = Schedule_tracing_wrap(ping);
}
thenable.then(ping, ping);
}

let absoluteTimeoutMs;
if (earliestTimeoutMs === -1) {
// If no explicit threshold is given, default to an abitrarily large
Expand Down
Loading

0 comments on commit 53e8639

Please sign in to comment.