Skip to content

Commit cf22a72

Browse files
committed
Only show most recent transition, per queue
When multiple transitions update the same queue, only the most recent one should be allowed to finish. Do not display intermediate states. For example, if you click on multiple tabs in quick succession, we should not switch to any tab that isn't the last one you clicked.
1 parent 5626beb commit cf22a72

File tree

5 files changed

+470
-16
lines changed

5 files changed

+470
-16
lines changed

packages/react-reconciler/src/ReactFiberHooks.js

Lines changed: 35 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,7 @@ import warning from 'shared/warning';
5252
import getComponentName from 'shared/getComponentName';
5353
import is from 'shared/objectIs';
5454
import {markWorkInProgressReceivedUpdate} from './ReactFiberBeginWork';
55+
import {SuspendOnTask} from './ReactFiberThrow';
5556
import {requestCurrentSuspenseConfig} from './ReactFiberSuspenseConfig';
5657
import {
5758
startTransition,
@@ -761,7 +762,10 @@ function updateReducer<S, I, A>(
761762
let prevUpdate = baseUpdate;
762763
let update = first;
763764
let didSkip = false;
765+
let lastProcessedTransitionTime = NoWork;
766+
let lastSkippedTransitionTime = NoWork;
764767
do {
768+
const suspenseConfig = update.suspenseConfig;
765769
const updateExpirationTime = update.expirationTime;
766770
if (updateExpirationTime < renderExpirationTime) {
767771
// Priority is insufficient. Skip this update. If this is the first
@@ -777,6 +781,16 @@ function updateReducer<S, I, A>(
777781
remainingExpirationTime = updateExpirationTime;
778782
markUnprocessedUpdateTime(remainingExpirationTime);
779783
}
784+
785+
if (suspenseConfig !== null) {
786+
// This update is part of a transition
787+
if (
788+
lastSkippedTransitionTime === NoWork ||
789+
lastSkippedTransitionTime > updateExpirationTime
790+
) {
791+
lastSkippedTransitionTime = updateExpirationTime;
792+
}
793+
}
780794
} else {
781795
// This update does have sufficient priority.
782796
// Mark the event time of this update as relevant to this render pass.
@@ -785,10 +799,7 @@ function updateReducer<S, I, A>(
785799
// TODO: We should skip this update if it was already committed but currently
786800
// we have no way of detecting the difference between a committed and suspended
787801
// update here.
788-
markRenderEventTimeAndConfig(
789-
updateExpirationTime,
790-
update.suspenseConfig,
791-
);
802+
markRenderEventTimeAndConfig(updateExpirationTime, suspenseConfig);
792803
793804
// Process this update.
794805
if (update.eagerReducer === reducer) {
@@ -799,12 +810,32 @@ function updateReducer<S, I, A>(
799810
const action = update.action;
800811
newState = reducer(newState, action);
801812
}
813+
814+
if (suspenseConfig !== null) {
815+
// This update is part of a transition
816+
if (
817+
lastProcessedTransitionTime === NoWork ||
818+
lastProcessedTransitionTime > updateExpirationTime
819+
) {
820+
lastProcessedTransitionTime = updateExpirationTime;
821+
}
822+
}
802823
}
803824
804825
prevUpdate = update;
805826
update = update.next;
806827
} while (update !== null && update !== first);
807828
829+
if (
830+
lastProcessedTransitionTime !== NoWork &&
831+
lastSkippedTransitionTime !== NoWork
832+
) {
833+
// There are multiple updates scheduled on this queue, but only some of
834+
// them were processed. To avoid showing an intermediate state, abort
835+
// the current render and restart at a level that includes them all.
836+
throw SuspendOnTask(lastSkippedTransitionTime);
837+
}
838+
808839
if (!didSkip) {
809840
newBaseUpdate = prevUpdate;
810841
newBaseState = newState;

packages/react-reconciler/src/ReactFiberThrow.js

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,14 @@ import {Sync} from './ReactFiberExpirationTime';
6262

6363
const PossiblyWeakMap = typeof WeakMap === 'function' ? WeakMap : Map;
6464

65+
// Throw an object with this type to abort the current render and restart at
66+
// a different level.
67+
export function SuspendOnTask(expirationTime: ExpirationTime) {
68+
return {
69+
_retryTime: expirationTime,
70+
};
71+
}
72+
6573
function createRootErrorUpdate(
6674
fiber: Fiber,
6775
errorInfo: CapturedValue<mixed>,

packages/react-reconciler/src/ReactFiberWorkLoop.js

Lines changed: 36 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -200,13 +200,14 @@ const LegacyUnbatchedContext = /* */ 0b001000;
200200
const RenderContext = /* */ 0b010000;
201201
const CommitContext = /* */ 0b100000;
202202

203-
type RootExitStatus = 0 | 1 | 2 | 3 | 4 | 5;
203+
type RootExitStatus = 0 | 1 | 2 | 3 | 4 | 5 | 6;
204204
const RootIncomplete = 0;
205205
const RootFatalErrored = 1;
206-
const RootErrored = 2;
207-
const RootSuspended = 3;
208-
const RootSuspendedWithDelay = 4;
209-
const RootCompleted = 5;
206+
const RootSuspendedOnTask = 2;
207+
const RootErrored = 3;
208+
const RootSuspended = 4;
209+
const RootSuspendedWithDelay = 5;
210+
const RootCompleted = 6;
210211

211212
export type Thenable = {
212213
then(resolve: () => mixed, reject?: () => mixed): Thenable | void,
@@ -237,7 +238,7 @@ let workInProgressRootCanSuspendUsingConfig: null | SuspenseConfig = null;
237238
// The work left over by components that were visited during this render. Only
238239
// includes unprocessed updates, not work in bailed out children.
239240
let workInProgressRootNextUnprocessedUpdateTime: ExpirationTime = NoWork;
240-
241+
let workInProgressRootRestartTime: ExpirationTime = NoWork;
241242
// If we're pinged while rendering we don't always restart immediately.
242243
// This flag determines if it might be worthwhile to restart if an opportunity
243244
// happens latere.
@@ -697,7 +698,12 @@ function performConcurrentWorkOnRoot(root, didTimeout) {
697698
throw fatalError;
698699
}
699700

700-
if (workInProgress !== null) {
701+
if (workInProgressRootExitStatus === RootSuspendedOnTask) {
702+
// Can't finish rendering at this level. Exit early and restart at the
703+
// specified time.
704+
markRootSuspendedAtTime(root, expirationTime);
705+
root.nextKnownPendingLevel = workInProgressRootRestartTime;
706+
} else if (workInProgress !== null) {
701707
// There's still work left over. Exit without committing.
702708
stopInterruptedWorkLoopTimer();
703709
} else {
@@ -738,7 +744,8 @@ function finishConcurrentRender(
738744

739745
switch (exitStatus) {
740746
case RootIncomplete:
741-
case RootFatalErrored: {
747+
case RootFatalErrored:
748+
case RootSuspendedOnTask: {
742749
invariant(false, 'Root did not complete. This is a bug in React.');
743750
}
744751
// Flow knows about invariant, so it complains if I add a break
@@ -1262,6 +1269,7 @@ function prepareFreshStack(root, expirationTime) {
12621269
workInProgressRootLatestSuspenseTimeout = Sync;
12631270
workInProgressRootCanSuspendUsingConfig = null;
12641271
workInProgressRootNextUnprocessedUpdateTime = NoWork;
1272+
workInProgressRootRestartTime = NoWork;
12651273
workInProgressRootHasPendingPing = false;
12661274

12671275
if (enableSchedulerTracing) {
@@ -1299,6 +1307,21 @@ function handleError(root, thrownValue) {
12991307
stopProfilerTimerIfRunningAndRecordDelta(workInProgress, true);
13001308
}
13011309

1310+
// Check if this is a SuspendOnTask exception.
1311+
// TODO: Should this be a brand check?
1312+
if (
1313+
thrownValue !== null &&
1314+
typeof thrownValue === 'object' &&
1315+
typeof thrownValue._retryTime === 'number'
1316+
) {
1317+
// Can't finish rendering at this level. Exit early and restart at
1318+
// the specified time.
1319+
workInProgressRootExitStatus = RootSuspendedOnTask;
1320+
workInProgressRootRestartTime = thrownValue._retryTime;
1321+
workInProgress = null;
1322+
return;
1323+
}
1324+
13021325
throwException(
13031326
root,
13041327
workInProgress.return,
@@ -2623,15 +2646,17 @@ if (__DEV__ && replayFailedUnitOfWorkWithInvokeGuardedCallback) {
26232646
try {
26242647
return originalBeginWork(current, unitOfWork, expirationTime);
26252648
} catch (originalError) {
2649+
// Filter out special exception types
26262650
if (
26272651
originalError !== null &&
26282652
typeof originalError === 'object' &&
2629-
typeof originalError.then === 'function'
2653+
// Promise
2654+
(typeof originalError.then === 'function' ||
2655+
// SuspendOnTask exception
2656+
typeof originalError._retryTime === 'number')
26302657
) {
2631-
// Don't replay promises. Treat everything else like an error.
26322658
throw originalError;
26332659
}
2634-
26352660
// Keep this code in sync with handleError; any changes here must have
26362661
// corresponding changes there.
26372662
resetContextDependencies();

packages/react-reconciler/src/ReactUpdateQueue.js

Lines changed: 35 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,7 @@ import {
105105
markRenderEventTimeAndConfig,
106106
markUnprocessedUpdateTime,
107107
} from './ReactFiberWorkLoop';
108+
import {SuspendOnTask} from './ReactFiberThrow';
108109

109110
import invariant from 'shared/invariant';
110111
import warningWithoutStack from 'shared/warningWithoutStack';
@@ -474,12 +475,15 @@ export function processUpdateQueue<State>(
474475
let newBaseState = queue.baseState;
475476
let newFirstUpdate = null;
476477
let newExpirationTime = NoWork;
478+
let lastProcessedTransitionTime = NoWork;
479+
let lastSkippedTransitionTime = NoWork;
477480

478481
// Iterate through the list of updates to compute the result.
479482
let update = queue.firstUpdate;
480483
let resultState = newBaseState;
481484
while (update !== null) {
482485
const updateExpirationTime = update.expirationTime;
486+
const suspenseConfig = update.suspenseConfig;
483487
if (updateExpirationTime < renderExpirationTime) {
484488
// This update does not have sufficient priority. Skip it.
485489
if (newFirstUpdate === null) {
@@ -495,6 +499,16 @@ export function processUpdateQueue<State>(
495499
if (newExpirationTime < updateExpirationTime) {
496500
newExpirationTime = updateExpirationTime;
497501
}
502+
503+
if (suspenseConfig !== null) {
504+
// This update is part of a transition
505+
if (
506+
lastSkippedTransitionTime === NoWork ||
507+
lastSkippedTransitionTime > updateExpirationTime
508+
) {
509+
lastSkippedTransitionTime = updateExpirationTime;
510+
}
511+
}
498512
} else {
499513
// This update does have sufficient priority.
500514

@@ -504,7 +518,7 @@ export function processUpdateQueue<State>(
504518
// TODO: We should skip this update if it was already committed but currently
505519
// we have no way of detecting the difference between a committed and suspended
506520
// update here.
507-
markRenderEventTimeAndConfig(updateExpirationTime, update.suspenseConfig);
521+
markRenderEventTimeAndConfig(updateExpirationTime, suspenseConfig);
508522

509523
// Process it and compute a new result.
510524
resultState = getStateFromUpdate(
@@ -527,11 +541,31 @@ export function processUpdateQueue<State>(
527541
queue.lastEffect = update;
528542
}
529543
}
544+
545+
if (suspenseConfig !== null) {
546+
// This update is part of a transition
547+
if (
548+
lastProcessedTransitionTime === NoWork ||
549+
lastProcessedTransitionTime > updateExpirationTime
550+
) {
551+
lastProcessedTransitionTime = updateExpirationTime;
552+
}
553+
}
530554
}
531555
// Continue to the next update.
532556
update = update.next;
533557
}
534558

559+
if (
560+
lastProcessedTransitionTime !== NoWork &&
561+
lastSkippedTransitionTime !== NoWork
562+
) {
563+
// There are multiple updates scheduled on this queue, but only some of
564+
// them were processed. To avoid showing an intermediate state, abort
565+
// the current render and restart at a level that includes them all.
566+
throw SuspendOnTask(lastSkippedTransitionTime);
567+
}
568+
535569
// Separately, iterate though the list of captured updates.
536570
let newFirstCapturedUpdate = null;
537571
update = queue.firstCapturedUpdate;

0 commit comments

Comments
 (0)