Decouple expiration times and transition timeouts (#17920)
We currently use the expiration time to represent the timeout of a transition. Since we intend to stop treating work priority as a timeline, we can no longer use this trick. In this commit, I've changed it to store the event time on the update object instead. Long term, we will store event time on the root as a map of transition -> event time. I'm only storing it on the update object as a temporary workaround to unblock the rest of the changes.
Andrew Clark committed
Apr 8, 2020 at 15:25 UTC
b04c7fa28cadaa76fd8390963fe2b8d2a7962c50
11 files changed
+136
-145
packages/react-reconciler/src/ReactFiberClassComponent.new.js
+3
-3
@@ -196,7 +196,7 @@ const classComponentUpdater = {
196
suspenseConfig,
197
);
198
199
- const update = createUpdate(expirationTime, suspenseConfig);
199
+ const update = createUpdate(currentTime, expirationTime, suspenseConfig);
200
update.payload = payload;
201
if (callback !== undefined && callback !== null) {
202
if (__DEV__) {
@@ -218,7 +218,7 @@ const classComponentUpdater = {
218
suspenseConfig,
219
);
220
221
- const update = createUpdate(expirationTime, suspenseConfig);
221
+ const update = createUpdate(currentTime, expirationTime, suspenseConfig);
222
update.tag = ReplaceState;
223
update.payload = payload;
224
@@ -242,7 +242,7 @@ const classComponentUpdater = {
242
suspenseConfig,
243
);
244
245
- const update = createUpdate(expirationTime, suspenseConfig);
245
+ const update = createUpdate(currentTime, expirationTime, suspenseConfig);
246
update.tag = ForceUpdate;
247
248
if (callback !== undefined && callback !== null) {
packages/react-reconciler/src/ReactFiberExpirationTime.new.js
+6
-7
@@ -35,6 +35,8 @@ export const Idle = 2;
35
// Continuous Hydration is slightly higher than Idle and is used to increase
36
// priority of hover targets.
37
export const ContinuousHydration = 3;
38
+export const LongTransition = 49999;
39
+export const ShortTransition = 99999;
40
export const Sync = MAX_SIGNED_31_BIT_INT;
41
export const Batched = Sync - 1;
42
@@ -84,16 +86,13 @@ export function computeAsyncExpiration(
86
);
87
}
88
87
-export function computeSuspenseExpiration(
89
+export function computeSuspenseTimeout(
90
currentTime: ExpirationTime,
91
timeoutMs: number,
92
): ExpirationTime {
91
- // TODO: Should we warn if timeoutMs is lower than the normal pri expiration time?
92
- return computeExpirationBucket(
93
- currentTime,
94
- timeoutMs,
95
- LOW_PRIORITY_BATCH_SIZE,
96
- );
93
+ const currentTimeMs = expirationTimeToMs(currentTime);
94
+ const deadlineMs = currentTimeMs + timeoutMs;
95
+ return msToExpirationTime(deadlineMs);
96
}
97
98
// We intentionally set a higher expiration time for interactive updates in
packages/react-reconciler/src/ReactFiberHooks.new.js
+11
-6
@@ -96,6 +96,9 @@ import {getIsRendering} from './ReactCurrentFiber';
96
const {ReactCurrentDispatcher, ReactCurrentBatchConfig} = ReactSharedInternals;
97
98
type Update<S, A> = {|
99
+ // TODO: Temporary field. Will remove this by storing a map of
100
+ // transition -> start time on the root.
101
+ eventTime: ExpirationTime,
102
expirationTime: ExpirationTime,
103
suspenseConfig: null | SuspenseConfig,
104
action: A,
@@ -715,14 +718,17 @@ function updateReducer<S, I, A>(
718
let newBaseQueueLast = null;
719
let update = first;
720
do {
721
+ const suspenseConfig = update.suspenseConfig;
722
const updateExpirationTime = update.expirationTime;
723
+ const updateEventTime = update.eventTime;
724
if (updateExpirationTime < renderExpirationTime) {
725
// Priority is insufficient. Skip this update. If this is the first
726
// skipped update, the previous update/state is the new base
727
// update/state.
728
const clone: Update<S, A> = {
724
- expirationTime: update.expirationTime,
725
- suspenseConfig: update.suspenseConfig,
729
+ eventTime: updateEventTime,
730
+ expirationTime: updateExpirationTime,
731
+ suspenseConfig: suspenseConfig,
732
action: update.action,
733
eagerReducer: update.eagerReducer,
734
eagerState: update.eagerState,
@@ -744,6 +750,7 @@ function updateReducer<S, I, A>(
750
751
if (newBaseQueueLast !== null) {
752
const clone: Update<S, A> = {
753
+ eventTime: updateEventTime,
754
expirationTime: Sync, // This update is going to be committed so we never want uncommit it.
755
suspenseConfig: update.suspenseConfig,
756
action: update.action,
@@ -760,10 +767,7 @@ function updateReducer<S, I, A>(
767
// TODO: We should skip this update if it was already committed but currently
768
// we have no way of detecting the difference between a committed and suspended
769
// update here.
763
- markRenderEventTimeAndConfig(
764
- updateExpirationTime,
765
- update.suspenseConfig,
766
- );
770
+ markRenderEventTimeAndConfig(updateEventTime, suspenseConfig);
771
772
// Process this update.
773
if (update.eagerReducer === reducer) {
@@ -1651,6 +1655,7 @@ function dispatchAction<S, A>(
1655
);
1656
1657
const update: Update<S, A> = {
1658
+ eventTime: currentTime,
1659
expirationTime,
1660
suspenseConfig,
1661
action,
packages/react-reconciler/src/ReactFiberNewContext.new.js
+1
-1
@@ -206,7 +206,7 @@ export function propagateContextChange(
206
207
if (fiber.tag === ClassComponent) {
208
// Schedule a force update on the work-in-progress.
209
- const update = createUpdate(renderExpirationTime, null);
209
+ const update = createUpdate(NoWork, renderExpirationTime, null);
210
update.tag = ForceUpdate;
211
// TODO: Because we don't have a work-in-progress, this will add the
212
// update to the current fiber, too, which means it will persist even if
packages/react-reconciler/src/ReactFiberReconciler.new.js
+1
-1
@@ -271,7 +271,7 @@ export function updateContainer(
271
}
272
}
273
274
- const update = createUpdate(expirationTime, suspenseConfig);
274
+ const update = createUpdate(currentTime, expirationTime, suspenseConfig);
275
// Caution: React DevTools currently depends on this property
276
// being called "element".
277
update.payload = {element};
packages/react-reconciler/src/ReactFiberThrow.new.js
+4
-4
@@ -56,7 +56,7 @@ import {
56
} from './ReactFiberWorkLoop.new';
57
import {logCapturedError} from './ReactFiberErrorLogger';
58
59
-import {Sync} from './ReactFiberExpirationTime.new';
59
+import {Sync, NoWork} from './ReactFiberExpirationTime.new';
60
61
const PossiblyWeakMap = typeof WeakMap === 'function' ? WeakMap : Map;
62
@@ -65,7 +65,7 @@ function createRootErrorUpdate(
65
errorInfo: CapturedValue<mixed>,
66
expirationTime: ExpirationTime,
67
): Update<mixed> {
68
- const update = createUpdate(expirationTime, null);
68
+ const update = createUpdate(NoWork, expirationTime, null);
69
// Unmount the root by rendering null.
70
update.tag = CaptureUpdate;
71
// Caution: React DevTools currently depends on this property
@@ -84,7 +84,7 @@ function createClassErrorUpdate(
84
errorInfo: CapturedValue<mixed>,
85
expirationTime: ExpirationTime,
86
): Update<mixed> {
87
- const update = createUpdate(expirationTime, null);
87
+ const update = createUpdate(NoWork, expirationTime, null);
88
update.tag = CaptureUpdate;
89
const getDerivedStateFromError = fiber.type.getDerivedStateFromError;
90
if (typeof getDerivedStateFromError === 'function') {
@@ -260,7 +260,7 @@ function throwException(
260
// When we try rendering again, we should not reuse the current fiber,
261
// since it's known to be in an inconsistent state. Use a force update to
262
// prevent a bail out.
263
- const update = createUpdate(Sync, null);
263
+ const update = createUpdate(NoWork, Sync, null);
264
update.tag = ForceUpdate;
265
enqueueUpdate(sourceFiber, update);
266
}
packages/react-reconciler/src/ReactFiberWorkLoop.new.js
+54
-76
@@ -119,11 +119,13 @@ import {
119
expirationTimeToMs,
120
computeInteractiveExpiration,
121
computeAsyncExpiration,
122
- computeSuspenseExpiration,
123
- inferPriorityFromExpirationTime,
122
+ computeSuspenseTimeout,
123
LOW_PRIORITY_EXPIRATION,
124
+ inferPriorityFromExpirationTime,
125
Batched,
126
Idle,
127
+ ShortTransition,
128
+ LongTransition,
129
} from './ReactFiberExpirationTime.new';
130
import {beginWork as originalBeginWork} from './ReactFiberBeginWork.new';
131
import {completeWork} from './ReactFiberCompleteWork.new';
@@ -220,7 +222,7 @@ let workInProgressRootFatalError: mixed = null;
222
// This is conceptually a time stamp but expressed in terms of an ExpirationTime
223
// because we deal mostly with expiration times in the hot path, so this avoids
224
// the conversion happening in the hot path.
223
-let workInProgressRootLatestProcessedExpirationTime: ExpirationTime = Sync;
225
+let workInProgressRootLatestProcessedEventTime: ExpirationTime = Sync;
226
let workInProgressRootLatestSuspenseTimeout: ExpirationTime = Sync;
227
let workInProgressRootCanSuspendUsingConfig: null | SuspenseConfig = null;
228
// The work left over by components that were visited during this render. Only
@@ -328,11 +330,16 @@ export function computeExpirationForFiber(
330
331
let expirationTime;
332
if (suspenseConfig !== null) {
331
- // Compute an expiration time based on the Suspense timeout.
332
- expirationTime = computeSuspenseExpiration(
333
- currentTime,
334
- suspenseConfig.timeoutMs | 0 || LOW_PRIORITY_EXPIRATION,
335
- );
333
+ // If there's a SuspenseConfig, choose an expiration time that's lower
334
+ // priority than a normal concurrent update (regardless of the current
335
+ // Scheduler priority.) Timeouts larger than 10 seconds move one level lower
336
+ // than that.
337
+ const timeoutMs = suspenseConfig.timeoutMs;
338
+ expirationTime =
339
+ // TODO: This will coerce numbers larger than 31 bits to 0.
340
+ timeoutMs === undefined || (timeoutMs | 0) < 10000
341
+ ? ShortTransition
342
+ : LongTransition;
343
} else {
344
// Compute an expiration time based on the Scheduler priority.
345
switch (priorityLevel) {
@@ -379,7 +386,7 @@ export function scheduleUpdateOnFiber(
386
const root = markUpdateTimeFromFiberToRoot(fiber, expirationTime);
387
if (root === null) {
388
warnAboutUpdateOnUnmountedFiberInDEV(fiber);
382
- return;
389
+ return null;
390
}
391
392
// TODO: computeExpirationForFiber also reads the priority. Pass the
@@ -728,7 +735,7 @@ function finishConcurrentRender(
735
// have a new loading state ready. We want to ensure that we commit
736
// that as soon as possible.
737
const hasNotProcessedNewUpdates =
731
- workInProgressRootLatestProcessedExpirationTime === Sync;
738
+ workInProgressRootLatestProcessedEventTime === Sync;
739
if (
740
hasNotProcessedNewUpdates &&
741
// do not delay if we're inside an act() scope
@@ -836,34 +843,19 @@ function finishConcurrentRender(
843
// can use as the timeout.
844
msUntilTimeout =
845
expirationTimeToMs(workInProgressRootLatestSuspenseTimeout) - now();
839
- } else if (workInProgressRootLatestProcessedExpirationTime === Sync) {
846
+ } else if (workInProgressRootLatestProcessedEventTime === Sync) {
847
// This should never normally happen because only new updates
848
// cause delayed states, so we should have processed something.
849
// However, this could also happen in an offscreen tree.
850
msUntilTimeout = 0;
851
} else {
845
- // If we don't have a suspense config, we're going to use a
846
- // heuristic to determine how long we can suspend.
847
- const eventTimeMs: number = inferTimeFromExpirationTime(
848
- workInProgressRootLatestProcessedExpirationTime,
852
+ // If we didn't process a suspense config, compute a JND based on
853
+ // the amount of time elapsed since the most recent event time.
854
+ const eventTimeMs = expirationTimeToMs(
855
+ workInProgressRootLatestProcessedEventTime,
856
);
850
- const currentTimeMs = now();
851
- const timeUntilExpirationMs =
852
- expirationTimeToMs(expirationTime) - currentTimeMs;
853
- let timeElapsed = currentTimeMs - eventTimeMs;
854
- if (timeElapsed < 0) {
855
- // We get this wrong some time since we estimate the time.
856
- timeElapsed = 0;
857
- }
858
-
859
- msUntilTimeout = jnd(timeElapsed) - timeElapsed;
860
-
861
- // Clamp the timeout to the expiration time. TODO: Once the
862
- // event time is exact instead of inferred from expiration time
863
- // we don't need this.
864
- if (timeUntilExpirationMs < msUntilTimeout) {
865
- msUntilTimeout = timeUntilExpirationMs;
866
- }
857
+ const timeElapsedMs = now() - eventTimeMs;
858
+ msUntilTimeout = jnd(timeElapsedMs) - timeElapsedMs;
859
}
860
861
// Don't bother with a very short suspense time.
@@ -891,7 +883,7 @@ function finishConcurrentRender(
883
flushSuspenseFallbacksInTests &&
884
IsThisRendererActing.current
885
) &&
894
- workInProgressRootLatestProcessedExpirationTime !== Sync &&
886
+ workInProgressRootLatestProcessedEventTime !== Sync &&
887
workInProgressRootCanSuspendUsingConfig !== null
888
) {
889
// If we have exceeded the minimum loading delay, which probably
@@ -899,7 +891,7 @@ function finishConcurrentRender(
891
// a bit longer to ensure that the spinner is shown for
892
// enough time.
893
const msUntilTimeout = computeMsUntilSuspenseLoadingDelay(
902
- workInProgressRootLatestProcessedExpirationTime,
894
+ workInProgressRootLatestProcessedEventTime,
895
expirationTime,
896
workInProgressRootCanSuspendUsingConfig,
897
);
@@ -1193,7 +1185,7 @@ function prepareFreshStack(root, expirationTime) {
1185
renderExpirationTime = expirationTime;
1186
workInProgressRootExitStatus = RootIncomplete;
1187
workInProgressRootFatalError = null;
1196
- workInProgressRootLatestProcessedExpirationTime = Sync;
1188
+ workInProgressRootLatestProcessedEventTime = Sync;
1189
workInProgressRootLatestSuspenseTimeout = Sync;
1190
workInProgressRootCanSuspendUsingConfig = null;
1191
workInProgressRootNextUnprocessedUpdateTime = NoWork;
@@ -1298,23 +1290,33 @@ export function markCommitTimeOfFallback() {
1290
}
1291
1292
export function markRenderEventTimeAndConfig(
1301
- expirationTime: ExpirationTime,
1293
+ eventTime: ExpirationTime,
1294
suspenseConfig: null | SuspenseConfig,
1295
): void {
1304
- if (
1305
- expirationTime < workInProgressRootLatestProcessedExpirationTime &&
1306
- expirationTime > Idle
1307
- ) {
1308
- workInProgressRootLatestProcessedExpirationTime = expirationTime;
1309
- }
1310
- if (suspenseConfig !== null) {
1311
- if (
1312
- expirationTime < workInProgressRootLatestSuspenseTimeout &&
1313
- expirationTime > Idle
1314
- ) {
1315
- workInProgressRootLatestSuspenseTimeout = expirationTime;
1316
- // Most of the time we only have one config and getting wrong is not bad.
1317
- workInProgressRootCanSuspendUsingConfig = suspenseConfig;
1296
+ // Anything lower pri than Idle is not an update, so we should skip it.
1297
+ if (eventTime > Idle) {
1298
+ // Track the most recent event time of all updates processed in this batch.
1299
+ if (workInProgressRootLatestProcessedEventTime > eventTime) {
1300
+ workInProgressRootLatestProcessedEventTime = eventTime;
1301
+ }
1302
+
1303
+ // Track the largest/latest timeout deadline in this batch.
1304
+ // TODO: If there are two transitions in the same batch, shouldn't we
1305
+ // choose the smaller one? Maybe this is because when an intermediate
1306
+ // transition is superseded, we should ignore its suspense config, but
1307
+ // we don't currently.
1308
+ if (suspenseConfig !== null) {
1309
+ // If `timeoutMs` is not specified, we default to 5 seconds. We have to
1310
+ // resolve this default here because `suspenseConfig` is owned
1311
+ // by userspace.
1312
+ // TODO: Store this on the root instead (transition -> timeoutMs)
1313
+ // TODO: Should this default to a JND instead?
1314
+ const timeoutMs = suspenseConfig.timeoutMs | 0 || LOW_PRIORITY_EXPIRATION;
1315
+ const timeoutTime = computeSuspenseTimeout(eventTime, timeoutMs);
1316
+ if (timeoutTime < workInProgressRootLatestSuspenseTimeout) {
1317
+ workInProgressRootLatestSuspenseTimeout = timeoutTime;
1318
+ workInProgressRootCanSuspendUsingConfig = suspenseConfig;
1319
+ }
1320
}
1321
}
1322
}
@@ -1372,27 +1374,6 @@ export function renderHasNotSuspendedYet(): boolean {
1374
return workInProgressRootExitStatus === RootIncomplete;
1375
}
1376
1375
-function inferTimeFromExpirationTime(expirationTime: ExpirationTime): number {
1376
- // We don't know exactly when the update was scheduled, but we can infer an
1377
- // approximate start time from the expiration time.
1378
- const earliestExpirationTimeMs = expirationTimeToMs(expirationTime);
1379
- return earliestExpirationTimeMs - LOW_PRIORITY_EXPIRATION;
1380
-}
1381
-
1382
-function inferTimeFromExpirationTimeWithSuspenseConfig(
1383
- expirationTime: ExpirationTime,
1384
- suspenseConfig: SuspenseConfig,
1385
-): number {
1386
- // We don't know exactly when the update was scheduled, but we can infer an
1387
- // approximate start time from the expiration time by subtracting the timeout
1388
- // that was added to the event time.
1389
- const earliestExpirationTimeMs = expirationTimeToMs(expirationTime);
1390
- return (
1391
- earliestExpirationTimeMs -
1392
- (suspenseConfig.timeoutMs | 0 || LOW_PRIORITY_EXPIRATION)
1393
- );
1394
-}
1395
-
1377
function renderRootSync(root, expirationTime) {
1378
const prevExecutionContext = executionContext;
1379
executionContext |= RenderContext;
@@ -2540,7 +2521,7 @@ export function pingSuspendedRoot(
2521
if (
2522
workInProgressRootExitStatus === RootSuspendedWithDelay ||
2523
(workInProgressRootExitStatus === RootSuspended &&
2543
- workInProgressRootLatestProcessedExpirationTime === Sync &&
2524
+ workInProgressRootLatestProcessedEventTime === Sync &&
2525
now() - globalMostRecentFallbackTime < FALLBACK_THROTTLE_MS)
2526
) {
2527
// Restart from the root. Don't need to schedule a ping because
@@ -2679,10 +2660,7 @@ function computeMsUntilSuspenseLoadingDelay(
2660
2661
// Compute the time until this render pass would expire.
2662
const currentTimeMs: number = now();
2682
- const eventTimeMs: number = inferTimeFromExpirationTimeWithSuspenseConfig(
2683
- mostRecentEventTime,
2684
- suspenseConfig,
2685
- );
2663
+ const eventTimeMs: number = expirationTimeToMs(mostRecentEventTime);
2664
const timeElapsed = currentTimeMs - eventTimeMs;
2665
if (timeElapsed <= busyDelayMs) {
2666
// If we haven't yet waited longer than the initial delay, we don't
packages/react-reconciler/src/ReactUpdateQueue.new.js
+14
-6
@@ -110,6 +110,9 @@ import {getCurrentPriorityLevel} from './SchedulerWithReactIntegration.new';
110
import {disableLogs, reenableLogs} from 'shared/ConsolePatchingDev';
111
112
export type Update<State> = {|
113
+ // TODO: Temporary field. Will remove this by storing a map of
114
+ // transition -> event time on the root.
115
+ eventTime: ExpirationTime,
116
expirationTime: ExpirationTime,
117
suspenseConfig: null | SuspenseConfig,
118
@@ -123,7 +126,9 @@ export type Update<State> = {|
126
priority?: ReactPriorityLevel,
127
|};
128
126
-type SharedQueue<State> = {|pending: Update<State> | null|};
129
+type SharedQueue<State> = {|
130
+ pending: Update<State> | null,
131
+|};
132
133
export type UpdateQueue<State> = {|
134
baseState: State,
@@ -187,10 +192,12 @@ export function cloneUpdateQueue<State>(
192
}
193
194
export function createUpdate(
195
+ eventTime: ExpirationTime,
196
expirationTime: ExpirationTime,
197
suspenseConfig: null | SuspenseConfig,
198
): Update<*> {
199
const update: Update<*> = {
200
+ eventTime,
201
expirationTime,
202
suspenseConfig,
203
@@ -268,6 +275,7 @@ export function enqueueCapturedUpdate<State>(
275
let update = firstBaseUpdate;
276
do {
277
const clone: Update<State> = {
278
+ eventTime: update.eventTime,
279
expirationTime: update.expirationTime,
280
suspenseConfig: update.suspenseConfig,
281
@@ -471,13 +479,15 @@ export function processUpdateQueue<State>(
479
480
let update = firstBaseUpdate;
481
do {
482
+ const updateEventTime = update.eventTime;
483
const updateExpirationTime = update.expirationTime;
484
if (updateExpirationTime < renderExpirationTime) {
485
// Priority is insufficient. Skip this update. If this is the first
486
// skipped update, the previous update/state is the new base
487
// update/state.
488
const clone: Update<State> = {
480
- expirationTime: update.expirationTime,
489
+ eventTime: updateEventTime,
490
+ expirationTime: updateExpirationTime,
491
suspenseConfig: update.suspenseConfig,
492
493
tag: update.tag,
@@ -501,6 +511,7 @@ export function processUpdateQueue<State>(
511
512
if (newLastBaseUpdate !== null) {
513
const clone: Update<State> = {
514
+ eventTime: updateEventTime,
515
expirationTime: Sync, // This update is going to be committed so we never want uncommit it.
516
suspenseConfig: update.suspenseConfig,
517
@@ -519,10 +530,7 @@ export function processUpdateQueue<State>(
530
// TODO: We should skip this update if it was already committed but currently
531
// we have no way of detecting the difference between a committed and suspended
532
// update here.
522
- markRenderEventTimeAndConfig(
523
- updateExpirationTime,
524
- update.suspenseConfig,
525
- );
533
+ markRenderEventTimeAndConfig(updateEventTime, update.suspenseConfig);
534
535
// Process this update.
536
newState = getStateFromUpdate(
packages/react-reconciler/src/__tests__/ReactHooksWithNoopRenderer-test.internal.js
+10
-4
@@ -2843,6 +2843,9 @@ describe('ReactHooksWithNoopRenderer', () => {
2843
span('Before... Pending: true'),
2844
]);
2845
2846
+ // Resolve the promise. The whole tree has now completed. However,
2847
+ // because we exceeded the busy threshold, we won't commit the
2848
+ // result yet.
2849
Scheduler.unstable_advanceTime(1000);
2850
await advanceTimers(1000);
2851
expect(Scheduler).toHaveYielded([
@@ -2853,13 +2856,16 @@ describe('ReactHooksWithNoopRenderer', () => {
2856
span('Before... Pending: true'),
2857
]);
2858
2856
- Scheduler.unstable_advanceTime(1000);
2857
- await advanceTimers(1000);
2859
+ // Advance time until just before the `busyMinDuration` threshold.
2860
+ Scheduler.unstable_advanceTime(999);
2861
+ await advanceTimers(999);
2862
expect(ReactNoop.getChildren()).toEqual([
2863
span('Before... Pending: true'),
2864
]);
2861
- Scheduler.unstable_advanceTime(250);
2862
- await advanceTimers(250);
2865
+
2866
+ // Advance time just a bit more. Now we complete the transition.
2867
+ Scheduler.unstable_advanceTime(300);
2868
+ await advanceTimers(300);
2869
expect(ReactNoop.getChildren()).toEqual([
2870
span('After... Pending: false'),
2871
]);
packages/react-reconciler/src/__tests__/ReactSuspensePlaceholder-test.internal.js
+7
@@ -498,6 +498,13 @@ describe('ReactSuspensePlaceholder', () => {
498
</Suspense>
499
</>,
500
);
501
+
502
+ // TODO: This is here only to shift us into the next JND bucket. A
503
+ // consequence of AsyncText relying on the same timer queue as React's
504
+ // internal Suspense timer. We should decouple our AsyncText helpers
505
+ // from timers.
506
+ Scheduler.unstable_advanceTime(100);
507
+
508
expect(Scheduler).toFlushAndYield([
509
'App',
510
'Suspending',
packages/react-reconciler/src/__tests__/ReactSuspenseWithNoopRenderer-test.internal.js
+25
-37
@@ -2580,22 +2580,17 @@ describe('ReactSuspenseWithNoopRenderer', () => {
2580
// but at this scope we should suspend for longer.
2581
Scheduler.unstable_next(() => ReactNoop.render(<App page="C" />));
2582
},
2583
- {timeoutMs: 2000},
2583
+ {timeoutMs: 60000},
2584
);
2585
- expect(Scheduler).toFlushAndYield([
2586
- 'Suspend! [C]',
2587
- 'Loading...',
2588
- 'Suspend! [C]',
2589
- 'Loading...',
2590
- ]);
2585
+ expect(Scheduler).toFlushAndYield(['B', 'Suspend! [C]', 'Loading...']);
2586
expect(ReactNoop.getChildren()).toEqual([span('B')]);
2587
Scheduler.unstable_advanceTime(1200);
2588
await advanceTimers(1200);
2589
// Even after a second, we have still not yet flushed the loading state.
2590
expect(ReactNoop.getChildren()).toEqual([span('B')]);
2596
- Scheduler.unstable_advanceTime(1200);
2597
- await advanceTimers(1200);
2598
- // After the two second timeout we show the loading state.
2591
+ Scheduler.unstable_advanceTime(60000);
2592
+ await advanceTimers(60000);
2593
+ // After the timeout we show the loading state.
2594
expect(ReactNoop.getChildren()).toEqual([
2595
hiddenSpan('B'),
2596
span('Loading...'),
@@ -2698,23 +2693,6 @@ describe('ReactSuspenseWithNoopRenderer', () => {
2693
});
2694
2695
it('supports delaying a busy spinner from disappearing', async () => {
2701
- function useLoadingIndicator(config) {
2702
- const [isLoading, setLoading] = React.useState(false);
2703
- const start = React.useCallback(
2704
- cb => {
2705
- setLoading(true);
2706
- Scheduler.unstable_next(() =>
2707
- React.unstable_withSuspenseConfig(() => {
2708
- setLoading(false);
2709
- cb();
2710
- }, config),
2711
- );
2712
- },
2713
- [setLoading, config],
2714
- );
2715
- return [isLoading, start];
2716
- }
2717
-
2696
const SUSPENSE_CONFIG = {
2697
timeoutMs: 10000,
2698
busyDelayMs: 500,
@@ -2725,7 +2703,7 @@ describe('ReactSuspenseWithNoopRenderer', () => {
2703
2704
function App() {
2705
const [page, setPage] = React.useState('A');
2728
- const [isLoading, startLoading] = useLoadingIndicator(SUSPENSE_CONFIG);
2706
+ const [startLoading, isLoading] = React.useTransition(SUSPENSE_CONFIG);
2707
transitionToPage = nextPage => startLoading(() => setPage(nextPage));
2708
return (
2709
<Fragment>
@@ -2907,7 +2885,7 @@ describe('ReactSuspenseWithNoopRenderer', () => {
2885
() => {
2886
ReactNoop.render(<App showContent={true} />);
2887
},
2910
- {timeoutMs: 2000},
2888
+ {timeoutMs: 2500},
2889
);
2890
2891
expect(Scheduler).toFlushAndYield(['Suspend! [A]', 'Loading...']);
@@ -3763,12 +3741,17 @@ describe('ReactSuspenseWithNoopRenderer', () => {
3741
it.experimental(
3742
'does not get stuck in pending state with render phase updates',
3743
async () => {
3766
- let setTextWithTransition;
3744
+ let setTextWithShortTransition;
3745
+ let setTextWithLongTransition;
3746
3747
function App() {
3769
- const [startTransition, isPending] = React.useTransition({
3748
+ const [startShortTransition, isPending1] = React.useTransition({
3749
+ timeoutMs: 5000,
3750
+ });
3751
+ const [startLongTransition, isPending2] = React.useTransition({
3752
timeoutMs: 30000,
3753
});
3754
+ const isPending = isPending1 || isPending2;
3755
const [text, setText] = React.useState('');
3756
const [mirror, setMirror] = React.useState('');
3757
@@ -3777,8 +3760,13 @@ describe('ReactSuspenseWithNoopRenderer', () => {
3760
setMirror(text);
3761
}
3762
3780
- setTextWithTransition = value => {
3781
- startTransition(() => {
3763
+ setTextWithShortTransition = value => {
3764
+ startShortTransition(() => {
3765
+ setText(value);
3766
+ });
3767
+ };
3768
+ setTextWithLongTransition = value => {
3769
+ startLongTransition(() => {
3770
setText(value);
3771
});
3772
};
@@ -3808,9 +3796,7 @@ describe('ReactSuspenseWithNoopRenderer', () => {
3796
3797
// Update to "a". That will suspend.
3798
await ReactNoop.act(async () => {
3811
- setTextWithTransition('a');
3812
- // Let it expire. This is important for the repro.
3813
- Scheduler.unstable_advanceTime(1000);
3799
+ setTextWithShortTransition('a');
3800
expect(Scheduler).toFlushAndYield([
3801
'Pending...',
3802
'',
@@ -3828,10 +3814,12 @@ describe('ReactSuspenseWithNoopRenderer', () => {
3814
3815
// Update to "b". That will suspend, too.
3816
await ReactNoop.act(async () => {
3831
- setTextWithTransition('b');
3817
+ setTextWithLongTransition('b');
3818
expect(Scheduler).toFlushAndYield([
3819
// Neither is resolved yet.
3820
'Pending...',
3821
+ '',
3822
+ 'Pending...',
3823
'Suspend! [a]',
3824
'Loading...',
3825
'Suspend! [b]',