Skip to content

Commit 8256781

Browse files
authored
Throttle retries even if everything has loaded (#26611)
If a Suspense fallback is shown, and the data finishes loading really quickly after that, we throttle the content from appearing for 500ms to reduce thrash. This already works for successive fallback states (like if one fallback is nested inside another) but it wasn't being applied to the final step in the sequence: if there were no more unresolved Suspense boundaries in the tree, the content would appear immediately. This fixes the throttling behavior so that it applies to all renders that are the result of suspended data being loaded. (Our internal jargon term for this is a "retry".)
1 parent 72c890e commit 8256781

File tree

2 files changed

+70
-152
lines changed

2 files changed

+70
-152
lines changed

packages/react-reconciler/src/ReactFiberWorkLoop.js

Lines changed: 66 additions & 92 deletions
Original file line numberDiff line numberDiff line change
@@ -1085,108 +1085,87 @@ function finishConcurrentRender(
10851085
finishedWork: Fiber,
10861086
lanes: Lanes,
10871087
) {
1088+
// TODO: The fact that most of these branches are identical suggests that some
1089+
// of the exit statuses are not best modeled as exit statuses and should be
1090+
// tracked orthogonally.
10881091
switch (exitStatus) {
10891092
case RootInProgress:
10901093
case RootFatalErrored: {
10911094
throw new Error('Root did not complete. This is a bug in React.');
10921095
}
1093-
case RootErrored: {
1094-
// We should have already attempted to retry this tree. If we reached
1095-
// this point, it errored again. Commit it.
1096-
commitRootWhenReady(
1097-
root,
1098-
finishedWork,
1099-
workInProgressRootRecoverableErrors,
1100-
workInProgressTransitions,
1101-
lanes,
1102-
);
1103-
break;
1104-
}
1105-
case RootSuspended: {
1106-
markRootSuspended(root, lanes);
1107-
1108-
// We have an acceptable loading state. We need to figure out if we
1109-
// should immediately commit it or wait a bit.
1110-
1111-
if (
1112-
includesOnlyRetries(lanes) &&
1113-
// do not delay if we're inside an act() scope
1114-
!shouldForceFlushFallbacksInDEV()
1115-
) {
1116-
// This render only included retries, no updates. Throttle committing
1117-
// retries so that we don't show too many loading states too quickly.
1118-
const msUntilTimeout =
1119-
globalMostRecentFallbackTime + FALLBACK_THROTTLE_MS - now();
1120-
// Don't bother with a very short suspense time.
1121-
if (msUntilTimeout > 10) {
1122-
const nextLanes = getNextLanes(root, NoLanes);
1123-
if (nextLanes !== NoLanes) {
1124-
// There's additional work on this root.
1125-
break;
1126-
}
1127-
1128-
// The render is suspended, it hasn't timed out, and there's no
1129-
// lower priority work to do. Instead of committing the fallback
1130-
// immediately, wait for more data to arrive.
1131-
root.timeoutHandle = scheduleTimeout(
1132-
commitRootWhenReady.bind(
1133-
null,
1134-
root,
1135-
finishedWork,
1136-
workInProgressRootRecoverableErrors,
1137-
workInProgressTransitions,
1138-
lanes,
1139-
),
1140-
msUntilTimeout,
1141-
);
1142-
break;
1143-
}
1144-
}
1145-
// The work expired. Commit immediately.
1146-
commitRootWhenReady(
1147-
root,
1148-
finishedWork,
1149-
workInProgressRootRecoverableErrors,
1150-
workInProgressTransitions,
1151-
lanes,
1152-
);
1153-
break;
1154-
}
11551096
case RootSuspendedWithDelay: {
1156-
markRootSuspended(root, lanes);
1157-
11581097
if (includesOnlyTransitions(lanes)) {
11591098
// This is a transition, so we should exit without committing a
11601099
// placeholder and without scheduling a timeout. Delay indefinitely
11611100
// until we receive more data.
1162-
break;
1101+
markRootSuspended(root, lanes);
1102+
return;
11631103
}
1164-
11651104
// Commit the placeholder.
1166-
commitRootWhenReady(
1167-
root,
1168-
finishedWork,
1169-
workInProgressRootRecoverableErrors,
1170-
workInProgressTransitions,
1171-
lanes,
1172-
);
11731105
break;
11741106
}
1107+
case RootErrored:
1108+
case RootSuspended:
11751109
case RootCompleted: {
1176-
// The work completed.
1177-
commitRootWhenReady(
1178-
root,
1179-
finishedWork,
1180-
workInProgressRootRecoverableErrors,
1181-
workInProgressTransitions,
1182-
lanes,
1183-
);
11841110
break;
11851111
}
11861112
default: {
11871113
throw new Error('Unknown root exit status.');
11881114
}
11891115
}
1116+
1117+
if (shouldForceFlushFallbacksInDEV()) {
1118+
// We're inside an `act` scope. Commit immediately.
1119+
commitRoot(
1120+
root,
1121+
workInProgressRootRecoverableErrors,
1122+
workInProgressTransitions,
1123+
);
1124+
} else {
1125+
if (includesOnlyRetries(lanes)) {
1126+
// This render only included retries, no updates. Throttle committing
1127+
// retries so that we don't show too many loading states too quickly.
1128+
const msUntilTimeout =
1129+
globalMostRecentFallbackTime + FALLBACK_THROTTLE_MS - now();
1130+
1131+
// Don't bother with a very short suspense time.
1132+
if (msUntilTimeout > 10) {
1133+
markRootSuspended(root, lanes);
1134+
1135+
const nextLanes = getNextLanes(root, NoLanes);
1136+
if (nextLanes !== NoLanes) {
1137+
// There's additional work we can do on this root. We might as well
1138+
// attempt to work on that while we're suspended.
1139+
return;
1140+
}
1141+
1142+
// The render is suspended, it hasn't timed out, and there's no
1143+
// lower priority work to do. Instead of committing the fallback
1144+
// immediately, wait for more data to arrive.
1145+
// TODO: Combine retry throttling with Suspensey commits. Right now they
1146+
// run one after the other.
1147+
root.timeoutHandle = scheduleTimeout(
1148+
commitRootWhenReady.bind(
1149+
null,
1150+
root,
1151+
finishedWork,
1152+
workInProgressRootRecoverableErrors,
1153+
workInProgressTransitions,
1154+
lanes,
1155+
),
1156+
msUntilTimeout,
1157+
);
1158+
return;
1159+
}
1160+
}
1161+
commitRootWhenReady(
1162+
root,
1163+
finishedWork,
1164+
workInProgressRootRecoverableErrors,
1165+
workInProgressTransitions,
1166+
lanes,
1167+
);
1168+
}
11901169
}
11911170

11921171
function commitRootWhenReady(
@@ -1196,6 +1175,8 @@ function commitRootWhenReady(
11961175
transitions: Array<Transition> | null,
11971176
lanes: Lanes,
11981177
) {
1178+
// TODO: Combine retry throttling with Suspensey commits. Right now they run
1179+
// one after the other.
11991180
if (includesOnlyNonUrgentLanes(lanes)) {
12001181
// Before committing, ask the renderer whether the host tree is ready.
12011182
// If it's not, we'll wait until it notifies us.
@@ -1218,22 +1199,15 @@ function commitRootWhenReady(
12181199
// us that it's ready. This will be canceled if we start work on the
12191200
// root again.
12201201
root.cancelPendingCommit = schedulePendingCommit(
1221-
commitRoot.bind(
1222-
null,
1223-
root,
1224-
workInProgressRootRecoverableErrors,
1225-
workInProgressTransitions,
1226-
),
1202+
commitRoot.bind(null, root, recoverableErrors, transitions),
12271203
);
1204+
markRootSuspended(root, lanes);
12281205
return;
12291206
}
12301207
}
1208+
12311209
// Otherwise, commit immediately.
1232-
commitRoot(
1233-
root,
1234-
workInProgressRootRecoverableErrors,
1235-
workInProgressTransitions,
1236-
);
1210+
commitRoot(root, recoverableErrors, transitions);
12371211
}
12381212

12391213
function isRenderConsistentWithExternalStores(finishedWork: Fiber): boolean {

packages/react-reconciler/src/__tests__/ReactSuspenseWithNoopRenderer-test.js

Lines changed: 4 additions & 60 deletions
Original file line numberDiff line numberDiff line change
@@ -1016,49 +1016,6 @@ describe('ReactSuspenseWithNoopRenderer', () => {
10161016
expect(ReactNoop).toMatchRenderedOutput(<span prop="C" />);
10171017
});
10181018

1019-
// TODO: This test was written against the old Expiration Times
1020-
// implementation. It doesn't really test what it was intended to test
1021-
// anymore, because all updates to the same queue get entangled together.
1022-
// Even if they haven't expired. Consider either deleting or rewriting.
1023-
// @gate enableLegacyCache
1024-
it('flushes all expired updates in a single batch', async () => {
1025-
class Foo extends React.Component {
1026-
componentDidUpdate() {
1027-
Scheduler.log('Commit: ' + this.props.text);
1028-
}
1029-
componentDidMount() {
1030-
Scheduler.log('Commit: ' + this.props.text);
1031-
}
1032-
render() {
1033-
return (
1034-
<Suspense fallback={<Text text="Loading..." />}>
1035-
<AsyncText text={this.props.text} />
1036-
</Suspense>
1037-
);
1038-
}
1039-
}
1040-
1041-
ReactNoop.render(<Foo text="" />);
1042-
ReactNoop.expire(1000);
1043-
jest.advanceTimersByTime(1000);
1044-
ReactNoop.render(<Foo text="go" />);
1045-
ReactNoop.expire(1000);
1046-
jest.advanceTimersByTime(1000);
1047-
ReactNoop.render(<Foo text="good" />);
1048-
ReactNoop.expire(1000);
1049-
jest.advanceTimersByTime(1000);
1050-
ReactNoop.render(<Foo text="goodbye" />);
1051-
1052-
await waitForAll(['Suspend! [goodbye]', 'Loading...', 'Commit: goodbye']);
1053-
expect(ReactNoop).toMatchRenderedOutput(<span prop="Loading..." />);
1054-
1055-
await resolveText('goodbye');
1056-
expect(ReactNoop).toMatchRenderedOutput(<span prop="Loading..." />);
1057-
1058-
await waitForAll(['goodbye']);
1059-
expect(ReactNoop).toMatchRenderedOutput(<span prop="goodbye" />);
1060-
});
1061-
10621019
// @gate enableLegacyCache
10631020
it('a suspended update that expires', async () => {
10641021
// Regression test. This test used to fall into an infinite loop.
@@ -1780,7 +1737,7 @@ describe('ReactSuspenseWithNoopRenderer', () => {
17801737
});
17811738

17821739
// @gate enableLegacyCache
1783-
it('does suspend if a fallback has been shown for a short time', async () => {
1740+
it('throttles content from appearing if a fallback was shown recently', async () => {
17841741
function Foo() {
17851742
Scheduler.log('Foo');
17861743
return (
@@ -1822,23 +1779,10 @@ describe('ReactSuspenseWithNoopRenderer', () => {
18221779
await resolveText('B');
18231780
expect(ReactNoop).toMatchRenderedOutput(<span prop="Loading..." />);
18241781

1825-
// Restart and render the complete content.
1782+
// Restart and render the complete content. The tree will finish but we
1783+
// won't commit the result yet because the fallback appeared recently.
18261784
await waitForAll(['A', 'B']);
1827-
// TODO: Because this render was the result of a retry, and a fallback
1828-
// was shown recently, we should suspend and remain on the fallback
1829-
// for little bit longer. We currently only do this if there's still
1830-
// remaining fallbacks in the tree, but we should do it for all retries.
1831-
//
1832-
// Correct output:
1833-
// expect(ReactNoop).toMatchRenderedOutput(<span prop="Loading..." />);
1834-
//
1835-
// Actual output:
1836-
expect(ReactNoop).toMatchRenderedOutput(
1837-
<>
1838-
<span prop="A" />
1839-
<span prop="B" />
1840-
</>,
1841-
);
1785+
expect(ReactNoop).toMatchRenderedOutput(<span prop="Loading..." />);
18421786
});
18431787
assertLog([]);
18441788
expect(ReactNoop).toMatchRenderedOutput(

0 commit comments

Comments
 (0)