Track Event Time as the Start Time for Suspense (#15358)
* Track the earliest event time in this render Rebase * Track the time of the fallback being shown as an event time When we switch back from fallback to content, we made progress and we track the time from when we showed the fallback in the first place as the last time we made progress. * Don't retry if synchronous * Only suspend when we switch to fallback mode This ensures that we don't resuspend unnecessarily if we're just retrying the same exact boundary again. We can still unnecessarily suspend for nested boundaries. * Rename timedOutAt to fallbackExpirationTime * Account for suspense in devtools suspense test
Sebastian Markbåge committed
Apr 9, 2019 at 18:59 UTC
4c78ac0b9df88edec73492f92f09d5438dd74c4d
14 files changed
+200
-228
packages/react-debug-tools/src/__tests__/ReactDevToolsHooksIntegration-test.js
+3
@@ -251,7 +251,10 @@ describe('React hooks DevTools integration', () => {
251
</div>,
252
{unstable_isConcurrent: true},
253
);
254
+
255
expect(Scheduler).toFlushAndYield([]);
256
+ // Ensure we timeout any suspense time.
257
+ jest.advanceTimersByTime(1000);
258
const fiber = renderer.root._currentFiber().child;
259
if (__DEV__) {
260
// First render was locked
packages/react-reconciler/src/ReactFiberBeginWork.js
+2
-1
@@ -1413,7 +1413,8 @@ function updateSuspenseComponent(
1413
// Something in this boundary's subtree already suspended. Switch to
1414
// rendering the fallback children.
1415
nextState = {
1416
- timedOutAt: nextState !== null ? nextState.timedOutAt : NoWork,
1416
+ fallbackExpirationTime:
1417
+ nextState !== null ? nextState.fallbackExpirationTime : NoWork,
1418
};
1419
nextDidTimeout = true;
1420
workInProgress.effectTag &= ~DidCapture;
packages/react-reconciler/src/ReactFiberCommitWork.js
+10
-3
@@ -63,7 +63,10 @@ import invariant from 'shared/invariant';
63
import warningWithoutStack from 'shared/warningWithoutStack';
64
import warning from 'shared/warning';
65
66
-import {NoWork} from './ReactFiberExpirationTime';
66
+import {
67
+ NoWork,
68
+ computeAsyncExpirationNoBucket,
69
+} from './ReactFiberExpirationTime';
70
import {onCommitUnmount} from './ReactFiberDevToolsHook';
71
import {startPhaseTimer, stopPhaseTimer} from './ReactDebugFiberPerf';
72
import {getStackByFiberInDevAndProd} from './ReactCurrentFiber';
@@ -1272,11 +1275,15 @@ function commitSuspenseComponent(finishedWork: Fiber) {
1275
} else {
1276
newDidTimeout = true;
1277
primaryChildParent = finishedWork.child;
1275
- if (newState.timedOutAt === NoWork) {
1278
+ if (newState.fallbackExpirationTime === NoWork) {
1279
// If the children had not already timed out, record the time.
1280
// This is used to compute the elapsed time during subsequent
1281
// attempts to render the children.
1279
- newState.timedOutAt = requestCurrentTime();
1282
+ // We model this as a normal pri expiration time since that's
1283
+ // how we infer start time for updates.
1284
+ newState.fallbackExpirationTime = computeAsyncExpirationNoBucket(
1285
+ requestCurrentTime(),
1286
+ );
1287
}
1288
}
1289
packages/react-reconciler/src/ReactFiberCompleteWork.js
+47
-19
@@ -18,6 +18,7 @@ import type {
18
ChildSet,
19
} from './ReactFiberHostConfig';
20
import type {ReactEventComponentInstance} from 'shared/ReactTypes';
21
+import type {SuspenseState} from './ReactFiberSuspenseComponent';
22
23
import {
24
IndeterminateComponent,
@@ -42,6 +43,7 @@ import {
43
EventComponent,
44
EventTarget,
45
} from 'shared/ReactWorkTags';
46
+import {ConcurrentMode, NoContext} from './ReactTypeOfMode';
47
import {
48
Placement,
49
Ref,
@@ -92,6 +94,7 @@ import {
94
enableSuspenseServerRenderer,
95
enableEventAPI,
96
} from 'shared/ReactFeatureFlags';
97
+import {markRenderEventTime, renderDidSuspend} from './ReactFiberScheduler';
98
99
function markUpdate(workInProgress: Fiber) {
100
// Tag the fiber with an update effect. This turns a Placement into
@@ -665,7 +668,7 @@ function completeWork(
668
case ForwardRef:
669
break;
670
case SuspenseComponent: {
668
- const nextState = workInProgress.memoizedState;
671
+ const nextState: null | SuspenseState = workInProgress.memoizedState;
672
if ((workInProgress.effectTag & DidCapture) !== NoEffect) {
673
// Something suspended. Re-render with the fallback children.
674
workInProgress.expirationTime = renderExpirationTime;
@@ -674,34 +677,58 @@ function completeWork(
677
}
678
679
const nextDidTimeout = nextState !== null;
677
- const prevDidTimeout = current !== null && current.memoizedState !== null;
678
-
680
+ let prevDidTimeout = false;
681
if (current === null) {
682
// In cases where we didn't find a suitable hydration boundary we never
683
// downgraded this to a DehydratedSuspenseComponent, but we still need to
684
// pop the hydration state since we might be inside the insertion tree.
685
popHydrationState(workInProgress);
684
- } else if (!nextDidTimeout && prevDidTimeout) {
685
- // We just switched from the fallback to the normal children. Delete
686
- // the fallback.
687
- // TODO: Would it be better to store the fallback fragment on
688
- // the stateNode during the begin phase?
689
- const currentFallbackChild: Fiber | null = (current.child: any).sibling;
690
- if (currentFallbackChild !== null) {
691
- // Deletions go at the beginning of the return fiber's effect list
692
- const first = workInProgress.firstEffect;
693
- if (first !== null) {
694
- workInProgress.firstEffect = currentFallbackChild;
695
- currentFallbackChild.nextEffect = first;
696
- } else {
697
- workInProgress.firstEffect = workInProgress.lastEffect = currentFallbackChild;
698
- currentFallbackChild.nextEffect = null;
686
+ } else {
687
+ const prevState: null | SuspenseState = current.memoizedState;
688
+ prevDidTimeout = prevState !== null;
689
+ if (!nextDidTimeout && prevState !== null) {
690
+ // We just switched from the fallback to the normal children.
691
+
692
+ // Mark the event time of the switching from fallback to normal children,
693
+ // based on the start of when we first showed the fallback. This time
694
+ // was given a normal pri expiration time at the time it was shown.
695
+ const fallbackExpirationTimeExpTime: ExpirationTime =
696
+ prevState.fallbackExpirationTime;
697
+ markRenderEventTime(fallbackExpirationTimeExpTime);
698
+
699
+ // Delete the fallback.
700
+ // TODO: Would it be better to store the fallback fragment on
701
+ // the stateNode during the begin phase?
702
+ const currentFallbackChild: Fiber | null = (current.child: any)
703
+ .sibling;
704
+ if (currentFallbackChild !== null) {
705
+ // Deletions go at the beginning of the return fiber's effect list
706
+ const first = workInProgress.firstEffect;
707
+ if (first !== null) {
708
+ workInProgress.firstEffect = currentFallbackChild;
709
+ currentFallbackChild.nextEffect = first;
710
+ } else {
711
+ workInProgress.firstEffect = workInProgress.lastEffect = currentFallbackChild;
712
+ currentFallbackChild.nextEffect = null;
713
+ }
714
+ currentFallbackChild.effectTag = Deletion;
715
}
700
- currentFallbackChild.effectTag = Deletion;
716
+ }
717
+ }
718
+
719
+ if (nextDidTimeout && !prevDidTimeout) {
720
+ // If this subtreee is running in concurrent mode we can suspend,
721
+ // otherwise we won't suspend.
722
+ // TODO: This will still suspend a synchronous tree if anything
723
+ // in the concurrent tree already suspended during this render.
724
+ // This is a known bug.
725
+ if ((workInProgress.mode & ConcurrentMode) !== NoContext) {
726
+ renderDidSuspend();
727
}
728
}
729
730
if (supportsPersistence) {
731
+ // TODO: Only schedule updates if not prevDidTimeout.
732
if (nextDidTimeout) {
733
// If this boundary just timed out, schedule an effect to attach a
734
// retry listener to the proimse. This flag is also used to hide the
@@ -710,6 +737,7 @@ function completeWork(
737
}
738
}
739
if (supportsMutation) {
740
+ // TODO: Only schedule updates if these values are non equal, i.e. it changed.
741
if (nextDidTimeout || prevDidTimeout) {
742
// If this boundary just timed out, schedule an effect to attach a
743
// retry listener to the proimse. This flag is also used to hide the
packages/react-reconciler/src/ReactFiberExpirationTime.js
+8
@@ -70,6 +70,14 @@ export function computeAsyncExpiration(
70
);
71
}
72
73
+// Same as computeAsyncExpiration but without the bucketing logic. This is
74
+// used to compute timestamps instead of actual expiration times.
75
+export function computeAsyncExpirationNoBucket(
76
+ currentTime: ExpirationTime,
77
+): ExpirationTime {
78
+ return currentTime - LOW_PRIORITY_EXPIRATION / UNIT_SIZE;
79
+}
80
+
81
// We intentionally set a higher expiration time for interactive updates in
82
// dev than in production.
83
//
packages/react-reconciler/src/ReactFiberHooks.js
+11
@@ -34,6 +34,7 @@ import {
34
flushPassiveEffects,
35
requestCurrentTime,
36
warnIfNotCurrentlyActingUpdatesInDev,
37
+ markRenderEventTime,
38
} from './ReactFiberScheduler';
39
40
import invariant from 'shared/invariant';
@@ -718,6 +719,16 @@ function updateReducer<S, I, A>(
719
remainingExpirationTime = updateExpirationTime;
720
}
721
} else {
722
+ // This update does have sufficient priority.
723
+
724
+ // Mark the event time of this update as relevant to this render pass.
725
+ // TODO: This should ideally use the true event time of this update rather than
726
+ // its priority which is a derived and not reverseable value.
727
+ // TODO: We should skip this update if it was already committed but currently
728
+ // we have no way of detecting the difference between a committed and suspended
729
+ // update here.
730
+ markRenderEventTime(updateExpirationTime);
731
+
732
// Process this update.
733
if (update.eagerReducer === reducer) {
734
// If this update was processed eagerly, and its reducer matches the
packages/react-reconciler/src/ReactFiberPendingPriority.js
-17
@@ -219,23 +219,6 @@ function clearPing(root, completedTime) {
219
}
220
}
221
222
-export function findEarliestOutstandingPriorityLevel(
223
- root: FiberRoot,
224
- renderExpirationTime: ExpirationTime,
225
-): ExpirationTime {
226
- let earliestExpirationTime = renderExpirationTime;
227
-
228
- const earliestPendingTime = root.earliestPendingTime;
229
- const earliestSuspendedTime = root.earliestSuspendedTime;
230
- if (earliestPendingTime > earliestExpirationTime) {
231
- earliestExpirationTime = earliestPendingTime;
232
- }
233
- if (earliestSuspendedTime > earliestExpirationTime) {
234
- earliestExpirationTime = earliestSuspendedTime;
235
- }
236
- return earliestExpirationTime;
237
-}
238
-
222
export function didExpireAtExpirationTime(
223
root: FiberRoot,
224
currentTime: ExpirationTime,
packages/react-reconciler/src/ReactFiberScheduler.js
+5
-5
@@ -14,6 +14,7 @@ import {
14
computeExpirationForFiber as computeExpirationForFiber_old,
15
captureCommitPhaseError as captureCommitPhaseError_old,
16
onUncaughtError as onUncaughtError_old,
17
+ markRenderEventTime as markRenderEventTime_old,
18
renderDidSuspend as renderDidSuspend_old,
19
renderDidError as renderDidError_old,
20
pingSuspendedRoot as pingSuspendedRoot_old,
@@ -34,7 +35,6 @@ import {
35
computeUniqueAsyncExpiration as computeUniqueAsyncExpiration_old,
36
flushPassiveEffects as flushPassiveEffects_old,
37
warnIfNotCurrentlyActingUpdatesInDev as warnIfNotCurrentlyActingUpdatesInDev_old,
37
- inferStartTimeFromExpirationTime as inferStartTimeFromExpirationTime_old,
38
} from './ReactFiberScheduler.old';
39
40
import {
@@ -42,6 +42,7 @@ import {
42
computeExpirationForFiber as computeExpirationForFiber_new,
43
captureCommitPhaseError as captureCommitPhaseError_new,
44
onUncaughtError as onUncaughtError_new,
45
+ markRenderEventTime as markRenderEventTime_new,
46
renderDidSuspend as renderDidSuspend_new,
47
renderDidError as renderDidError_new,
48
pingSuspendedRoot as pingSuspendedRoot_new,
@@ -62,7 +63,6 @@ import {
63
computeUniqueAsyncExpiration as computeUniqueAsyncExpiration_new,
64
flushPassiveEffects as flushPassiveEffects_new,
65
warnIfNotCurrentlyActingUpdatesInDev as warnIfNotCurrentlyActingUpdatesInDev_new,
65
- inferStartTimeFromExpirationTime as inferStartTimeFromExpirationTime_new,
66
} from './ReactFiberScheduler.new';
67
68
export const requestCurrentTime = enableNewScheduler
@@ -77,6 +77,9 @@ export const captureCommitPhaseError = enableNewScheduler
77
export const onUncaughtError = enableNewScheduler
78
? onUncaughtError_new
79
: onUncaughtError_old;
80
+export const markRenderEventTime = enableNewScheduler
81
+ ? markRenderEventTime_new
82
+ : markRenderEventTime_old;
83
export const renderDidSuspend = enableNewScheduler
84
? renderDidSuspend_new
85
: renderDidSuspend_old;
@@ -133,9 +136,6 @@ export const flushPassiveEffects = enableNewScheduler
136
export const warnIfNotCurrentlyActingUpdatesInDev = enableNewScheduler
137
? warnIfNotCurrentlyActingUpdatesInDev_new
138
: warnIfNotCurrentlyActingUpdatesInDev_old;
136
-export const inferStartTimeFromExpirationTime = enableNewScheduler
137
- ? inferStartTimeFromExpirationTime_new
138
- : inferStartTimeFromExpirationTime_old;
139
140
export type Thenable = {
141
then(resolve: () => mixed, reject?: () => mixed): void | Thenable,
packages/react-reconciler/src/ReactFiberScheduler.new.js
+52
-59
@@ -194,7 +194,11 @@ let workInProgress: Fiber | null = null;
194
let renderExpirationTime: ExpirationTime = NoWork;
195
// Whether to root completed, errored, suspended, etc.
196
let workInProgressRootExitStatus: RootExitStatus = RootIncomplete;
197
-let workInProgressRootAbsoluteTimeoutMs: number = -1;
197
+// Most recent event time among processed updates during this render.
198
+// This is conceptually a time stamp but expressed in terms of an ExpirationTime
199
+// because we deal mostly with expiration times in the hot path, so this avoids
200
+// the conversion happening in the hot path.
201
+let workInProgressRootMostRecentEventTime: ExpirationTime = Sync;
202
203
let nextEffect: Fiber | null = null;
204
let hasUncaughtError = false;
@@ -678,7 +682,7 @@ function prepareFreshStack(root, expirationTime) {
682
workInProgress = createWorkInProgress(root.current, null, expirationTime);
683
renderExpirationTime = expirationTime;
684
workInProgressRootExitStatus = RootIncomplete;
681
- workInProgressRootAbsoluteTimeoutMs = -1;
685
+ workInProgressRootMostRecentEventTime = Sync;
686
687
if (__DEV__) {
688
ReactStrictModeWarnings.discardPendingWarnings();
@@ -878,26 +882,30 @@ function renderRoot(
882
return commitRoot.bind(null, root, expirationTime);
883
}
884
case RootSuspended: {
881
- const lastPendingTime = root.lastPendingTime;
882
- if (root.lastPendingTime < expirationTime) {
883
- // There's lower priority work. It might be unsuspended. Try rendering
884
- // at that level.
885
- return renderRoot.bind(null, root, lastPendingTime);
886
- }
885
if (!isSync) {
888
- const msUntilTimeout = computeMsUntilTimeout(
889
- root,
890
- workInProgressRootAbsoluteTimeoutMs,
891
- );
892
- if (msUntilTimeout > 0) {
893
- // The render is suspended, it hasn't timed out, and there's no lower
894
- // priority work to do. Instead of committing the fallback
895
- // immediately, wait for more data to arrive.
896
- root.timeoutHandle = scheduleTimeout(
897
- commitRoot.bind(null, root, expirationTime),
898
- msUntilTimeout,
886
+ const lastPendingTime = root.lastPendingTime;
887
+ if (root.lastPendingTime < expirationTime) {
888
+ // There's lower priority work. It might be unsuspended. Try rendering
889
+ // at that level.
890
+ return renderRoot.bind(null, root, lastPendingTime);
891
+ }
892
+ // If workInProgressRootMostRecentEventTime is Sync, that means we didn't
893
+ // track any event times. That can happen if we retried but nothing switched
894
+ // from fallback to content. There's no reason to delay doing no work.
895
+ if (workInProgressRootMostRecentEventTime !== Sync) {
896
+ const msUntilTimeout = computeMsUntilTimeout(
897
+ workInProgressRootMostRecentEventTime,
898
);
900
- return null;
899
+ if (msUntilTimeout > 0) {
900
+ // The render is suspended, it hasn't timed out, and there's no lower
901
+ // priority work to do. Instead of committing the fallback
902
+ // immediately, wait for more data to arrive.
903
+ root.timeoutHandle = scheduleTimeout(
904
+ commitRoot.bind(null, root, expirationTime),
905
+ msUntilTimeout,
906
+ );
907
+ return null;
908
+ }
909
}
910
}
911
// The work expired. Commit immediately.
@@ -913,20 +921,15 @@ function renderRoot(
921
}
922
}
923
916
-export function renderDidSuspend(
917
- root: FiberRoot,
918
- absoluteTimeoutMs: number,
919
- // TODO: Don't need this argument anymore
920
- suspendedTime: ExpirationTime,
921
-) {
922
- if (
923
- absoluteTimeoutMs >= 0 &&
924
- workInProgressRootAbsoluteTimeoutMs < absoluteTimeoutMs
925
- ) {
926
- workInProgressRootAbsoluteTimeoutMs = absoluteTimeoutMs;
927
- if (workInProgressRootExitStatus === RootIncomplete) {
928
- workInProgressRootExitStatus = RootSuspended;
929
- }
924
+export function markRenderEventTime(expirationTime: ExpirationTime): void {
925
+ if (expirationTime < workInProgressRootMostRecentEventTime) {
926
+ workInProgressRootMostRecentEventTime = expirationTime;
927
+ }
928
+}
929
+
930
+export function renderDidSuspend(): void {
931
+ if (workInProgressRootExitStatus === RootIncomplete) {
932
+ workInProgressRootExitStatus = RootSuspended;
933
}
934
}
935
@@ -939,6 +942,13 @@ export function renderDidError() {
942
}
943
}
944
945
+function inferTimeFromExpirationTime(expirationTime: ExpirationTime): number {
946
+ // We don't know exactly when the update was scheduled, but we can infer an
947
+ // approximate start time from the expiration time.
948
+ const earliestExpirationTimeMs = expirationTimeToMs(expirationTime);
949
+ return earliestExpirationTimeMs - LOW_PRIORITY_EXPIRATION + initialTimeMs;
950
+}
951
+
952
function workLoopSync() {
953
// Already timed out, so perform work without checking if we need to yield.
954
while (workInProgress !== null) {
@@ -1805,37 +1815,20 @@ export function resolveRetryThenable(boundaryFiber: Fiber, thenable: Thenable) {
1815
retryTimedOutBoundary(boundaryFiber);
1816
}
1817
1808
-export function inferStartTimeFromExpirationTime(
1809
- root: FiberRoot,
1810
- expirationTime: ExpirationTime,
1811
-) {
1812
- // We don't know exactly when the update was scheduled, but we can infer an
1813
- // approximate start time from the expiration time.
1814
- const earliestExpirationTimeMs = expirationTimeToMs(root.firstPendingTime);
1815
- // TODO: Track this on the root instead. It's more accurate, doesn't rely on
1816
- // assumptions about priority, and isn't coupled to Scheduler details.
1817
- return earliestExpirationTimeMs - LOW_PRIORITY_EXPIRATION;
1818
-}
1819
-
1820
-function computeMsUntilTimeout(root, absoluteTimeoutMs) {
1818
+function computeMsUntilTimeout(mostRecentEventTime: ExpirationTime) {
1819
if (disableYielding) {
1820
// Timeout immediately when yielding is disabled.
1821
return 0;
1822
}
1823
1826
- // Find the earliest uncommitted expiration time in the tree, including
1827
- // work that is suspended. The timeout threshold cannot be longer than
1828
- // the overall expiration.
1829
- const earliestExpirationTimeMs = expirationTimeToMs(root.firstPendingTime);
1830
- if (earliestExpirationTimeMs < absoluteTimeoutMs) {
1831
- absoluteTimeoutMs = earliestExpirationTimeMs;
1832
- }
1824
+ const eventTimeMs: number = inferTimeFromExpirationTime(mostRecentEventTime);
1825
+ const currentTimeMs: number = now();
1826
+ const timeElapsed = currentTimeMs - eventTimeMs;
1827
1834
- // Subtract the current time from the absolute timeout to get the number
1835
- // of milliseconds until the timeout. In other words, convert an absolute
1836
- // timestamp to a relative time. This is the value that is passed
1837
- // to `setTimeout`.
1838
- let msUntilTimeout = absoluteTimeoutMs - now();
1828
+ // TODO: Account for the Just Noticeable Difference
1829
+ const timeoutMs = 150;
1830
+ const msUntilTimeout = timeoutMs - timeElapsed;
1831
+ // This is the value that is passed to `setTimeout`.
1832
return msUntilTimeout < 0 ? 0 : msUntilTimeout;
1833
}
1834
packages/react-reconciler/src/ReactFiberScheduler.old.js
+29
-49
@@ -91,7 +91,6 @@ import {
91
markPingedPriorityLevel,
92
hasLowerPriorityWork,
93
isPriorityLevelSuspended,
94
- findEarliestOutstandingPriorityLevel,
94
didExpireAtExpirationTime,
95
} from './ReactFiberPendingPriority';
96
import {
@@ -272,7 +271,8 @@ let nextUnitOfWork: Fiber | null = null;
271
let nextRoot: FiberRoot | null = null;
272
// The time at which we're currently rendering work.
273
let nextRenderExpirationTime: ExpirationTime = NoWork;
275
-let nextLatestAbsoluteTimeoutMs: number = -1;
274
+let mostRecentEventTime: ExpirationTime = Sync;
275
+let nextRenderDidSuspend: boolean = false;
276
let nextRenderDidError: boolean = false;
277
278
// The next fiber with an effect that we're currently committing.
@@ -399,7 +399,8 @@ function resetStack() {
399
400
nextRoot = null;
401
nextRenderExpirationTime = NoWork;
402
- nextLatestAbsoluteTimeoutMs = -1;
402
+ mostRecentEventTime = Sync;
403
+ nextRenderDidSuspend = false;
404
nextRenderDidError = false;
405
nextUnitOfWork = null;
406
}
@@ -1502,32 +1503,25 @@ function renderRoot(root: FiberRoot, isYieldy: boolean): void {
1503
}
1504
}
1505
1505
- if (isYieldy && nextLatestAbsoluteTimeoutMs !== -1) {
1506
+ // Check if we should suspend this commit.
1507
+ // If mostRecentEventTime is Sync, that means we didn't track any event
1508
+ // times. That can happen if we retried but nothing switched from fallback
1509
+ // to content. There's no reason to delay doing no work.
1510
+ if (isYieldy && nextRenderDidSuspend && mostRecentEventTime !== Sync) {
1511
// The tree was suspended.
1512
const suspendedExpirationTime = expirationTime;
1513
markSuspendedPriorityLevel(root, suspendedExpirationTime);
1514
1510
- // Find the earliest uncommitted expiration time in the tree, including
1511
- // work that is suspended. The timeout threshold cannot be longer than
1512
- // the overall expiration.
1513
- const earliestExpirationTime = findEarliestOutstandingPriorityLevel(
1514
- root,
1515
- expirationTime,
1515
+ const eventTimeMs: number = inferTimeFromExpirationTime(
1516
+ mostRecentEventTime,
1517
);
1517
- const earliestExpirationTimeMs = expirationTimeToMs(earliestExpirationTime);
1518
- if (earliestExpirationTimeMs < nextLatestAbsoluteTimeoutMs) {
1519
- nextLatestAbsoluteTimeoutMs = earliestExpirationTimeMs;
1520
- }
1521
-
1522
- // Subtract the current time from the absolute timeout to get the number
1523
- // of milliseconds until the timeout. In other words, convert an absolute
1524
- // timestamp to a relative time. This is the value that is passed
1525
- // to `setTimeout`.
1526
- const currentTimeMs = expirationTimeToMs(requestCurrentTime());
1527
- let msUntilTimeout = nextLatestAbsoluteTimeoutMs - currentTimeMs;
1528
- msUntilTimeout = msUntilTimeout < 0 ? 0 : msUntilTimeout;
1518
+ const currentTimeMs: number = now();
1519
+ const timeElapsed = currentTimeMs - eventTimeMs;
1520
1521
// TODO: Account for the Just Noticeable Difference
1522
+ const timeoutMs = 150;
1523
+ let msUntilTimeout = timeoutMs - timeElapsed;
1524
+ msUntilTimeout = msUntilTimeout < 0 ? 0 : msUntilTimeout;
1525
1526
const rootExpirationTime = root.expirationTime;
1527
onSuspend(
@@ -1662,41 +1656,27 @@ function computeExpirationForFiber(currentTime: ExpirationTime, fiber: Fiber) {
1656
return expirationTime;
1657
}
1658
1665
-function renderDidSuspend(
1666
- root: FiberRoot,
1667
- absoluteTimeoutMs: number,
1668
- suspendedTime: ExpirationTime,
1669
-) {
1670
- // Schedule the timeout.
1671
- if (
1672
- absoluteTimeoutMs >= 0 &&
1673
- nextLatestAbsoluteTimeoutMs < absoluteTimeoutMs
1674
- ) {
1675
- nextLatestAbsoluteTimeoutMs = absoluteTimeoutMs;
1659
+function markRenderEventTime(expirationTime: ExpirationTime): void {
1660
+ if (expirationTime < mostRecentEventTime) {
1661
+ mostRecentEventTime = expirationTime;
1662
}
1663
}
1664
1665
+function renderDidSuspend() {
1666
+ nextRenderDidSuspend = true;
1667
+}
1668
+
1669
function renderDidError() {
1670
nextRenderDidError = true;
1671
}
1672
1683
-function inferStartTimeFromExpirationTime(
1684
- root: FiberRoot,
1685
- expirationTime: ExpirationTime,
1686
-) {
1673
+function inferTimeFromExpirationTime(expirationTime: ExpirationTime) {
1674
// We don't know exactly when the update was scheduled, but we can infer an
1688
- // approximate start time from the expiration time. First, find the earliest
1689
- // uncommitted expiration time in the tree, including work that is suspended.
1690
- // Then subtract the offset used to compute an async update's expiration time.
1691
- // This will cause high priority (interactive) work to expire earlier than
1692
- // necessary, but we can account for this by adjusting for the Just
1693
- // Noticeable Difference.
1694
- const earliestExpirationTime = findEarliestOutstandingPriorityLevel(
1695
- root,
1696
- expirationTime,
1675
+ // approximate start time from the expiration time.
1676
+ const earliestExpirationTimeMs = expirationTimeToMs(expirationTime);
1677
+ return (
1678
+ earliestExpirationTimeMs - LOW_PRIORITY_EXPIRATION + originalStartTimeMs
1679
);
1698
- const earliestExpirationTimeMs = expirationTimeToMs(earliestExpirationTime);
1699
- return earliestExpirationTimeMs - LOW_PRIORITY_EXPIRATION;
1680
}
1681
1682
function pingSuspendedRoot(
@@ -2692,6 +2672,7 @@ export {
2672
computeExpirationForFiber,
2673
captureCommitPhaseError,
2674
onUncaughtError,
2675
+ markRenderEventTime,
2676
renderDidSuspend,
2677
renderDidError,
2678
pingSuspendedRoot,
@@ -2711,5 +2692,4 @@ export {
2692
flushInteractiveUpdates,
2693
computeUniqueAsyncExpiration,
2694
flushPassiveEffects,
2714
- inferStartTimeFromExpirationTime,
2695
};
packages/react-reconciler/src/ReactFiberSuspenseComponent.js
+1
-1
@@ -11,7 +11,7 @@ import type {Fiber} from './ReactFiber';
11
import type {ExpirationTime} from './ReactFiberExpirationTime';
12
13
export type SuspenseState = {|
14
- timedOutAt: ExpirationTime,
14
+ fallbackExpirationTime: ExpirationTime,
15
|};
16
17
export function shouldCaptureSuspense(workInProgress: Fiber): boolean {
packages/react-reconciler/src/ReactFiberUnwindWork.js
+5
-70
@@ -13,7 +13,6 @@ import type {ExpirationTime} from './ReactFiberExpirationTime';
13
import type {CapturedValue} from './ReactCapturedValue';
14
import type {Update} from './ReactUpdateQueue';
15
import type {Thenable} from './ReactFiberScheduler';
16
-import type {SuspenseState} from './ReactFiberSuspenseComponent';
16
17
import {unstable_wrap as Schedule_tracing_wrap} from 'scheduler/tracing';
18
import getComponentName from 'shared/getComponentName';
@@ -42,7 +41,7 @@ import {
41
enableSuspenseServerRenderer,
42
enableEventAPI,
43
} from 'shared/ReactFeatureFlags';
45
-import {ConcurrentMode} from './ReactTypeOfMode';
44
+import {ConcurrentMode, NoContext} from './ReactTypeOfMode';
45
import {shouldCaptureSuspense} from './ReactFiberSuspenseComponent';
46
47
import {createCapturedValue} from './ReactCapturedValue';
@@ -63,19 +62,17 @@ import {
62
} from './ReactFiberContext';
63
import {popProvider} from './ReactFiberNewContext';
64
import {
66
- renderDidSuspend,
65
renderDidError,
66
onUncaughtError,
67
markLegacyErrorBoundaryAsFailed,
68
isAlreadyFailedLegacyErrorBoundary,
69
pingSuspendedRoot,
70
resolveRetryThenable,
73
- inferStartTimeFromExpirationTime,
71
} from './ReactFiberScheduler';
72
73
import invariant from 'shared/invariant';
77
-import maxSigned31BitInt from './maxSigned31BitInt';
78
-import {Sync, expirationTimeToMs} from './ReactFiberExpirationTime';
74
+
75
+import {Sync} from './ReactFiberExpirationTime';
76
77
const PossiblyWeakSet = typeof WeakSet === 'function' ? WeakSet : Set;
78
const PossiblyWeakMap = typeof WeakMap === 'function' ? WeakMap : Map;
@@ -206,44 +203,8 @@ function throwException(
203
// This is a thenable.
204
const thenable: Thenable = (value: any);
205
209
- // Find the earliest timeout threshold of all the placeholders in the
210
- // ancestor path. We could avoid this traversal by storing the thresholds on
211
- // the stack, but we choose not to because we only hit this path if we're
212
- // IO-bound (i.e. if something suspends). Whereas the stack is used even in
213
- // the non-IO- bound case.
214
- let workInProgress = returnFiber;
215
- let earliestTimeoutMs = -1;
216
- let startTimeMs = -1;
217
- do {
218
- if (workInProgress.tag === SuspenseComponent) {
219
- const current = workInProgress.alternate;
220
- if (current !== null) {
221
- const currentState: SuspenseState | null = current.memoizedState;
222
- if (currentState !== null) {
223
- // Reached a boundary that already timed out. Do not search
224
- // any further.
225
- const timedOutAt = currentState.timedOutAt;
226
- startTimeMs = expirationTimeToMs(timedOutAt);
227
- // Do not search any further.
228
- break;
229
- }
230
- }
231
- const defaultSuspenseTimeout = 150;
232
- if (
233
- earliestTimeoutMs === -1 ||
234
- defaultSuspenseTimeout < earliestTimeoutMs
235
- ) {
236
- earliestTimeoutMs = defaultSuspenseTimeout;
237
- }
238
- }
239
- // If there is a DehydratedSuspenseComponent we don't have to do anything because
240
- // if something suspends inside it, we will simply leave that as dehydrated. It
241
- // will never timeout.
242
- workInProgress = workInProgress.return;
243
- } while (workInProgress !== null);
244
-
206
// Schedule the nearest Suspense to re-render the timed out view.
246
- workInProgress = returnFiber;
207
+ let workInProgress = returnFiber;
208
do {
209
if (
210
workInProgress.tag === SuspenseComponent &&
@@ -270,7 +231,7 @@ function throwException(
231
// Note: It doesn't matter whether the component that suspended was
232
// inside a concurrent mode tree. If the Suspense is outside of it, we
233
// should *not* suspend the commit.
273
- if ((workInProgress.mode & ConcurrentMode) === NoEffect) {
234
+ if ((workInProgress.mode & ConcurrentMode) === NoContext) {
235
workInProgress.effectTag |= DidCapture;
236
237
// We're going to commit this fiber even though it didn't complete.
@@ -308,32 +269,6 @@ function throwException(
269
270
attachPingListener(root, renderExpirationTime, thenable);
271
311
- let absoluteTimeoutMs;
312
- if (earliestTimeoutMs === -1) {
313
- // If no explicit threshold is given, default to an arbitrarily large
314
- // value. The actual size doesn't matter because the threshold for the
315
- // whole tree will be clamped to the expiration time.
316
- absoluteTimeoutMs = maxSigned31BitInt;
317
- } else {
318
- if (startTimeMs === -1) {
319
- // This suspend happened outside of any already timed-out
320
- // placeholders. We don't know exactly when the update was
321
- // scheduled, but we can infer an approximate start time based on
322
- // the expiration time and the priority.
323
- startTimeMs = inferStartTimeFromExpirationTime(
324
- root,
325
- renderExpirationTime,
326
- );
327
- }
328
- absoluteTimeoutMs = startTimeMs + earliestTimeoutMs;
329
- }
330
-
331
- // Mark the earliest timeout in the suspended fiber's ancestor path.
332
- // After completing the root, we'll take the largest of all the
333
- // suspended fiber's timeouts and use it to compute a timeout for the
334
- // whole tree.
335
- renderDidSuspend(root, absoluteTimeoutMs, renderExpirationTime);
336
-
272
workInProgress.effectTag |= ShouldCapture;
273
workInProgress.expirationTime = renderExpirationTime;
274
return;
packages/react-reconciler/src/ReactUpdateQueue.js
+12
-2
@@ -101,6 +101,7 @@ import {
101
} from 'shared/ReactFeatureFlags';
102
103
import {StrictMode} from './ReactTypeOfMode';
104
+import {markRenderEventTime} from './ReactFiberScheduler';
105
106
import invariant from 'shared/invariant';
107
import warningWithoutStack from 'shared/warningWithoutStack';
@@ -454,8 +455,17 @@ export function processUpdateQueue<State>(
455
newExpirationTime = updateExpirationTime;
456
}
457
} else {
457
- // This update does have sufficient priority. Process it and compute
458
- // a new result.
458
+ // This update does have sufficient priority.
459
+
460
+ // Mark the event time of this update as relevant to this render pass.
461
+ // TODO: This should ideally use the true event time of this update rather than
462
+ // its priority which is a derived and not reverseable value.
463
+ // TODO: We should skip this update if it was already committed but currently
464
+ // we have no way of detecting the difference between a committed and suspended
465
+ // update here.
466
+ markRenderEventTime(updateExpirationTime);
467
+
468
+ // Process it and compute a new result.
469
resultState = getStateFromUpdate(
470
workInProgress,
471
queue,
packages/react-reconciler/src/__tests__/ReactSuspensePlaceholder-test.internal.js
+15
-2
@@ -490,8 +490,16 @@ describe('ReactSuspensePlaceholder', () => {
490
expect(onRender.mock.calls[1][3]).toBe(15);
491
492
// Update again while timed out.
493
+ // Since this test was originally written we added an optimization to avoid
494
+ // suspending in the case that we already timed out. To simulate the old
495
+ // behavior, we add a different suspending boundary as a sibling.
496
ReactNoop.render(
494
- <App shouldSuspend={true} text="New" textRenderDuration={6} />,
497
+ <React.Fragment>
498
+ <App shouldSuspend={true} text="New" textRenderDuration={6} />
499
+ <Suspense fallback={null}>
500
+ <AsyncText ms={250} text="Sibling" fakeRenderDuration={1} />
501
+ </Suspense>
502
+ </React.Fragment>,
503
);
504
expect(Scheduler).toFlushAndYield([
505
'App',
@@ -499,18 +507,23 @@ describe('ReactSuspensePlaceholder', () => {
507
'Suspend! [Loaded]',
508
'New',
509
'Fallback',
510
+ 'Suspend! [Sibling]',
511
]);
512
expect(ReactNoop).toMatchRenderedOutput('Loading...');
513
expect(onRender).toHaveBeenCalledTimes(2);
514
515
// Resolve the pending promise.
516
jest.advanceTimersByTime(250);
508
- expect(Scheduler).toHaveYielded(['Promise resolved [Loaded]']);
517
+ expect(Scheduler).toHaveYielded([
518
+ 'Promise resolved [Loaded]',
519
+ 'Promise resolved [Sibling]',
520
+ ]);
521
expect(Scheduler).toFlushAndYield([
522
'App',
523
'Suspending',
524
'Loaded',
525
'New',
526
+ 'Sibling',
527
]);
528
expect(onRender).toHaveBeenCalledTimes(3);
529