Skip to content

Commit 6de58f6

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 08d24f4 commit 6de58f6

File tree

5 files changed

+474
-17
lines changed

5 files changed

+474
-17
lines changed

packages/react-reconciler/src/ReactFiberHooks.js

Lines changed: 35 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,7 @@ import warning from 'shared/warning';
5151
import getComponentName from 'shared/getComponentName';
5252
import is from 'shared/objectIs';
5353
import {markWorkInProgressReceivedUpdate} from './ReactFiberBeginWork';
54+
import {SuspendOnTask} from './ReactFiberThrow';
5455
import {requestCurrentSuspenseConfig} from './ReactFiberSuspenseConfig';
5556
import {
5657
startTransition,
@@ -744,7 +745,10 @@ function updateReducer<S, I, A>(
744745
let prevUpdate = baseUpdate;
745746
let update = first;
746747
let didSkip = false;
748+
let lastProcessedTransitionTime = NoWork;
749+
let lastSkippedTransitionTime = NoWork;
747750
do {
751+
const suspenseConfig = update.suspenseConfig;
748752
const updateExpirationTime = update.expirationTime;
749753
if (updateExpirationTime < renderExpirationTime) {
750754
// Priority is insufficient. Skip this update. If this is the first
@@ -760,6 +764,16 @@ function updateReducer<S, I, A>(
760764
currentlyRenderingFiber.expirationTime = updateExpirationTime;
761765
markUnprocessedUpdateTime(updateExpirationTime);
762766
}
767+
768+
if (suspenseConfig !== null) {
769+
// This update is part of a transition
770+
if (
771+
lastSkippedTransitionTime === NoWork ||
772+
lastSkippedTransitionTime > updateExpirationTime
773+
) {
774+
lastSkippedTransitionTime = updateExpirationTime;
775+
}
776+
}
763777
} else {
764778
// This update does have sufficient priority.
765779
// Mark the event time of this update as relevant to this render pass.
@@ -768,10 +782,7 @@ function updateReducer<S, I, A>(
768782
// TODO: We should skip this update if it was already committed but currently
769783
// we have no way of detecting the difference between a committed and suspended
770784
// update here.
771-
markRenderEventTimeAndConfig(
772-
updateExpirationTime,
773-
update.suspenseConfig,
774-
);
785+
markRenderEventTimeAndConfig(updateExpirationTime, suspenseConfig);
775786
776787
// Process this update.
777788
if (update.eagerReducer === reducer) {
@@ -782,12 +793,32 @@ function updateReducer<S, I, A>(
782793
const action = update.action;
783794
newState = reducer(newState, action);
784795
}
796+
797+
if (suspenseConfig !== null) {
798+
// This update is part of a transition
799+
if (
800+
lastProcessedTransitionTime === NoWork ||
801+
lastProcessedTransitionTime > updateExpirationTime
802+
) {
803+
lastProcessedTransitionTime = updateExpirationTime;
804+
}
805+
}
785806
}
786807
787808
prevUpdate = update;
788809
update = update.next;
789810
} while (update !== null && update !== first);
790811
812+
if (
813+
lastProcessedTransitionTime !== NoWork &&
814+
lastSkippedTransitionTime !== NoWork
815+
) {
816+
// There are multiple updates scheduled on this queue, but only some of
817+
// them were processed. To avoid showing an intermediate state, abort
818+
// the current render and restart at a level that includes them all.
819+
throw new SuspendOnTask(lastSkippedTransitionTime);
820+
}
821+
791822
if (!didSkip) {
792823
newBaseUpdate = prevUpdate;
793824
newBaseState = newState;

packages/react-reconciler/src/ReactFiberThrow.js

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,12 @@ 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+
this.retryTime = expirationTime;
69+
}
70+
6571
function createRootErrorUpdate(
6672
fiber: Fiber,
6773
errorInfo: CapturedValue<mixed>,

packages/react-reconciler/src/ReactFiberWorkLoop.js

Lines changed: 42 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -126,6 +126,7 @@ import {
126126
throwException,
127127
createRootErrorUpdate,
128128
createClassErrorUpdate,
129+
SuspendOnTask,
129130
} from './ReactFiberThrow';
130131
import {
131132
commitBeforeMutationLifeCycles as commitBeforeMutationEffectOnFiber,
@@ -201,13 +202,14 @@ const LegacyUnbatchedContext = /* */ 0b001000;
201202
const RenderContext = /* */ 0b010000;
202203
const CommitContext = /* */ 0b100000;
203204

204-
type RootExitStatus = 0 | 1 | 2 | 3 | 4 | 5;
205+
type RootExitStatus = 0 | 1 | 2 | 3 | 4 | 5 | 6;
205206
const RootIncomplete = 0;
206207
const RootFatalErrored = 1;
207-
const RootErrored = 2;
208-
const RootSuspended = 3;
209-
const RootSuspendedWithDelay = 4;
210-
const RootCompleted = 5;
208+
const RootSuspendedOnTask = 2;
209+
const RootErrored = 3;
210+
const RootSuspended = 4;
211+
const RootSuspendedWithDelay = 5;
212+
const RootCompleted = 6;
211213

212214
export type Thenable = {
213215
then(resolve: () => mixed, reject?: () => mixed): Thenable | void,
@@ -238,7 +240,7 @@ let workInProgressRootCanSuspendUsingConfig: null | SuspenseConfig = null;
238240
// The work left over by components that were visited during this render. Only
239241
// includes unprocessed updates, not work in bailed out children.
240242
let workInProgressRootNextUnprocessedUpdateTime: ExpirationTime = NoWork;
241-
243+
let workInProgressRootRestartTime: ExpirationTime = NoWork;
242244
// If we're pinged while rendering we don't always restart immediately.
243245
// This flag determines if it might be worthwhile to restart if an opportunity
244246
// happens latere.
@@ -708,7 +710,12 @@ function performConcurrentWorkOnRoot(root, didTimeout) {
708710
throw fatalError;
709711
}
710712

711-
if (workInProgress !== null) {
713+
if (workInProgressRootExitStatus === RootSuspendedOnTask) {
714+
// Can't finish rendering at this level. Exit early and restart at the
715+
// specified time.
716+
markRootSuspendedAtTime(root, expirationTime);
717+
root.nextKnownPendingLevel = workInProgressRootRestartTime;
718+
} else if (workInProgress !== null) {
712719
// There's still work left over. Exit without committing.
713720
stopInterruptedWorkLoopTimer();
714721
} else {
@@ -749,7 +756,8 @@ function finishConcurrentRender(
749756

750757
switch (exitStatus) {
751758
case RootIncomplete:
752-
case RootFatalErrored: {
759+
case RootFatalErrored:
760+
case RootSuspendedOnTask: {
753761
invariant(false, 'Root did not complete. This is a bug in React.');
754762
}
755763
// Flow knows about invariant, so it complains if I add a break
@@ -1036,7 +1044,12 @@ function performSyncWorkOnRoot(root) {
10361044
throw fatalError;
10371045
}
10381046

1039-
if (workInProgress !== null) {
1047+
if (workInProgressRootExitStatus === RootSuspendedOnTask) {
1048+
// Can't finish rendering at this level. Exit early and restart at the
1049+
// specified time.
1050+
markRootSuspendedAtTime(root, expirationTime);
1051+
root.nextKnownPendingLevel = workInProgressRootRestartTime;
1052+
} else if (workInProgress !== null) {
10401053
// This is a sync render, so we should have finished the whole tree.
10411054
invariant(
10421055
false,
@@ -1263,6 +1276,7 @@ function prepareFreshStack(root, expirationTime) {
12631276
workInProgressRootLatestSuspenseTimeout = Sync;
12641277
workInProgressRootCanSuspendUsingConfig = null;
12651278
workInProgressRootNextUnprocessedUpdateTime = NoWork;
1279+
workInProgressRootRestartTime = NoWork;
12661280
workInProgressRootHasPendingPing = false;
12671281

12681282
if (enableSchedulerTracing) {
@@ -1283,6 +1297,20 @@ function handleError(root, thrownValue) {
12831297
resetHooks();
12841298
resetCurrentDebugFiberInDEV();
12851299

1300+
// Check if this is a SuspendOnTask exception. This is the one type of
1301+
// exception that is allowed to happen at the root.
1302+
// TODO: I think instanceof is OK here? A brand check seems unnecessary
1303+
// since this is always thrown by the renderer and not across realms
1304+
// or packages.
1305+
if (thrownValue instanceof SuspendOnTask) {
1306+
// Can't finish rendering at this level. Exit early and restart at
1307+
// the specified time.
1308+
workInProgressRootExitStatus = RootSuspendedOnTask;
1309+
workInProgressRootRestartTime = thrownValue.retryTime;
1310+
workInProgress = null;
1311+
return;
1312+
}
1313+
12861314
if (workInProgress === null || workInProgress.return === null) {
12871315
// Expected to be working on a non-root fiber. This is a fatal error
12881316
// because there's no ancestor that can handle it; the root is
@@ -2624,15 +2652,17 @@ if (__DEV__ && replayFailedUnitOfWorkWithInvokeGuardedCallback) {
26242652
try {
26252653
return originalBeginWork(current, unitOfWork, expirationTime);
26262654
} catch (originalError) {
2655+
// Filter out special exception types
26272656
if (
26282657
originalError !== null &&
26292658
typeof originalError === 'object' &&
2630-
typeof originalError.then === 'function'
2659+
// Promise
2660+
(typeof originalError.then === 'function' ||
2661+
// SuspendOnTask exception
2662+
originalError instanceof SuspendOnTask)
26312663
) {
2632-
// Don't replay promises. Treat everything else like an error.
26332664
throw originalError;
26342665
}
2635-
26362666
// Keep this code in sync with handleError; any changes here must have
26372667
// corresponding changes there.
26382668
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 new 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)