@samitouri / QOS-React / commits / aa8266c4f7

Prepare placeholders before timing out (#13092)

* Prepare placeholders before timing out While a tree is suspended, prepare for the timeout by pre-rendering the placeholder state. This simplifies the implementation a bit because every render now results in a completed tree. * Suspend inside an already timed out Placeholder A component should be able to suspend inside an already timed out placeholder. The time at which the placeholder committed is used as the start time for a subsequent suspend. So, if a placeholder times out after 3 seconds, and an inner placeholder has a threshold of 2 seconds, the inner placeholder will not time out until 5 seconds total have elapsed.

Andrew Clark committed Jul 3, 2018 at 19:22 UTC aa8266c4f7da62788156bf0bdd95fd729c04388a
17 files changed +673 -561
packages/react-art/src/ReactARTHostConfig.js
+4
@@ -332,6 +332,10 @@ export function getChildHostContext() {
332 export const scheduleDeferredCallback = ReactScheduler.scheduleWork;
333 export const cancelDeferredCallback = ReactScheduler.cancelScheduledWork;
334
335 +export const scheduleTimeout = setTimeout;
336 +export const cancelTimeout = clearTimeout;
337 +export const noTimeout = -1;
338 +
339 export function shouldSetTextContent(type, props) {
340 return (
341 typeof props.children === 'string' || typeof props.children === 'number'
packages/react-dom/src/client/ReactDOMHostConfig.js
+6
@@ -45,6 +45,8 @@ type HostContextProd = string;
45 export type HostContext = HostContextDev | HostContextProd;
46 export type UpdatePayload = Array<mixed>;
47 export type ChildSet = void; // Unused
48 +export type TimeoutHandle = TimeoutID;
49 +export type NoTimeout = -1;
50
51 const {
52 createElement,
@@ -278,6 +280,10 @@ export const isPrimaryRenderer = true;
280 export const scheduleDeferredCallback = ReactScheduler.scheduleWork;
281 export const cancelDeferredCallback = ReactScheduler.cancelScheduledWork;
282
283 +export const scheduleTimeout = setTimeout;
284 +export const cancelTimeout = clearTimeout;
285 +export const noTimeout = -1;
286 +
287 // -------------------
288 // Mutation
289 // -------------------
packages/react-native-renderer/src/ReactFabricHostConfig.js
+7
@@ -66,6 +66,9 @@ export type HostContext = $ReadOnly<{|
66 |}>;
67 export type UpdatePayload = Object;
68
69 +export type TimeoutHandle = TimeoutID;
70 +export type NoTimeout = -1;
71 +
72 // TODO: Remove this conditional once all changes have propagated.
73 if (registerEventHandler) {
74 /**
@@ -324,6 +327,10 @@ export const scheduleDeferredCallback =
327 export const cancelDeferredCallback =
328 ReactNativeFrameScheduling.cancelDeferredCallback;
329
330 +export const scheduleTimeout = setTimeout;
331 +export const cancelTimeout = clearTimeout;
332 +export const noTimeout = -1;
333 +
334 // -------------------
335 // Persistence
336 // -------------------
packages/react-native-renderer/src/ReactNativeHostConfig.js
+7
@@ -42,6 +42,9 @@ export type HostContext = $ReadOnly<{|
42 export type UpdatePayload = Object; // Unused
43 export type ChildSet = void; // Unused
44
45 +export type TimeoutHandle = TimeoutID;
46 +export type NoTimeout = -1;
47 +
48 const UPDATE_SIGNAL = {};
49 if (__DEV__) {
50 Object.freeze(UPDATE_SIGNAL);
@@ -236,6 +239,10 @@ export const scheduleDeferredCallback =
239 export const cancelDeferredCallback =
240 ReactNativeFrameScheduling.cancelDeferredCallback;
241
242 +export const scheduleTimeout = setTimeout;
243 +export const cancelTimeout = clearTimeout;
244 +export const noTimeout = -1;
245 +
246 export function shouldDeprioritizeSubtree(type: string, props: Props): boolean {
247 return false;
248 }
packages/react-noop-renderer/src/createReactNoop.js
+4
@@ -182,6 +182,10 @@ function createReactNoop(reconciler: Function, useMutation: boolean) {
182 scheduledCallback = null;
183 },
184
185 + scheduleTimeout: setTimeout,
186 + cancelTimeout: clearTimeout,
187 + noTimeout: -1,
188 +
189 prepareForCommit(): void {},
190
191 resetAfterCommit(): void {},
packages/react-reconciler/src/ReactFiberBeginWork.js
+15 -3
@@ -708,7 +708,7 @@ function updateTimeoutComponent(current, workInProgress, renderExpirationTime) {
708 const nextProps = workInProgress.pendingProps;
709 const prevProps = workInProgress.memoizedProps;
710
711 - const prevDidTimeout = workInProgress.memoizedState;
711 + const prevDidTimeout = workInProgress.memoizedState === true;
712
713 // Check if we already attempted to render the normal state. If we did,
714 // and we timed out, render the placeholder state.
@@ -723,6 +723,16 @@ function updateTimeoutComponent(current, workInProgress, renderExpirationTime) {
723 return bailoutOnAlreadyFinishedWork(current, workInProgress);
724 }
725
726 + if (nextDidTimeout) {
727 + // If the timed-out view commits, schedule an update effect to record
728 + // the committed time.
729 + workInProgress.effectTag |= Update;
730 + } else {
731 + // The state node points to the time at which placeholder timed out.
732 + // We can clear it once we switch back to the normal children.
733 + workInProgress.stateNode = null;
734 + }
735 +
736 const render = nextProps.children;
737 const nextChildren = render(nextDidTimeout);
738 workInProgress.memoizedProps = nextProps;
@@ -785,8 +795,9 @@ function propagateContextChange<V>(
795 if (fiber.type === context && (observedBits & changedBits) !== 0) {
796 // Update the expiration time of all the ancestors, including
797 // the alternates.
798 +
799 let node = fiber;
789 - while (node !== null) {
800 + do {
801 const alternate = node.alternate;
802 if (
803 node.expirationTime === NoWork ||
@@ -812,7 +823,8 @@ function propagateContextChange<V>(
823 break;
824 }
825 node = node.return;
815 - }
826 + } while (node !== null);
827 +
828 // Don't scan deeper than a matching consumer. When we render the
829 // consumer, we'll continue scanning from that point. This way the
830 // scanning work is time-sliced.
packages/react-reconciler/src/ReactFiberCommitWork.js
+9 -3
@@ -19,7 +19,7 @@ import type {FiberRoot} from './ReactFiberRoot';
19 import type {ExpirationTime} from './ReactFiberExpirationTime';
20 import type {CapturedValue, CapturedError} from './ReactCapturedValue';
21
22 -import {enableProfilerTimer} from 'shared/ReactFeatureFlags';
22 +import {enableProfilerTimer, enableSuspense} from 'shared/ReactFeatureFlags';
23 import {getCommitTime} from './ReactProfilerTimer';
24 import {
25 ClassComponent,
@@ -63,7 +63,10 @@ import {
63 replaceContainerChildren,
64 createContainerChildSet,
65 } from './ReactFiberHostConfig';
66 -import {captureCommitPhaseError} from './ReactFiberScheduler';
66 +import {
67 + captureCommitPhaseError,
68 + requestCurrentTime,
69 +} from './ReactFiberScheduler';
70
71 const {
72 invokeGuardedCallback,
@@ -314,7 +317,10 @@ function commitLifeCycles(
317 return;
318 }
319 case TimeoutComponent: {
317 - // We have no life-cycles associated with Timeouts.
320 + if (enableSuspense) {
321 + const currentTime = requestCurrentTime();
322 + finishedWork.stateNode = {timedOutAt: currentTime};
323 + }
324 return;
325 }
326 default: {
packages/react-reconciler/src/ReactFiberExpirationTime.js
+39 -4
@@ -32,10 +32,10 @@ function ceiling(num: number, precision: number): number {
32 return (((num / precision) | 0) + 1) * precision;
33 }
34
35 -export function computeExpirationBucket(
36 - currentTime: ExpirationTime,
37 - expirationInMs: number,
38 - bucketSizeMs: number,
35 +function computeExpirationBucket(
36 + currentTime,
37 + expirationInMs,
38 + bucketSizeMs,
39 ): ExpirationTime {
40 return (
41 MAGIC_NUMBER_OFFSET +
@@ -45,3 +45,38 @@ export function computeExpirationBucket(
45 )
46 );
47 }
48 +
49 +export const LOW_PRIORITY_EXPIRATION = 5000;
50 +export const LOW_PRIORITY_BATCH_SIZE = 250;
51 +
52 +export function computeAsyncExpiration(
53 + currentTime: ExpirationTime,
54 +): ExpirationTime {
55 + return computeExpirationBucket(
56 + currentTime,
57 + LOW_PRIORITY_EXPIRATION,
58 + LOW_PRIORITY_BATCH_SIZE,
59 + );
60 +}
61 +
62 +// We intentionally set a higher expiration time for interactive updates in
63 +// dev than in production.
64 +//
65 +// If the main thread is being blocked so long that you hit the expiration,
66 +// it's a problem that could be solved with better scheduling.
67 +//
68 +// People will be more likely to notice this and fix it with the long
69 +// expiration time in development.
70 +//
71 +// In production we opt for better UX at the risk of masking scheduling
72 +// problems, by expiring fast.
73 +export const HIGH_PRIORITY_EXPIRATION = __DEV__ ? 500 : 150;
74 +export const HIGH_PRIORITY_BATCH_SIZE = 100;
75 +
76 +export function computeInteractiveExpiration(currentTime: ExpirationTime) {
77 + return computeExpirationBucket(
78 + currentTime,
79 + HIGH_PRIORITY_EXPIRATION,
80 + HIGH_PRIORITY_BATCH_SIZE,
81 + );
82 +}
packages/react-reconciler/src/ReactFiberPendingPriority.js
+109 -56
@@ -10,9 +10,11 @@
10 import type {FiberRoot} from './ReactFiberRoot';
11 import type {ExpirationTime} from './ReactFiberExpirationTime';
12
13 -import {NoWork, Sync} from './ReactFiberExpirationTime';
13 +import {NoWork} from './ReactFiberExpirationTime';
14
15 -// TODO: Offscreen updates
15 +// TODO: Offscreen updates should never suspend. However, a promise that
16 +// suspended inside an offscreen subtree should be able to ping at the priority
17 +// of the outer render.
18
19 export function markPendingPriorityLevel(
20 root: FiberRoot,
@@ -40,7 +42,7 @@ export function markPendingPriorityLevel(
42 }
43 }
44 }
43 - findNextPendingPriorityLevel(root);
45 + findNextExpirationTimeToWorkOn(expirationTime, root);
46 }
47
48 export function markCommittedPriorityLevels(
@@ -56,7 +58,7 @@ export function markCommittedPriorityLevels(
58 root.earliestSuspendedTime = NoWork;
59 root.latestSuspendedTime = NoWork;
60 root.latestPingedTime = NoWork;
59 - findNextPendingPriorityLevel(root);
61 + findNextExpirationTimeToWorkOn(NoWork, root);
62 return;
63 }
64
@@ -85,7 +87,7 @@ export function markCommittedPriorityLevels(
87 // There's no suspended work. Treat the earliest remaining level as a
88 // pending level.
89 markPendingPriorityLevel(root, earliestRemainingTime);
88 - findNextPendingPriorityLevel(root);
90 + findNextExpirationTimeToWorkOn(NoWork, root);
91 return;
92 }
93
@@ -100,7 +102,7 @@ export function markCommittedPriorityLevels(
102 // There's no suspended work. Treat the earliest remaining level as a
103 // pending level.
104 markPendingPriorityLevel(root, earliestRemainingTime);
103 - findNextPendingPriorityLevel(root);
105 + findNextExpirationTimeToWorkOn(NoWork, root);
106 return;
107 }
108
@@ -108,38 +110,50 @@ export function markCommittedPriorityLevels(
110 // The earliest remaining time is earlier than all the suspended work.
111 // Treat it as a pending update.
112 markPendingPriorityLevel(root, earliestRemainingTime);
111 - findNextPendingPriorityLevel(root);
113 + findNextExpirationTimeToWorkOn(NoWork, root);
114 return;
115 }
116
117 // The earliest remaining time falls within the range of known suspended
118 // levels. We should treat this as suspended work.
117 - findNextPendingPriorityLevel(root);
119 + findNextExpirationTimeToWorkOn(NoWork, root);
120 }
121
122 export function hasLowerPriorityWork(
123 root: FiberRoot,
122 - renderExpirationTime: ExpirationTime,
123 -) {
124 + erroredExpirationTime: ExpirationTime,
125 +): boolean {
126 + const latestPendingTime = root.latestPendingTime;
127 + const latestSuspendedTime = root.latestSuspendedTime;
128 + const latestPingedTime = root.latestPingedTime;
129 + return (
130 + (latestPendingTime !== NoWork &&
131 + latestPendingTime > erroredExpirationTime) ||
132 + (latestSuspendedTime !== NoWork &&
133 + latestSuspendedTime > erroredExpirationTime) ||
134 + (latestPingedTime !== NoWork && latestPingedTime > erroredExpirationTime)
135 + );
136 +}
137 +
138 +export function isPriorityLevelSuspended(
139 + root: FiberRoot,
140 + expirationTime: ExpirationTime,
141 +): boolean {
142 + const earliestSuspendedTime = root.earliestSuspendedTime;
143 + const latestSuspendedTime = root.latestSuspendedTime;
144 return (
125 - renderExpirationTime !== root.latestPendingTime &&
126 - renderExpirationTime !== root.latestSuspendedTime
145 + earliestSuspendedTime !== NoWork &&
146 + expirationTime >= earliestSuspendedTime &&
147 + expirationTime <= latestSuspendedTime
148 );
149 }
150
151 export function markSuspendedPriorityLevel(
152 root: FiberRoot,
153 suspendedTime: ExpirationTime,
133 - didError: boolean,
154 ): void {
135 - if (didError && !hasLowerPriorityWork(root, suspendedTime)) {
136 - // TODO: When we add back resuming, we need to ensure the progressed work
137 - // is thrown out and not reused during the restarted render. One way to
138 - // invalidate the progressed work is to restart at expirationTime + 1.
139 - root.didError = true;
140 - findNextPendingPriorityLevel(root);
141 - return;
142 - }
155 + root.didError = false;
156 + clearPing(root, suspendedTime);
157
158 // First, check the known pending levels and update them if needed.
159 const earliestPendingTime = root.earliestPendingTime;
@@ -159,15 +173,9 @@ export function markSuspendedPriorityLevel(
173 root.latestPendingTime = earliestPendingTime;
174 }
175
162 - // Next, if we're working on the lowest known suspended level, clear the ping.
163 - // TODO: What if a promise suspends and pings before the root completes?
164 - const latestSuspendedTime = root.latestSuspendedTime;
165 - if (latestSuspendedTime === suspendedTime) {
166 - root.latestPingedTime = NoWork;
167 - }
168 -
176 // Finally, update the known suspended levels.
177 const earliestSuspendedTime = root.earliestSuspendedTime;
178 + const latestSuspendedTime = root.latestSuspendedTime;
179 if (earliestSuspendedTime === NoWork) {
180 // No other suspended levels.
181 root.earliestSuspendedTime = root.latestSuspendedTime = suspendedTime;
@@ -180,47 +188,92 @@ export function markSuspendedPriorityLevel(
188 root.latestSuspendedTime = suspendedTime;
189 }
190 }
183 - findNextPendingPriorityLevel(root);
191 +
192 + findNextExpirationTimeToWorkOn(suspendedTime, root);
193 }
194
195 export function markPingedPriorityLevel(
196 root: FiberRoot,
197 pingedTime: ExpirationTime,
198 ): void {
190 - const latestSuspendedTime = root.latestSuspendedTime;
191 - if (latestSuspendedTime !== NoWork && latestSuspendedTime <= pingedTime) {
192 - const latestPingedTime = root.latestPingedTime;
193 - if (latestPingedTime === NoWork || latestPingedTime < pingedTime) {
194 - root.latestPingedTime = pingedTime;
195 - }
199 + root.didError = false;
200 +
201 + // TODO: When we add back resuming, we need to ensure the progressed work
202 + // is thrown out and not reused during the restarted render. One way to
203 + // invalidate the progressed work is to restart at expirationTime + 1.
204 + const latestPingedTime = root.latestPingedTime;
205 + if (latestPingedTime === NoWork || latestPingedTime < pingedTime) {
206 + root.latestPingedTime = pingedTime;
207 }
197 - findNextPendingPriorityLevel(root);
208 + findNextExpirationTimeToWorkOn(pingedTime, root);
209 }
210
200 -function findNextPendingPriorityLevel(root) {
211 +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.
214 + const latestPingedTime = root.latestPingedTime;
215 + if (latestPingedTime !== NoWork && latestPingedTime <= completedTime) {
216 + root.latestPingedTime = NoWork;
217 + }
218 +}
219 +
220 +export function findEarliestOutstandingPriorityLevel(
221 + root: FiberRoot,
222 + renderExpirationTime: ExpirationTime,
223 +): ExpirationTime {
224 + let earliestExpirationTime = renderExpirationTime;
225 +
226 + const earliestPendingTime = root.earliestPendingTime;
227 const earliestSuspendedTime = root.earliestSuspendedTime;
228 + if (
229 + earliestExpirationTime === NoWork ||
230 + (earliestPendingTime !== NoWork &&
231 + earliestPendingTime < earliestExpirationTime)
232 + ) {
233 + earliestExpirationTime = earliestPendingTime;
234 + }
235 + if (
236 + earliestExpirationTime === NoWork ||
237 + (earliestSuspendedTime !== NoWork &&
238 + earliestSuspendedTime < earliestExpirationTime)
239 + ) {
240 + earliestExpirationTime = earliestSuspendedTime;
241 + }
242 + return earliestExpirationTime;
243 +}
244 +
245 +function findNextExpirationTimeToWorkOn(completedExpirationTime, root) {
246 + const earliestSuspendedTime = root.earliestSuspendedTime;
247 + const latestSuspendedTime = root.latestSuspendedTime;
248 const earliestPendingTime = root.earliestPendingTime;
203 - let nextExpirationTimeToWorkOn;
204 - let expirationTime;
205 - if (earliestSuspendedTime === NoWork) {
206 - // Fast path. There's no suspended work.
207 - nextExpirationTimeToWorkOn = expirationTime = earliestPendingTime;
208 - } else if (earliestPendingTime !== NoWork) {
209 - // Check if there's known pending work.
210 - nextExpirationTimeToWorkOn = earliestPendingTime;
211 - expirationTime =
212 - earliestSuspendedTime < earliestPendingTime
213 - ? earliestSuspendedTime
214 - : earliestPendingTime;
215 - } else {
216 - // Finally, if a suspended level was pinged, work on that. Otherwise there's
217 - // nothing to work on.
218 - nextExpirationTimeToWorkOn = expirationTime = root.latestPingedTime;
249 + const latestPingedTime = root.latestPingedTime;
250 +
251 + // Work on the earliest pending time. Failing that, work on the latest
252 + // pinged time.
253 + let nextExpirationTimeToWorkOn =
254 + earliestPendingTime !== NoWork ? earliestPendingTime : latestPingedTime;
255 +
256 + // If there is no pending or pinted work, check if there's suspended work
257 + // that's lower priority than what we just completed.
258 + if (
259 + nextExpirationTimeToWorkOn === NoWork &&
260 + (completedExpirationTime === NoWork ||
261 + latestSuspendedTime > completedExpirationTime)
262 + ) {
263 + // The lowest priority suspended work is the work most likely to be
264 + // committed next. Let's start rendering it again, so that if it times out,
265 + // it's ready to commit.
266 + nextExpirationTimeToWorkOn = latestSuspendedTime;
267 }
268
221 - if (root.didError) {
222 - // Revert to synchronous mode.
223 - expirationTime = Sync;
269 + let expirationTime = nextExpirationTimeToWorkOn;
270 + if (
271 + expirationTime !== NoWork &&
272 + earliestSuspendedTime !== NoWork &&
273 + earliestSuspendedTime < expirationTime
274 + ) {
275 + // Expire using the earliest known expiration time.
276 + expirationTime = earliestSuspendedTime;
277 }
278
279 root.nextExpirationTimeToWorkOn = nextExpirationTimeToWorkOn;
packages/react-reconciler/src/ReactFiberRoot.js
+7 -3
@@ -9,6 +9,9 @@
9
10 import type {Fiber} from './ReactFiber';
11 import type {ExpirationTime} from './ReactFiberExpirationTime';
12 +import type {TimeoutHandle, NoTimeout} from './ReactFiberHostConfig';
13 +
14 +import {noTimeout} from './ReactFiberHostConfig';
15
16 import {createHostRootFiber} from './ReactFiber';
17 import {NoWork} from './ReactFiberExpirationTime';
@@ -51,10 +54,10 @@ export type FiberRoot = {
54
55 pendingCommitExpirationTime: ExpirationTime,
56 // A finished work-in-progress HostRoot that's ready to be committed.
54 - // TODO: The reason this is separate from isReadyForCommit is because the
55 - // FiberRoot concept will likely be lifted out of the reconciler and into
56 - // the renderer.
57 finishedWork: Fiber | null,
58 + // Timeout handle returned by setTimeout. Used to cancel a pending timeout, if
59 + // it's superseded by a new one.
60 + timeoutHandle: TimeoutHandle | NoTimeout,
61 // Top context object, used by renderSubtreeIntoContainer
62 context: Object | null,
63 pendingContext: Object | null,
@@ -95,6 +98,7 @@ export function createFiberRoot(
98
99 pendingCommitExpirationTime: NoWork,
100 finishedWork: null,
101 + timeoutHandle: noTimeout,
102 context: null,
103 pendingContext: null,
104 hydrate,
packages/react-reconciler/src/ReactFiberScheduler.js
+291 -163
@@ -43,11 +43,18 @@ import {
43 replayFailedUnitOfWorkWithInvokeGuardedCallback,
44 warnAboutDeprecatedLifecycles,
45 warnAboutLegacyContextAPI,
46 + enableSuspense,
47 } from 'shared/ReactFeatureFlags';
48 import getComponentName from 'shared/getComponentName';
49 import invariant from 'shared/invariant';
50 import warning from 'shared/warning';
51
52 +import {
53 + scheduleTimeout,
54 + cancelTimeout,
55 + noTimeout,
56 +} from './ReactFiberHostConfig';
57 +
58 import ReactFiberInstrumentation from './ReactFiberInstrumentation';
59 import ReactDebugCurrentFiber from './ReactDebugCurrentFiber';
60 import {
@@ -62,6 +69,9 @@ import {
69 markCommittedPriorityLevels,
70 markSuspendedPriorityLevel,
71 markPingedPriorityLevel,
72 + hasLowerPriorityWork,
73 + isPriorityLevelSuspended,
74 + findEarliestOutstandingPriorityLevel,
75 } from './ReactFiberPendingPriority';
76 import {
77 recordEffect,
@@ -90,7 +100,8 @@ import {
100 Never,
101 msToExpirationTime,
102 expirationTimeToMs,
93 - computeExpirationBucket,
103 + computeAsyncExpiration,
104 + computeInteractiveExpiration,
105 } from './ReactFiberExpirationTime';
106 import {AsyncMode, ProfileMode} from './ReactTypeOfMode';
107 import {enqueueUpdate, resetCurrentlyProcessingQueue} from './ReactUpdateQueue';
@@ -223,7 +234,7 @@ let nextUnitOfWork: Fiber | null = null;
234 let nextRoot: FiberRoot | null = null;
235 // The time at which we're currently rendering work.
236 let nextRenderExpirationTime: ExpirationTime = NoWork;
226 -let nextLatestTimeoutMs: number = -1;
237 +let nextLatestAbsoluteTimeoutMs: number = -1;
238 let nextRenderDidError: boolean = false;
239
240 // The next fiber with an effect that we're currently committing.
@@ -338,7 +349,7 @@ function resetStack() {
349
350 nextRoot = null;
351 nextRenderExpirationTime = NoWork;
341 - nextLatestTimeoutMs = -1;
352 + nextLatestAbsoluteTimeoutMs = -1;
353 nextRenderDidError = false;
354 nextUnitOfWork = null;
355 }
@@ -978,7 +989,11 @@ function workLoop(isYieldy) {
989 }
990 }
991
981 -function renderRoot(root: FiberRoot, isYieldy: boolean): void {
992 +function renderRoot(
993 + root: FiberRoot,
994 + isYieldy: boolean,
995 + isExpired: boolean,
996 +): void {
997 invariant(
998 !isWorking,
999 'renderRoot was called recursively. This error is likely caused ' +
@@ -1086,44 +1101,10 @@ function renderRoot(root: FiberRoot, isYieldy: boolean): void {
1101 // there's no more work to be done in the current batch.
1102 nextRoot = null;
1103 onFatal(root);
1089 - } else if (nextUnitOfWork === null) {
1090 - // We reached the root.
1091 - const rootWorkInProgress = root.current.alternate;
1092 - invariant(
1093 - rootWorkInProgress !== null,
1094 - 'Finished root should have a work-in-progress. This error is likely ' +
1095 - 'caused by a bug in React. Please file an issue.',
1096 - );
1097 - if ((rootWorkInProgress.effectTag & Incomplete) === NoEffect) {
1098 - // The root successfully completed.
1099 - const didCompleteRoot = true;
1100 - stopWorkLoopTimer(interruptedBy, didCompleteRoot);
1101 - interruptedBy = null;
1102 - // `nextRoot` points to the in-progress root. A non-null value indicates
1103 - // that we're in the middle of an async render. Set it to null to indicate
1104 - // there's no more work to be done in the current batch.
1105 - nextRoot = null;
1106 - onComplete(root, rootWorkInProgress, expirationTime);
1107 - } else {
1108 - // The root did not complete.
1109 - const didCompleteRoot = false;
1110 - stopWorkLoopTimer(interruptedBy, didCompleteRoot);
1111 - interruptedBy = null;
1112 - // `nextRoot` points to the in-progress root. A non-null value indicates
1113 - // that we're in the middle of an async render. Set it to null to indicate
1114 - // there's no more work to be done in the current batch.
1115 - nextRoot = null;
1116 - markSuspendedPriorityLevel(root, expirationTime, nextRenderDidError);
1117 - const suspendedExpirationTime = expirationTime;
1118 - const newExpirationTime = root.expirationTime;
1119 - onSuspend(
1120 - root,
1121 - suspendedExpirationTime,
1122 - newExpirationTime,
1123 - nextLatestTimeoutMs,
1124 - );
1125 - }
1126 - } else {
1104 + return;
1105 + }
1106 +
1107 + if (nextUnitOfWork !== null) {
1108 // There's still remaining async work in this tree, but we ran out of time
1109 // in the current frame. Yield back to the renderer. Unless we're
1110 // interrupted by a higher priority update, we'll continue later from where
@@ -1132,7 +1113,103 @@ function renderRoot(root: FiberRoot, isYieldy: boolean): void {
1113 stopWorkLoopTimer(interruptedBy, didCompleteRoot);
1114 interruptedBy = null;
1115 onYield(root);
1116 + return;
1117 + }
1118 +
1119 + // We completed the whole tree.
1120 + const didCompleteRoot = true;
1121 + stopWorkLoopTimer(interruptedBy, didCompleteRoot);
1122 + const rootWorkInProgress = root.current.alternate;
1123 + invariant(
1124 + rootWorkInProgress !== null,
1125 + 'Finished root should have a work-in-progress. This error is likely ' +
1126 + 'caused by a bug in React. Please file an issue.',
1127 + );
1128 +
1129 + // `nextRoot` points to the in-progress root. A non-null value indicates
1130 + // that we're in the middle of an async render. Set it to null to indicate
1131 + // there's no more work to be done in the current batch.
1132 + nextRoot = null;
1133 + interruptedBy = null;
1134 +
1135 + if (nextRenderDidError) {
1136 + // There was an error
1137 + if (hasLowerPriorityWork(root, expirationTime)) {
1138 + // There's lower priority work. If so, it may have the effect of fixing
1139 + // the exception that was just thrown. Exit without committing. This is
1140 + // similar to a suspend, but without a timeout because we're not waiting
1141 + // for a promise to resolve. React will restart at the lower
1142 + // priority level.
1143 + markSuspendedPriorityLevel(root, expirationTime);
1144 + const suspendedExpirationTime = expirationTime;
1145 + const rootExpirationTime = root.expirationTime;
1146 + onSuspend(
1147 + root,
1148 + rootWorkInProgress,
1149 + suspendedExpirationTime,
1150 + rootExpirationTime,
1151 + -1, // Indicates no timeout
1152 + );
1153 + return;
1154 + } else if (!root.didError && expirationTime !== Sync) {
1155 + // There's no lower priority work, but we're rendering asynchronously.
1156 + // Synchronsouly attempt to render the same level one more time. This is
1157 + // similar to a suspend, but without a timeout because we're not waiting
1158 + // for a promise to resolve.
1159 + root.didError = true;
1160 + const suspendedExpirationTime = (root.nextExpirationTimeToWorkOn = expirationTime);
1161 + const rootExpirationTime = (root.expirationTime = Sync);
1162 + onSuspend(
1163 + root,
1164 + rootWorkInProgress,
1165 + suspendedExpirationTime,
1166 + rootExpirationTime,
1167 + -1, // Indicates no timeout
1168 + );
1169 + return;
1170 + }
1171 }
1172 +
1173 + if (enableSuspense && !isExpired && nextLatestAbsoluteTimeoutMs !== -1) {
1174 + // The tree was suspended.
1175 + const suspendedExpirationTime = expirationTime;
1176 + markSuspendedPriorityLevel(root, suspendedExpirationTime);
1177 +
1178 + // Find the earliest uncommitted expiration time in the tree, including
1179 + // work that is suspended. The timeout threshold cannot be longer than
1180 + // the overall expiration.
1181 + const earliestExpirationTime = findEarliestOutstandingPriorityLevel(
1182 + root,
1183 + expirationTime,
1184 + );
1185 + const earliestExpirationTimeMs = expirationTimeToMs(earliestExpirationTime);
1186 + if (earliestExpirationTimeMs < nextLatestAbsoluteTimeoutMs) {
1187 + nextLatestAbsoluteTimeoutMs = earliestExpirationTimeMs;
1188 + }
1189 +
1190 + // Subtract the current time from the absolute timeout to get the number
1191 + // of milliseconds until the timeout. In other words, convert an absolute
1192 + // timestamp to a relative time. This is the value that is passed
1193 + // to `setTimeout`.
1194 + const currentTimeMs = expirationTimeToMs(requestCurrentTime());
1195 + let msUntilTimeout = nextLatestAbsoluteTimeoutMs - currentTimeMs;
1196 + msUntilTimeout = msUntilTimeout < 0 ? 0 : msUntilTimeout;
1197 +
1198 + // TODO: Account for the Just Noticable Difference
1199 +
1200 + const rootExpirationTime = root.expirationTime;
1201 + onSuspend(
1202 + root,
1203 + rootWorkInProgress,
1204 + suspendedExpirationTime,
1205 + rootExpirationTime,
1206 + msUntilTimeout,
1207 + );
1208 + return;
1209 + }
1210 +
1211 + // Ready to commit.
1212 + onComplete(root, rootWorkInProgress, expirationTime);
1213 }
1214
1215 function dispatch(
@@ -1193,36 +1270,6 @@ function captureCommitPhaseError(fiber: Fiber, error: mixed) {
1270 return dispatch(fiber, error, Sync);
1271 }
1272
1196 -function computeAsyncExpiration(currentTime) {
1197 - // Given the current clock time, returns an expiration time. We use rounding
1198 - // to batch like updates together.
1199 - // Should complete within ~5000ms. 5250ms max.
1200 - const expirationMs = 5000;
1201 - const bucketSizeMs = 250;
1202 - return computeExpirationBucket(currentTime, expirationMs, bucketSizeMs);
1203 -}
1204 -
1205 -function computeInteractiveExpiration(currentTime) {
1206 - let expirationMs;
1207 - // We intentionally set a higher expiration time for interactive updates in
1208 - // dev than in production.
1209 - // If the main thread is being blocked so long that you hit the expiration,
1210 - // it's a problem that could be solved with better scheduling.
1211 - // People will be more likely to notice this and fix it with the long
1212 - // expiration time in development.
1213 - // In production we opt for better UX at the risk of masking scheduling
1214 - // problems, by expiring fast.
1215 - if (__DEV__) {
1216 - // Should complete within ~500ms. 600ms max.
1217 - expirationMs = 500;
1218 - } else {
1219 - // In production things should be more responsive, 150ms max.
1220 - expirationMs = 150;
1221 - }
1222 - const bucketSizeMs = 100;
1223 - return computeExpirationBucket(currentTime, expirationMs, bucketSizeMs);
1224 -}
1225 -
1273 // Creates a unique async expiration time.
1274 function computeUniqueAsyncExpiration(): ExpirationTime {
1275 const currentTime = requestCurrentTime();
@@ -1287,101 +1334,131 @@ function computeExpirationForFiber(currentTime: ExpirationTime, fiber: Fiber) {
1334 return expirationTime;
1335 }
1336
1290 -function markTimeout(
1337 +function renderDidSuspend(
1338 root: FiberRoot,
1292 - thenable: Thenable,
1293 - timeoutMs: number,
1339 + absoluteTimeoutMs: number,
1340 suspendedTime: ExpirationTime,
1341 ) {
1342 // Schedule the timeout.
1297 - if (timeoutMs >= 0 && nextLatestTimeoutMs < timeoutMs) {
1298 - nextLatestTimeoutMs = timeoutMs;
1343 + if (
1344 + absoluteTimeoutMs >= 0 &&
1345 + nextLatestAbsoluteTimeoutMs < absoluteTimeoutMs
1346 + ) {
1347 + nextLatestAbsoluteTimeoutMs = absoluteTimeoutMs;
1348 }
1349 }
1350
1302 -function markError(root: FiberRoot) {
1351 +function renderDidError(root: FiberRoot) {
1352 nextRenderDidError = true;
1353 }
1354
1306 -function retrySuspendedRoot(root: FiberRoot, suspendedTime: ExpirationTime) {
1307 - markPingedPriorityLevel(root, suspendedTime);
1308 - const retryTime = root.expirationTime;
1309 - if (retryTime !== NoWork) {
1310 - requestWork(root, retryTime);
1311 - }
1312 -}
1355 +function retrySuspendedRoot(
1356 + root: FiberRoot,
1357 + fiber: Fiber,
1358 + suspendedTime: ExpirationTime,
1359 +) {
1360 + if (enableSuspense) {
1361 + let retryTime;
1362
1314 -function scheduleWork(fiber: Fiber, expirationTime: ExpirationTime) {
1315 - recordScheduleUpdate();
1363 + if (isPriorityLevelSuspended(root, suspendedTime)) {
1364 + // Ping at the original level
1365 + retryTime = suspendedTime;
1366 + markPingedPriorityLevel(root, retryTime);
1367 + } else {
1368 + // Placeholder already timed out. Compute a new expiration time
1369 + const currentTime = requestCurrentTime();
1370 + retryTime = computeExpirationForFiber(currentTime, fiber);
1371 + markPendingPriorityLevel(root, retryTime);
1372 + }
1373
1317 - if (__DEV__) {
1318 - if (fiber.tag === ClassComponent) {
1319 - const instance = fiber.stateNode;
1320 - warnAboutInvalidUpdates(instance);
1374 + scheduleWorkToRoot(fiber, retryTime);
1375 + const rootExpirationTime = root.expirationTime;
1376 + if (rootExpirationTime !== NoWork) {
1377 + requestWork(root, rootExpirationTime);
1378 }
1379 }
1380 +}
1381
1382 +function scheduleWorkToRoot(fiber: Fiber, expirationTime): FiberRoot | null {
1383 + // Walk the parent path to the root and update each node's
1384 + // expiration time.
1385 let node = fiber;
1325 - while (node !== null) {
1326 - // Walk the parent path to the root and update each node's
1327 - // expiration time.
1386 + do {
1387 + const alternate = node.alternate;
1388 if (
1389 node.expirationTime === NoWork ||
1390 node.expirationTime > expirationTime
1391 ) {
1392 node.expirationTime = expirationTime;
1333 - }
1334 - if (node.alternate !== null) {
1393 if (
1336 - node.alternate.expirationTime === NoWork ||
1337 - node.alternate.expirationTime > expirationTime
1394 + alternate !== null &&
1395 + (alternate.expirationTime === NoWork ||
1396 + alternate.expirationTime > expirationTime)
1397 ) {
1339 - node.alternate.expirationTime = expirationTime;
1398 + alternate.expirationTime = expirationTime;
1399 }
1400 + } else if (
1401 + alternate !== null &&
1402 + (alternate.expirationTime === NoWork ||
1403 + alternate.expirationTime > expirationTime)
1404 + ) {
1405 + alternate.expirationTime = expirationTime;
1406 }
1342 - if (node.return === null) {
1343 - if (node.tag === HostRoot) {
1344 - const root: FiberRoot = (node.stateNode: any);
1345 - if (
1346 - !isWorking &&
1347 - nextRenderExpirationTime !== NoWork &&
1348 - expirationTime < nextRenderExpirationTime
1349 - ) {
1350 - // This is an interruption. (Used for performance tracking.)
1351 - interruptedBy = fiber;
1352 - resetStack();
1353 - }
1354 - markPendingPriorityLevel(root, expirationTime);
1355 - if (
1356 - // If we're in the render phase, we don't need to schedule this root
1357 - // for an update, because we'll do it before we exit...
1358 - !isWorking ||
1359 - isCommitting ||
1360 - // ...unless this is a different root than the one we're rendering.
1361 - nextRoot !== root
1362 - ) {
1363 - const rootExpirationTime = root.expirationTime;
1364 - requestWork(root, rootExpirationTime);
1365 - }
1366 - if (nestedUpdateCount > NESTED_UPDATE_LIMIT) {
1367 - invariant(
1368 - false,
1369 - 'Maximum update depth exceeded. This can happen when a ' +
1370 - 'component repeatedly calls setState inside ' +
1371 - 'componentWillUpdate or componentDidUpdate. React limits ' +
1372 - 'the number of nested updates to prevent infinite loops.',
1373 - );
1374 - }
1375 - } else {
1376 - if (__DEV__) {
1377 - if (fiber.tag === ClassComponent) {
1378 - warnAboutUpdateOnUnmounted(fiber);
1379 - }
1380 - }
1381 - return;
1382 - }
1407 + if (node.return === null && node.tag === HostRoot) {
1408 + return node.stateNode;
1409 }
1410 node = node.return;
1411 + } while (node !== null);
1412 + return null;
1413 +}
1414 +
1415 +function scheduleWork(fiber: Fiber, expirationTime: ExpirationTime) {
1416 + recordScheduleUpdate();
1417 +
1418 + if (__DEV__) {
1419 + if (fiber.tag === ClassComponent) {
1420 + const instance = fiber.stateNode;
1421 + warnAboutInvalidUpdates(instance);
1422 + }
1423 + }
1424 +
1425 + const root = scheduleWorkToRoot(fiber, expirationTime);
1426 + if (root === null) {
1427 + if (__DEV__ && fiber.tag === ClassComponent) {
1428 + warnAboutUpdateOnUnmounted(fiber);
1429 + }
1430 + return;
1431 + }
1432 +
1433 + if (
1434 + !isWorking &&
1435 + nextRenderExpirationTime !== NoWork &&
1436 + expirationTime < nextRenderExpirationTime
1437 + ) {
1438 + // This is an interruption. (Used for performance tracking.)
1439 + interruptedBy = fiber;
1440 + resetStack();
1441 + }
1442 + markPendingPriorityLevel(root, expirationTime);
1443 + if (
1444 + // If we're in the render phase, we don't need to schedule this root
1445 + // for an update, because we'll do it before we exit...
1446 + !isWorking ||
1447 + isCommitting ||
1448 + // ...unless this is a different root than the one we're rendering.
1449 + nextRoot !== root
1450 + ) {
1451 + const rootExpirationTime = root.expirationTime;
1452 + requestWork(root, rootExpirationTime);
1453 + }
1454 + if (nestedUpdateCount > NESTED_UPDATE_LIMIT) {
1455 + invariant(
1456 + false,
1457 + 'Maximum update depth exceeded. This can happen when a ' +
1458 + 'component repeatedly calls setState inside ' +
1459 + 'componentWillUpdate or componentDidUpdate. React limits ' +
1460 + 'the number of nested updates to prevent infinite loops.',
1461 + );
1462 }
1463 }
1464
@@ -1497,23 +1574,43 @@ function onComplete(
1574
1575 function onSuspend(
1576 root: FiberRoot,
1577 + finishedWork: Fiber,
1578 suspendedExpirationTime: ExpirationTime,
1501 - expirationTime: ExpirationTime,
1502 - timeoutMs: number,
1503 -) {
1504 - if (timeoutMs >= 0) {
1505 - const suspendedRoot = root;
1506 - setTimeout(() => {
1507 - retrySuspendedRoot(suspendedRoot, suspendedExpirationTime);
1508 - }, timeoutMs);
1579 + rootExpirationTime: ExpirationTime,
1580 + msUntilTimeout: number,
1581 +): void {
1582 + root.expirationTime = rootExpirationTime;
1583 + if (enableSuspense && msUntilTimeout === 0 && !shouldYield()) {
1584 + // Don't wait an additional tick. Commit the tree immediately.
1585 + root.pendingCommitExpirationTime = suspendedExpirationTime;
1586 + root.finishedWork = finishedWork;
1587 + } else if (msUntilTimeout > 0) {
1588 + // Wait `msUntilTimeout` milliseconds before committing.
1589 + root.timeoutHandle = scheduleTimeout(
1590 + onTimeout.bind(null, root, finishedWork, suspendedExpirationTime),
1591 + msUntilTimeout,
1592 + );
1593 }
1510 - root.expirationTime = expirationTime;
1594 }
1595
1596 function onYield(root) {
1597 root.finishedWork = null;
1598 }
1599
1600 +function onTimeout(root, finishedWork, suspendedExpirationTime) {
1601 + if (enableSuspense) {
1602 + // The root timed out. Commit it.
1603 + root.pendingCommitExpirationTime = suspendedExpirationTime;
1604 + root.finishedWork = finishedWork;
1605 + // Read the current time before entering the commit phase. We can be
1606 + // certain this won't cause tearing related to batching of event updates
1607 + // because we're at the top of a timer event.
1608 + recomputeCurrentRendererTime();
1609 + currentSchedulerTime = currentRendererTime;
1610 + flushRoot(root, suspendedExpirationTime);
1611 + }
1612 +}
1613 +
1614 function onCommit(root, expirationTime) {
1615 root.expirationTime = expirationTime;
1616 root.finishedWork = null;
@@ -1567,7 +1664,6 @@ function requestCurrentTime() {
1664 // It's up to the renderer to call renderRoot at some point in the future.
1665 function requestWork(root: FiberRoot, expirationTime: ExpirationTime) {
1666 addRootToSchedule(root, expirationTime);
1570 -
1667 if (isRendering) {
1668 // Prevent reentrancy. Remaining work will be scheduled at the end of
1669 // the currently rendering batch.
@@ -1581,7 +1677,7 @@ function requestWork(root: FiberRoot, expirationTime: ExpirationTime) {
1677 // flush it now.
1678 nextFlushedRoot = root;
1679 nextFlushedExpirationTime = Sync;
1584 - performWorkOnRoot(root, Sync, false);
1680 + performWorkOnRoot(root, Sync, true);
1681 }
1682 return;
1683 }
@@ -1733,7 +1829,11 @@ function performWork(minExpirationTime: ExpirationTime, dl: Deadline | null) {
1829 minExpirationTime >= nextFlushedExpirationTime) &&
1830 (!deadlineDidExpire || currentRendererTime >= nextFlushedExpirationTime)
1831 ) {
1736 - performWorkOnRoot(nextFlushedRoot, nextFlushedExpirationTime, true);
1832 + performWorkOnRoot(
1833 + nextFlushedRoot,
1834 + nextFlushedExpirationTime,
1835 + currentRendererTime >= nextFlushedExpirationTime,
1836 + );
1837 findHighestPriorityRoot();
1838 recomputeCurrentRendererTime();
1839 currentSchedulerTime = currentRendererTime;
@@ -1745,7 +1845,7 @@ function performWork(minExpirationTime: ExpirationTime, dl: Deadline | null) {
1845 (minExpirationTime === NoWork ||
1846 minExpirationTime >= nextFlushedExpirationTime)
1847 ) {
1748 - performWorkOnRoot(nextFlushedRoot, nextFlushedExpirationTime, false);
1848 + performWorkOnRoot(nextFlushedRoot, nextFlushedExpirationTime, true);
1849 findHighestPriorityRoot();
1850 }
1851 }
@@ -1781,7 +1881,7 @@ function flushRoot(root: FiberRoot, expirationTime: ExpirationTime) {
1881 // including the given time.
1882 nextFlushedRoot = root;
1883 nextFlushedExpirationTime = expirationTime;
1784 - performWorkOnRoot(root, expirationTime, false);
1884 + performWorkOnRoot(root, expirationTime, true);
1885 // Flush any sync work that was scheduled by lifecycles
1886 performSyncWork();
1887 finishRendering();
@@ -1817,7 +1917,7 @@ function finishRendering() {
1917 function performWorkOnRoot(
1918 root: FiberRoot,
1919 expirationTime: ExpirationTime,
1820 - isYieldy: boolean,
1920 + isExpired: boolean,
1921 ) {
1922 invariant(
1923 !isRendering,
@@ -1828,14 +1928,28 @@ function performWorkOnRoot(
1928 isRendering = true;
1929
1930 // Check if this is async work or sync/expired work.
1831 - if (!isYieldy) {
1931 + if (deadline === null || isExpired) {
1932 // Flush work without yielding.
1933 + // TODO: Non-yieldy work does not necessarily imply expired work. A renderer
1934 + // may want to perform some work without yielding, but also without
1935 + // requiring the root to complete (by triggering placeholders).
1936 +
1937 let finishedWork = root.finishedWork;
1938 if (finishedWork !== null) {
1939 // This root is already complete. We can commit it.
1940 completeRoot(root, finishedWork, expirationTime);
1941 } else {
1838 - renderRoot(root, false);
1942 + root.finishedWork = null;
1943 + // If this root previously suspended, clear its existing timeout, since
1944 + // we're about to try rendering again.
1945 + const timeoutHandle = root.timeoutHandle;
1946 + if (enableSuspense && timeoutHandle !== noTimeout) {
1947 + root.timeoutHandle = noTimeout;
1948 + // $FlowFixMe Complains noTimeout is not a TimeoutID, despite the check above
1949 + cancelTimeout(timeoutHandle);
1950 + }
1951 + const isYieldy = false;
1952 + renderRoot(root, isYieldy, isExpired);
1953 finishedWork = root.finishedWork;
1954 if (finishedWork !== null) {
1955 // We've completed the root. Commit it.
@@ -1849,7 +1963,17 @@ function performWorkOnRoot(
1963 // This root is already complete. We can commit it.
1964 completeRoot(root, finishedWork, expirationTime);
1965 } else {
1852 - renderRoot(root, true);
1966 + root.finishedWork = null;
1967 + // If this root previously suspended, clear its existing timeout, since
1968 + // we're about to try rendering again.
1969 + const timeoutHandle = root.timeoutHandle;
1970 + if (enableSuspense && timeoutHandle !== noTimeout) {
1971 + root.timeoutHandle = noTimeout;
1972 + // $FlowFixMe Complains noTimeout is not a TimeoutID, despite the check above
1973 + cancelTimeout(timeoutHandle);
1974 + }
1975 + const isYieldy = true;
1976 + renderRoot(root, isYieldy, isExpired);
1977 finishedWork = root.finishedWork;
1978 if (finishedWork !== null) {
1979 // We've completed the root. Check the deadline one more time
@@ -1899,16 +2023,20 @@ function completeRoot(
2023
2024 // Commit the root.
2025 root.finishedWork = null;
2026 +
2027 commitRoot(root, finishedWork);
2028 }
2029
2030 // When working on async work, the reconciler asks the renderer if it should
2031 // yield execution. For DOM, we implement this with requestIdleCallback.
2032 function shouldYield() {
1908 - if (deadline === null || deadlineDidExpire) {
1909 - return false;
2033 + if (deadlineDidExpire) {
2034 + return true;
2035 }
1911 - if (deadline.timeRemaining() > timeHeuristicForUnitOfWork) {
2036 + if (
2037 + deadline === null ||
2038 + deadline.timeRemaining() > timeHeuristicForUnitOfWork
2039 + ) {
2040 // Disregard deadline.didTimeout. Only expired work should be flushed
2041 // during a timeout. This path is only hit for non-expired work.
2042 return false;
@@ -2037,8 +2165,8 @@ export {
2165 computeExpirationForFiber,
2166 captureCommitPhaseError,
2167 onUncaughtError,
2040 - markTimeout,
2041 - markError,
2168 + renderDidSuspend,
2169 + renderDidError,
2170 retrySuspendedRoot,
2171 markLegacyErrorBoundaryAsFailed,
2172 isAlreadyFailedLegacyErrorBoundary,
packages/react-reconciler/src/ReactFiberUnwindWork.js
+98 -104
@@ -39,11 +39,9 @@ import {createCapturedValue} from './ReactCapturedValue';
39 import {
40 enqueueCapturedUpdate,
41 createUpdate,
42 - enqueueUpdate,
42 CaptureUpdate,
43 } from './ReactUpdateQueue';
44 import {logError} from './ReactFiberCommitWork';
46 -import {Never, Sync, expirationTimeToMs} from './ReactFiberExpirationTime';
45 import {popHostContainer, popHostContext} from './ReactFiberHostContext';
46 import {
47 popContextProvider as popLegacyContextProvider,
@@ -55,23 +53,27 @@ import {
53 recordElapsedActualRenderTime,
54 } from './ReactProfilerTimer';
55 import {
58 - markTimeout,
59 - markError,
56 + renderDidSuspend,
57 + renderDidError,
58 onUncaughtError,
59 markLegacyErrorBoundaryAsFailed,
60 isAlreadyFailedLegacyErrorBoundary,
63 - requestCurrentTime,
64 - computeExpirationForFiber,
65 - scheduleWork,
61 retrySuspendedRoot,
62 } from './ReactFiberScheduler';
68 -import {hasLowerPriorityWork} from './ReactFiberPendingPriority';
63 +
64 +import invariant from 'shared/invariant';
65 +import maxSigned31BitInt from './maxSigned31BitInt';
66 +import {
67 + expirationTimeToMs,
68 + LOW_PRIORITY_EXPIRATION,
69 +} from './ReactFiberExpirationTime';
70 +import {findEarliestOutstandingPriorityLevel} from './ReactFiberPendingPriority';
71
72 function createRootErrorUpdate(
73 fiber: Fiber,
74 errorInfo: CapturedValue<mixed>,
75 expirationTime: ExpirationTime,
74 -): Update<null> {
76 +): Update<mixed> {
77 const update = createUpdate(expirationTime);
78 // Unmount the root by rendering null.
79 update.tag = CaptureUpdate;
@@ -129,16 +131,6 @@ function createClassErrorUpdate(
131 return update;
132 }
133
132 -function schedulePing(finishedWork) {
133 - // Once the promise resolves, we should try rendering the non-
134 - // placeholder state again.
135 - const currentTime = requestCurrentTime();
136 - const expirationTime = computeExpirationForFiber(currentTime, finishedWork);
137 - const recoveryUpdate = createUpdate(expirationTime);
138 - enqueueUpdate(finishedWork, recoveryUpdate, expirationTime);
139 - scheduleWork(finishedWork, expirationTime);
140 -}
141 -
134 function throwException(
135 root: FiberRoot,
136 returnFiber: Fiber,
@@ -160,113 +152,113 @@ function throwException(
152 // This is a thenable.
153 const thenable: Thenable = (value: any);
154
163 - // TODO: Should use the earliest known expiration time
164 - const currentTime = requestCurrentTime();
165 - const expirationTimeMs = expirationTimeToMs(renderExpirationTime);
166 - const currentTimeMs = expirationTimeToMs(currentTime);
167 - const startTimeMs = expirationTimeMs - 5000;
168 - let elapsedMs = currentTimeMs - startTimeMs;
169 - if (elapsedMs < 0) {
170 - elapsedMs = 0;
171 - }
172 - const remainingTimeMs = expirationTimeMs - currentTimeMs;
173 -
174 - // Find the earliest timeout of all the timeouts in the ancestor path.
175 - // TODO: Alternatively, we could store the earliest timeout on the context
176 - // stack, rather than searching on every suspend.
155 + // Find the earliest timeout of all the timeouts in the ancestor path. We
156 + // could avoid this traversal by storing the timeouts on the stack, but we
157 + // choose not to because we only hit this path if we're IO-bound (i.e. if
158 + // something suspends). Whereas the stack is used even in the non-IO-
159 + // bound case.
160 let workInProgress = returnFiber;
161 let earliestTimeoutMs = -1;
179 - searchForEarliestTimeout: do {
162 + let startTimeMs = -1;
163 + do {
164 if (workInProgress.tag === TimeoutComponent) {
165 const current = workInProgress.alternate;
182 - if (current !== null && current.memoizedState === true) {
183 - // A parent Timeout already committed in a placeholder state. We
184 - // need to handle this promise immediately. In other words, we
185 - // should never suspend inside a tree that already expired.
186 - earliestTimeoutMs = 0;
187 - break searchForEarliestTimeout;
166 + if (
167 + current !== null &&
168 + current.memoizedState === true &&
169 + current.stateNode !== null
170 + ) {
171 + // Reached a placeholder that already timed out. Each timed out
172 + // placeholder acts as the root of a new suspense boundary.
173 +
174 + // Use the time at which the placeholder timed out as the start time
175 + // for the current render.
176 + const timedOutAt = current.stateNode.timedOutAt;
177 + startTimeMs = expirationTimeToMs(timedOutAt);
178 +
179 + // Do not search any further.
180 + break;
181 }
182 let timeoutPropMs = workInProgress.pendingProps.ms;
183 if (typeof timeoutPropMs === 'number') {
184 if (timeoutPropMs <= 0) {
185 earliestTimeoutMs = 0;
193 - break searchForEarliestTimeout;
186 } else if (
187 earliestTimeoutMs === -1 ||
188 timeoutPropMs < earliestTimeoutMs
189 ) {
190 earliestTimeoutMs = timeoutPropMs;
191 }
200 - } else if (earliestTimeoutMs === -1) {
201 - earliestTimeoutMs = remainingTimeMs;
192 }
193 }
194 workInProgress = workInProgress.return;
195 } while (workInProgress !== null);
196
207 - // Compute the remaining time until the timeout.
208 - const msUntilTimeout = earliestTimeoutMs - elapsedMs;
209 -
210 - if (renderExpirationTime === Never || msUntilTimeout > 0) {
211 - // There's still time remaining.
212 - markTimeout(root, thenable, msUntilTimeout, renderExpirationTime);
213 - const onResolveOrReject = () => {
214 - retrySuspendedRoot(root, renderExpirationTime);
215 - };
216 - thenable.then(onResolveOrReject, onResolveOrReject);
217 - return;
197 + let absoluteTimeoutMs;
198 + if (earliestTimeoutMs === -1) {
199 + // If no explicit threshold is given, default to an abitrarily large
200 + // value. The actual size doesn't matter because the threshold for the
201 + // whole tree will be clamped to the expiration time.
202 + absoluteTimeoutMs = maxSigned31BitInt;
203 } else {
219 - // No time remaining. Need to fallback to placeholder.
220 - // Find the nearest timeout that can be retried.
221 - workInProgress = returnFiber;
222 - do {
223 - switch (workInProgress.tag) {
224 - case HostRoot: {
225 - // The root expired, but no fallback was provided. Throw a
226 - // helpful error.
227 - const message =
228 - renderExpirationTime === Sync
229 - ? 'A synchronous update was suspended, but no fallback UI ' +
230 - 'was provided.'
231 - : 'An update was suspended for longer than the timeout, ' +
232 - 'but no fallback UI was provided.';
233 - value = new Error(message);
234 - break;
235 - }
236 - case TimeoutComponent: {
237 - if ((workInProgress.effectTag & DidCapture) === NoEffect) {
238 - workInProgress.effectTag |= ShouldCapture;
239 - const onResolveOrReject = schedulePing.bind(null, workInProgress);
240 - thenable.then(onResolveOrReject, onResolveOrReject);
241 - return;
242 - }
243 - // Already captured during this render. Continue to the next
244 - // Timeout ancestor.
245 - break;
246 - }
247 - }
248 - workInProgress = workInProgress.return;
249 - } while (workInProgress !== null);
250 - }
251 - } else {
252 - // This is an error.
253 - markError(root);
254 - if (
255 - // Retry (at the same priority) one more time before handling the error.
256 - // The retry will flush synchronously. (Unless we're already rendering
257 - // synchronously, in which case move to the next check.)
258 - (!root.didError && renderExpirationTime !== Sync) ||
259 - // There's lower priority work. If so, it may have the effect of fixing
260 - // the exception that was just thrown.
261 - hasLowerPriorityWork(root, renderExpirationTime)
262 - ) {
263 - return;
204 + if (startTimeMs === -1) {
205 + // This suspend happened outside of any already timed-out
206 + // placeholders. We don't know exactly when the update was scheduled,
207 + // but we can infer an approximate start time from the expiration
208 + // time. First, find the earliest uncommitted expiration time in the
209 + // tree, including work that is suspended. Then subtract the offset
210 + // used to compute an async update's expiration time. This will cause
211 + // high priority (interactive) work to expire earlier than neccessary,
212 + // but we can account for this by adjusting for the Just Noticable
213 + // Difference.
214 + const earliestExpirationTime = findEarliestOutstandingPriorityLevel(
215 + root,
216 + renderExpirationTime,
217 + );
218 + const earliestExpirationTimeMs = expirationTimeToMs(
219 + earliestExpirationTime,
220 + );
221 + startTimeMs = earliestExpirationTimeMs - LOW_PRIORITY_EXPIRATION;
222 + }
223 + absoluteTimeoutMs = startTimeMs + earliestTimeoutMs;
224 }
225 +
226 + // Mark the earliest timeout in the suspended fiber's ancestor path. After
227 + // completing the root, we'll take the largest of all the suspended
228 + // fiber's timeouts and use it to compute a timeout for the whole tree.
229 + renderDidSuspend(root, absoluteTimeoutMs, renderExpirationTime);
230 +
231 + // Schedule the nearest Timeout to re-render using a placeholder.
232 + workInProgress = returnFiber;
233 + do {
234 + if (workInProgress.tag === TimeoutComponent) {
235 + if ((workInProgress.effectTag & DidCapture) === NoEffect) {
236 + workInProgress.effectTag |= ShouldCapture;
237 + // Attach a listener to the promise to "ping" the root and retry.
238 + const onResolveOrReject = retrySuspendedRoot.bind(
239 + null,
240 + root,
241 + workInProgress,
242 + renderExpirationTime,
243 + );
244 + thenable.then(onResolveOrReject, onResolveOrReject);
245 + return;
246 + }
247 + // Already captured during this render. Continue to the next
248 + // Timeout ancestor.
249 + }
250 + workInProgress = workInProgress.return;
251 + } while (workInProgress !== null);
252 + // No Timeout was found. Fallthrough to error mode.
253 + value = new Error(
254 + 'An update was suspended, but no placeholder UI was provided.',
255 + );
256 }
257
258 // We didn't find a boundary that could handle this type of exception. Start
259 // over and traverse parent path again, this time treating the exception
260 // as an error.
261 + renderDidError(root);
262 value = createCapturedValue(value, sourceFiber);
263 let workInProgress = returnFiber;
264 do {
@@ -337,11 +329,13 @@ function unwindWork(
329 popHostContainer(workInProgress);
330 popTopLevelLegacyContextObject(workInProgress);
331 const effectTag = workInProgress.effectTag;
340 - if (effectTag & ShouldCapture) {
341 - workInProgress.effectTag = (effectTag & ~ShouldCapture) | DidCapture;
342 - return workInProgress;
343 - }
344 - return null;
332 + invariant(
333 + (effectTag & DidCapture) === NoEffect,
334 + 'The root failed to unmount after an error. This is likely a bug in ' +
335 + 'React. Please file an issue.',
336 + );
337 + workInProgress.effectTag = (effectTag & ~ShouldCapture) | DidCapture;
338 + return workInProgress;
339 }
340 case HostComponent: {
341 popHostContext(workInProgress);
packages/react-reconciler/src/__tests__/ReactIncrementalErrorHandling-test.internal.js
+2 -2
@@ -234,10 +234,10 @@ describe('ReactIncrementalErrorHandling', () => {
234 // Finish the rest of the async work
235 ReactNoop.flushThrough(['Sibling']);
236
237 - // Rendering one more unit of work should be enough to trigger the retry
237 + // Rendering two more units of work should be enough to trigger the retry
238 // and synchronously throw an error.
239 ops = [];
240 - expect(() => ReactNoop.flushUnitsOfWork(1)).toThrow('oops');
240 + expect(() => ReactNoop.flushUnitsOfWork(2)).toThrow('oops');
241 expect(ops).toEqual(['Parent', 'BadRender', 'Sibling']);
242 });
243
packages/react-reconciler/src/__tests__/ReactSuspense-test.internal.js
+57 -220
@@ -92,6 +92,7 @@ describe('ReactSuspense', () => {
92 </Timeout>
93 );
94 }
95 +
96 it('suspends rendering and continues later', async () => {
97 function Bar(props) {
98 ReactNoop.yield('Bar');
@@ -152,19 +153,19 @@ describe('ReactSuspense', () => {
153 </Fallback>
154 </Fragment>,
155 );
155 - expect(ReactNoop.flush()).toEqual(['Suspend! [A]', 'Suspend! [B]']);
156 - expect(ReactNoop.getChildren()).toEqual([]);
157 -
158 - // Advance time by enough to timeout both components and commit their placeholders
159 - ReactNoop.expire(4000);
160 - await advanceTimers(4000);
161 -
156 expect(ReactNoop.flush()).toEqual([
157 'Suspend! [A]',
158 'Loading A...',
159 'Suspend! [B]',
160 'Loading B...',
161 ]);
162 + expect(ReactNoop.getChildren()).toEqual([]);
163 +
164 + // Advance time by enough to timeout both components and commit their placeholders
165 + ReactNoop.expire(4000);
166 + await advanceTimers(4000);
167 +
168 + expect(ReactNoop.flush()).toEqual([]);
169 expect(ReactNoop.getChildren()).toEqual([
170 span('Loading A...'),
171 span('Loading B...'),
@@ -310,12 +311,12 @@ describe('ReactSuspense', () => {
311 }
312
313 ReactNoop.render(<App />);
313 - expect(ReactNoop.flush()).toEqual(['Suspend! [Result]']);
314 + expect(ReactNoop.flush()).toEqual(['Suspend! [Result]', 'Loading...']);
315 expect(ReactNoop.getChildren()).toEqual([]);
316
317 ReactNoop.expire(2000);
318 await advanceTimers(2000);
318 - expect(ReactNoop.flush()).toEqual(['Suspend! [Result]', 'Loading...']);
319 + expect(ReactNoop.flush()).toEqual([]);
320 expect(ReactNoop.getChildren()).toEqual([span('Loading...')]);
321
322 textResourceShouldFail = true;
@@ -342,7 +343,7 @@ describe('ReactSuspense', () => {
343 errorBoundary.current.reset();
344 cache.invalidate();
345
345 - expect(ReactNoop.flush()).toEqual(['Suspend! [Result]']);
346 + expect(ReactNoop.flush()).toEqual(['Suspend! [Result]', 'Loading...']);
347 ReactNoop.expire(3000);
348 await advanceTimers(3000);
349 expect(ReactNoop.flush()).toEqual(['Promise resolved [Result]', 'Result']);
@@ -443,53 +444,6 @@ describe('ReactSuspense', () => {
444 expect(ReactNoop.getChildren()).toEqual([span('(empty)')]);
445 });
446
446 - it('coalesces all async updates when in a suspended state', async () => {
447 - ReactNoop.render(
448 - <Fallback>
449 - <AsyncText text="A" />
450 - </Fallback>,
451 - );
452 - ReactNoop.flush();
453 - await advanceTimers(0);
454 - ReactNoop.flush();
455 - expect(ReactNoop.getChildren()).toEqual([span('A')]);
456 -
457 - ReactNoop.render(
458 - <Fallback>
459 - <AsyncText text="B" ms={50} />
460 - </Fallback>,
461 - );
462 - expect(ReactNoop.flush()).toEqual(['Suspend! [B]']);
463 - expect(ReactNoop.getChildren()).toEqual([span('A')]);
464 -
465 - // Advance React's virtual time so that C falls into a new expiration bucket
466 - ReactNoop.expire(1000);
467 - ReactNoop.render(
468 - <Fallback>
469 - <AsyncText text="C" ms={100} />
470 - </Fallback>,
471 - );
472 - expect(ReactNoop.flush()).toEqual([
473 - // Tries C first, since it has a later expiration time
474 - 'Suspend! [C]',
475 - // Does not retry B, because its promise has not resolved yet.
476 - ]);
477 -
478 - expect(ReactNoop.getChildren()).toEqual([span('A')]);
479 -
480 - // Unblock B
481 - await advanceTimers(90);
482 - // Even though B's promise resolved, the view is still suspended because it
483 - // coalesced with C.
484 - expect(ReactNoop.flush()).toEqual(['Promise resolved [B]']);
485 - expect(ReactNoop.getChildren()).toEqual([span('A')]);
486 -
487 - // Unblock C
488 - await advanceTimers(50);
489 - expect(ReactNoop.flush()).toEqual(['Promise resolved [C]', 'C']);
490 - expect(ReactNoop.getChildren()).toEqual([span('C')]);
491 - });
492 -
447 it('forces an expiration after an update times out', async () => {
448 ReactNoop.render(
449 <Fragment>
@@ -503,6 +457,8 @@ describe('ReactSuspense', () => {
457 expect(ReactNoop.flush()).toEqual([
458 // The async child suspends
459 'Suspend! [Async]',
460 + // Render the placeholder
461 + 'Loading...',
462 // Continue on the sibling
463 'Sync',
464 ]);
@@ -513,13 +469,10 @@ describe('ReactSuspense', () => {
469 // the update, but not by enough to flush the suspending promise.
470 ReactNoop.expire(10000);
471 await advanceTimers(10000);
516 - expect(ReactNoop.flushExpired()).toEqual([
517 - // Still suspended.
518 - 'Suspend! [Async]',
519 - // Now that the update has expired, we render the fallback UI
520 - 'Loading...',
521 - 'Sync',
522 - ]);
472 + // No additional rendering work is required, since we already prepared
473 + // the placeholder.
474 + expect(ReactNoop.flushExpired()).toEqual([]);
475 + // Should have committed the placeholder.
476 expect(ReactNoop.getChildren()).toEqual([span('Loading...'), span('Sync')]);
477
478 // Once the promise resolves, we render the suspended view
@@ -535,9 +488,9 @@ describe('ReactSuspense', () => {
488 <Fallback timeout={1000} placeholder={<Text text="Loading outer..." />}>
489 <AsyncText text="Outer content" ms={2000} />
490 <Fallback
538 - timeout={3000}
491 + timeout={2500}
492 placeholder={<Text text="Loading inner..." />}>
540 - <AsyncText text="Inner content" ms={4000} />
493 + <AsyncText text="Inner content" ms={5000} />
494 </Fallback>
495 </Fallback>
496 </Fragment>,
@@ -548,6 +501,8 @@ describe('ReactSuspense', () => {
501 // The async content suspends
502 'Suspend! [Outer content]',
503 'Suspend! [Inner content]',
504 + 'Loading inner...',
505 + 'Loading outer...',
506 ]);
507 // The update hasn't expired yet, so we commit nothing.
508 expect(ReactNoop.getChildren()).toEqual([]);
@@ -556,34 +511,36 @@ describe('ReactSuspense', () => {
511 // We should see the outer loading placeholder.
512 ReactNoop.expire(1500);
513 await advanceTimers(1500);
559 - expect(ReactNoop.flush()).toEqual([
560 - 'Sync',
561 - // Still suspended.
562 - 'Suspend! [Outer content]',
563 - 'Suspend! [Inner content]',
564 - // We attempt to fallback to the inner placeholder
565 - 'Loading inner...',
566 - // But the outer content is still suspended, so we need to fallback to
567 - // the outer placeholder.
568 - 'Loading outer...',
569 - ]);
570 -
514 + expect(ReactNoop.flush()).toEqual([]);
515 expect(ReactNoop.getChildren()).toEqual([
516 span('Sync'),
517 span('Loading outer...'),
518 ]);
519
576 - // Resolve the outer content's promise
577 - ReactNoop.expire(1000);
578 - await advanceTimers(1000);
520 + // Resolve the outer promise.
521 + ReactNoop.expire(2000);
522 + await advanceTimers(2000);
523 + // At this point, 3.5 seconds have elapsed total. The outer placeholder
524 + // timed out at 1.5 seconds. So, 2 seconds have elapsed since the
525 + // placeholder timed out. That means we still haven't reached the 2.5 second
526 + // threshold of the inner placeholder.
527 expect(ReactNoop.flush()).toEqual([
528 'Promise resolved [Outer content]',
529 'Outer content',
582 - // Inner content still hasn't loaded
530 'Suspend! [Inner content]',
531 'Loading inner...',
532 ]);
586 - // We should now see the inner fallback UI.
533 + // Don't commit the inner placeholder yet.
534 + expect(ReactNoop.getChildren()).toEqual([
535 + span('Sync'),
536 + span('Loading outer...'),
537 + ]);
538 +
539 + // Expire the inner timeout.
540 + ReactNoop.expire(500);
541 + await advanceTimers(500);
542 + // Now that 2.5 seconds have elapsed since the outer placeholder timed out,
543 + // we can timeout the inner placeholder.
544 expect(ReactNoop.getChildren()).toEqual([
545 span('Sync'),
546 span('Outer content'),
@@ -591,8 +548,8 @@ describe('ReactSuspense', () => {
548 ]);
549
550 // Finally, flush the inner promise. We should see the complete screen.
594 - ReactNoop.expire(3000);
595 - await advanceTimers(3000);
551 + ReactNoop.expire(1000);
552 + await advanceTimers(1000);
553 expect(ReactNoop.flush()).toEqual([
554 'Promise resolved [Inner content]',
555 'Inner content',
@@ -669,6 +626,7 @@ describe('ReactSuspense', () => {
626 expect(ReactNoop.flush()).toEqual([
627 // The async child suspends
628 'Suspend! [Async]',
629 + 'Loading...',
630 // Continue on the sibling
631 'Sync',
632 ]);
@@ -680,13 +638,7 @@ describe('ReactSuspense', () => {
638 // expiration time.
639 ReactNoop.expire(2000);
640 await advanceTimers(2000);
683 - expect(ReactNoop.flush()).toEqual([
684 - // Still suspended.
685 - 'Suspend! [Async]',
686 - // Now that the expiration view has timed out, we render the fallback UI
687 - 'Loading...',
688 - 'Sync',
689 - ]);
641 + expect(ReactNoop.flush()).toEqual([]);
642 expect(ReactNoop.getChildren()).toEqual([span('Loading...'), span('Sync')]);
643
644 // Once the promise resolves, we render the suspended view
@@ -695,28 +647,12 @@ describe('ReactSuspense', () => {
647 expect(ReactNoop.getChildren()).toEqual([span('Async'), span('Sync')]);
648 });
649
698 - it('throws a helpful error when a synchronous update is suspended', () => {
650 + it('throws a helpful error when an update is suspends without a placeholder', () => {
651 expect(() => {
652 ReactNoop.flushSync(() =>
653 ReactNoop.render(<Timeout>{() => <AsyncText text="Async" />}</Timeout>),
654 );
703 - }).toThrow(
704 - 'A synchronous update was suspended, but no fallback UI was provided.',
705 - );
706 - });
707 -
708 - it('throws a helpful error when an expired update is suspended', async () => {
709 - ReactNoop.render(
710 - <Timeout>{() => <AsyncText text="Async" ms={20000} />}</Timeout>,
711 - );
712 - expect(ReactNoop.flush()).toEqual(['Suspend! [Async]']);
713 - await advanceTimers(10000);
714 - expect(() => {
715 - ReactNoop.expire(10000);
716 - }).toThrow(
717 - 'An update was suspended for longer than the timeout, but no fallback ' +
718 - 'UI was provided.',
719 - );
655 + }).toThrow('An update was suspended, but no placeholder UI was provided.');
656 });
657
658 it('a Timeout component correctly handles more than one suspended child', async () => {
@@ -746,7 +682,7 @@ describe('ReactSuspense', () => {
682 <AsyncText text="Async" ms={100} />
683 </Fallback>,
684 );
749 - expect(ReactNoop.flush()).toEqual(['Suspend! [Async]']);
685 + expect(ReactNoop.flush()).toEqual(['Suspend! [Async]', 'Loading...']);
686 expect(ReactNoop.getChildren()).toEqual([]);
687
688 // Advance time by an amount slightly smaller than what's necessary to
@@ -830,16 +766,12 @@ describe('ReactSuspense', () => {
766 }
767
768 ReactNoop.render(<App />);
833 - expect(ReactNoop.flush()).toEqual(['Suspend! [Async]']);
769 + expect(ReactNoop.flush()).toEqual(['Suspend! [Async]', 'Loading...']);
770 expect(ReactNoop.getChildren()).toEqual([]);
771
772 ReactNoop.expire(2000);
773 await advanceTimers(2000);
838 - expect(ReactNoop.flush()).toEqual([
839 - 'Suspend! [Async]',
840 - 'Loading...',
841 - 'Suspend! [Async]',
842 - ]);
774 + expect(ReactNoop.flush()).toEqual([]);
775 expect(ReactNoop.getChildren()).toEqual([div(), span('Loading...')]);
776
777 ReactNoop.expire(1000);
@@ -849,109 +781,6 @@ describe('ReactSuspense', () => {
781 expect(ReactNoop.getChildren()).toEqual([div(span('Async'))]);
782 });
783
852 - describe('splitting a high-pri update into high and low', () => {
853 - React = require('react');
854 -
855 - class AsyncValue extends React.Component {
856 - state = {asyncValue: this.props.defaultValue};
857 - componentDidMount() {
858 - ReactNoop.deferredUpdates(() => {
859 - this.setState((state, props) => ({asyncValue: props.value}));
860 - });
861 - }
862 - componentDidUpdate() {
863 - if (this.props.value !== this.state.asyncValue) {
864 - ReactNoop.deferredUpdates(() => {
865 - this.setState((state, props) => ({asyncValue: props.value}));
866 - });
867 - }
868 - }
869 - render() {
870 - return this.props.children(this.state.asyncValue);
871 - }
872 - }
873 -
874 - it('coalesces async values when in a suspended state', async () => {
875 - function App(props) {
876 - const highPriText = props.text;
877 - return (
878 - <Fallback>
879 - <AsyncValue value={highPriText} defaultValue={null}>
880 - {lowPriText => (
881 - <Fragment>
882 - <Text text={`High-pri: ${highPriText}`} />
883 - {lowPriText && (
884 - <AsyncText text={`Low-pri: ${lowPriText}`} ms={100} />
885 - )}
886 - </Fragment>
887 - )}
888 - </AsyncValue>
889 - </Fallback>
890 - );
891 - }
892 -
893 - function renderAppSync(props) {
894 - ReactNoop.flushSync(() => ReactNoop.render(<App {...props} />));
895 - }
896 -
897 - // Initial mount
898 - renderAppSync({text: 'A'});
899 - expect(ReactNoop.flush()).toEqual([
900 - // First we render at high priority
901 - 'High-pri: A',
902 - // Then we come back later to render a low priority
903 - 'High-pri: A',
904 - // The low-pri view suspends
905 - 'Suspend! [Low-pri: A]',
906 - ]);
907 - expect(ReactNoop.getChildren()).toEqual([span('High-pri: A')]);
908 -
909 - // Partially flush the promise for 'A', not by enough to resolve it.
910 - await advanceTimers(99);
911 -
912 - // Advance React's virtual time so that the next update falls into a new
913 - // expiration bucket
914 - ReactNoop.expire(2000);
915 - // Update to B. At this point, the low-pri view still hasn't updated
916 - // to 'A'.
917 - renderAppSync({text: 'B'});
918 - expect(ReactNoop.flush()).toEqual([
919 - // First we render at high priority
920 - 'High-pri: B',
921 - // Then we come back later to render a low priority
922 - 'High-pri: B',
923 - // The low-pri view suspends
924 - 'Suspend! [Low-pri: B]',
925 - ]);
926 - expect(ReactNoop.getChildren()).toEqual([span('High-pri: B')]);
927 -
928 - // Flush the rest of the promise for 'A', without flushing the one
929 - // for 'B'.
930 - await advanceTimers(1);
931 - expect(ReactNoop.flush()).toEqual([
932 - // A is unblocked
933 - 'Promise resolved [Low-pri: A]',
934 - // But we don't try to render it, because there's a lower priority
935 - // update that is also suspended.
936 - ]);
937 - expect(ReactNoop.getChildren()).toEqual([span('High-pri: B')]);
938 -
939 - // Flush the remaining work.
940 - await advanceTimers(99);
941 - expect(ReactNoop.flush()).toEqual([
942 - // B is unblocked
943 - 'Promise resolved [Low-pri: B]',
944 - // Now we can continue rendering the async view
945 - 'High-pri: B',
946 - 'Low-pri: B',
947 - ]);
948 - expect(ReactNoop.getChildren()).toEqual([
949 - span('High-pri: B'),
950 - span('Low-pri: B'),
951 - ]);
952 - });
953 - });
954 -
784 describe('a Delay component', () => {
785 function Never() {
786 // Throws a promise that resolves after some arbitrarily large
@@ -1001,3 +830,11 @@ describe('ReactSuspense', () => {
830 });
831 });
832 });
833 +
834 +// TODO:
835 +// An update suspends, timeout is scheduled. Update again with different timeout.
836 +// An update suspends, a higher priority update also suspends, each has different timeouts.
837 +// Can update siblings of a timed out placeholder without suspending
838 +// Pinging during the render phase
839 +// Synchronous thenable
840 +// Start time is computed using earliest suspended time
packages/react-reconciler/src/__tests__/__snapshots__/ReactIncrementalPerf-test.internal.js.snap
+7 -3
@@ -288,10 +288,14 @@ exports[`ReactDebugFiberPerf recovers from caught errors 1`] = `
288 // Stop on Baddie and restart from Boundary
289 ⚛ (React Tree Reconciliation: Yielded)
290 ⚛ Parent [mount]
291 - ⚛ Boundary [mount]
291 + ⛔ Boundary [mount] Warning: An error was thrown inside this error boundary
292 ⚛ Parent [mount]
293 ⚛ Baddie [mount]
294
295 +⚛ (React Tree Reconciliation: Completed Root)
296 + ⚛ Parent [mount]
297 + ⚛ Boundary [mount]
298 +
299 ⚛ (React Tree Reconciliation: Yielded)
300 ⚛ Parent [mount]
301 ⛔ Boundary [mount] Warning: An error was thrown inside this error boundary
@@ -326,6 +330,8 @@ exports[`ReactDebugFiberPerf recovers from fatal errors 1`] = `
330 ⚛ Parent [mount]
331 ⚛ Baddie [mount]
332
333 +⚛ (React Tree Reconciliation: Completed Root)
334 +
335 ⚛ (React Tree Reconciliation: Yielded)
336 ⚛ Parent [mount]
337 ⚛ Baddie [mount]
@@ -406,8 +412,6 @@ exports[`ReactDebugFiberPerf warns if an in-progress update is interrupted 1`] =
412 exports[`ReactDebugFiberPerf warns if async work expires (starvation) 1`] = `
413 "⛔ (Waiting for async callback... will force flush in 5250 ms) Warning: React was blocked by main thread
414
409 -⚛ (React Tree Reconciliation: Yielded)
410 -
415 ⚛ (React Tree Reconciliation: Completed Root)
416 ⚛ Foo [mount]
417
packages/react-reconciler/src/forks/ReactFiberHostConfig.custom.js
+5
@@ -34,6 +34,8 @@ export opaque type PublicInstance = mixed; // eslint-disable-line no-undef
34 export opaque type HostContext = mixed; // eslint-disable-line no-undef
35 export opaque type UpdatePayload = mixed; // eslint-disable-line no-undef
36 export opaque type ChildSet = mixed; // eslint-disable-line no-undef
37 +export opaque type TimeoutHandle = mixed; // eslint-disable-line no-undef
38 +export opaque type NoTimeout = mixed; // eslint-disable-line no-undef
39
40 export const getPublicInstance = $$$hostConfig.getPublicInstance;
41 export const getRootHostContext = $$$hostConfig.getRootHostContext;
@@ -50,6 +52,9 @@ export const shouldDeprioritizeSubtree =
52 export const createTextInstance = $$$hostConfig.createTextInstance;
53 export const scheduleDeferredCallback = $$$hostConfig.scheduleDeferredCallback;
54 export const cancelDeferredCallback = $$$hostConfig.cancelDeferredCallback;
55 +export const scheduleTimeout = $$$hostConfig.setTimeout;
56 +export const cancelTimeout = $$$hostConfig.clearTimeout;
57 +export const noTimeout = $$$hostConfig.noTimeout;
58 export const now = $$$hostConfig.now;
59 export const isPrimaryRenderer = $$$hostConfig.isPrimaryRenderer;
60 export const supportsMutation = $$$hostConfig.supportsMutation;
packages/react-test-renderer/src/ReactTestHostConfig.js
+6
@@ -32,6 +32,8 @@ export type PublicInstance = Instance | TextInstance;
32 export type HostContext = Object;
33 export type UpdatePayload = Object;
34 export type ChildSet = void; // Unused
35 +export type TimeoutHandle = TimeoutID;
36 +export type NoTimeout = -1;
37
38 export * from 'shared/HostConfigWithNoPersistence';
39 export * from 'shared/HostConfigWithNoHydration';
@@ -187,6 +189,10 @@ export const scheduleDeferredCallback =
189 export const cancelDeferredCallback =
190 TestRendererScheduling.cancelDeferredCallback;
191
192 +export const scheduleTimeout = setTimeout;
193 +export const cancelTimeout = clearTimeout;
194 +export const noTimeout = -1;
195 +
196 // -------------------
197 // Mutation
198 // -------------------