Skip to content

Commit 08d24f4

Browse files
committed
Only most recent transition is pending, per queue
When multiple transitions update the same queue, only the most recent one should be considered pending. Example: If I switch tabs multiple times, only the last tab I click should display a pending state (e.g. an inline spinner).
1 parent fe2e86f commit 08d24f4

File tree

5 files changed

+521
-104
lines changed

5 files changed

+521
-104
lines changed

packages/react-reconciler/src/ReactFiberHooks.js

Lines changed: 32 additions & 104 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import type {Fiber} from './ReactFiber';
1616
import type {ExpirationTime} from './ReactFiberExpirationTime';
1717
import type {HookEffectTag} from './ReactHookEffectTags';
1818
import type {SuspenseConfig} from './ReactFiberSuspenseConfig';
19+
import type {TransitionInstance} from './ReactFiberTransition';
1920
import type {ReactPriorityLevel} from './SchedulerWithReactIntegration';
2021

2122
import ReactSharedInternals from 'shared/ReactSharedInternals';
@@ -52,11 +53,11 @@ import is from 'shared/objectIs';
5253
import {markWorkInProgressReceivedUpdate} from './ReactFiberBeginWork';
5354
import {requestCurrentSuspenseConfig} from './ReactFiberSuspenseConfig';
5455
import {
55-
UserBlockingPriority,
56-
NormalPriority,
57-
runWithPriority,
58-
getCurrentPriorityLevel,
59-
} from './SchedulerWithReactIntegration';
56+
startTransition,
57+
requestCurrentTransition,
58+
cancelPendingTransition,
59+
} from './ReactFiberTransition';
60+
import {getCurrentPriorityLevel} from './SchedulerWithReactIntegration';
6061

6162
const {ReactCurrentDispatcher, ReactCurrentBatchConfig} = ReactSharedInternals;
6263

@@ -116,14 +117,11 @@ type Update<S, A> = {
116117
type UpdateQueue<S, A> = {
117118
last: Update<S, A> | null,
118119
dispatch: (A => mixed) | null,
120+
pendingTransition: TransitionInstance | null,
119121
lastRenderedReducer: ((S, A) => S) | null,
120122
lastRenderedState: S | null,
121123
};
122124

123-
type TransitionInstance = {|
124-
pendingExpirationTime: ExpirationTime,
125-
|};
126-
127125
export type HookType =
128126
| 'useState'
129127
| 'useReducer'
@@ -651,6 +649,7 @@ function mountReducer<S, I, A>(
651649
const queue = (hook.queue = {
652650
last: null,
653651
dispatch: null,
652+
pendingTransition: null,
654653
lastRenderedReducer: reducer,
655654
lastRenderedState: (initialState: any),
656655
});
@@ -822,6 +821,7 @@ function mountState<S>(
822821
const queue = (hook.queue = {
823822
last: null,
824823
dispatch: null,
824+
pendingTransition: null,
825825
lastRenderedReducer: basicStateReducer,
826826
lastRenderedState: (initialState: any),
827827
});
@@ -1157,89 +1157,14 @@ function updateDeferredValue<T>(
11571157
return prevValue;
11581158
}
11591159
1160-
function startTransition(fiber, instance, config, callback) {
1161-
let resolvedConfig: SuspenseConfig | null =
1162-
config === undefined ? null : config;
1163-
1164-
// TODO: runWithPriority shouldn't be necessary here. React should manage its
1165-
// own concept of priority, and only consult Scheduler for updates that are
1166-
// scheduled from outside a React context.
1167-
const priorityLevel = getCurrentPriorityLevel();
1168-
runWithPriority(
1169-
priorityLevel < UserBlockingPriority ? UserBlockingPriority : priorityLevel,
1170-
() => {
1171-
const currentTime = requestCurrentTimeForUpdate();
1172-
const expirationTime = computeExpirationForFiber(
1173-
currentTime,
1174-
fiber,
1175-
null,
1176-
);
1177-
scheduleWork(fiber, expirationTime);
1178-
},
1179-
);
1180-
runWithPriority(
1181-
priorityLevel > NormalPriority ? NormalPriority : priorityLevel,
1182-
() => {
1183-
const currentTime = requestCurrentTimeForUpdate();
1184-
let expirationTime = computeExpirationForFiber(
1185-
currentTime,
1186-
fiber,
1187-
resolvedConfig,
1188-
);
1189-
// Set the expiration time at which the pending transition will finish.
1190-
// Because there's only a single transition per useTransition hook, we
1191-
// don't need a queue here; we can cheat by only tracking the most
1192-
// recently scheduled transition.
1193-
// TODO: This trick depends on transition expiration times being
1194-
// monotonically decreasing in priority, but since expiration times
1195-
// currently correspond to `timeoutMs`, that might not be true if
1196-
// `timeoutMs` changes to something smaller before the previous transition
1197-
// resolves. But this is a temporary edge case, since we're about to
1198-
// remove the correspondence between `timeoutMs` and the expiration time.
1199-
const oldPendingExpirationTime = instance.pendingExpirationTime;
1200-
while (
1201-
oldPendingExpirationTime !== NoWork &&
1202-
oldPendingExpirationTime <= expirationTime
1203-
) {
1204-
// Temporary hack to make pendingExpirationTime monotonically decreasing
1205-
if (resolvedConfig === null) {
1206-
resolvedConfig = {
1207-
timeoutMs: 5250,
1208-
};
1209-
} else {
1210-
resolvedConfig = {
1211-
timeoutMs: (resolvedConfig.timeoutMs | 0 || 5000) + 250,
1212-
busyDelayMs: resolvedConfig.busyDelayMs,
1213-
busyMinDurationMs: resolvedConfig.busyMinDurationMs,
1214-
};
1215-
}
1216-
expirationTime = computeExpirationForFiber(
1217-
currentTime,
1218-
fiber,
1219-
resolvedConfig,
1220-
);
1221-
}
1222-
instance.pendingExpirationTime = expirationTime;
1223-
1224-
scheduleWork(fiber, expirationTime);
1225-
const previousConfig = ReactCurrentBatchConfig.suspense;
1226-
ReactCurrentBatchConfig.suspense = resolvedConfig;
1227-
try {
1228-
callback();
1229-
} finally {
1230-
ReactCurrentBatchConfig.suspense = previousConfig;
1231-
}
1232-
},
1233-
);
1234-
}
1235-
12361160
function mountTransition(
12371161
config: SuspenseConfig | void | null,
12381162
): [(() => void) => void, boolean] {
12391163
const hook = mountWorkInProgressHook();
1240-
1164+
const fiber = ((currentlyRenderingFiber: any): Fiber);
12411165
const instance: TransitionInstance = {
12421166
pendingExpirationTime: NoWork,
1167+
fiber,
12431168
};
12441169
// TODO: Intentionally storing this on the queue field to avoid adding a new/
12451170
// one; `queue` should be a union.
@@ -1251,15 +1176,9 @@ function mountTransition(
12511176
// Then we don't have to recompute the callback whenever it changes. However,
12521177
// if we don't end up changing the API, we should at least optimize this
12531178
// to use the same hook instead of a separate hook just for the callback.
1254-
const start = mountCallback(
1255-
startTransition.bind(
1256-
null,
1257-
((currentlyRenderingFiber: any): Fiber),
1258-
instance,
1259-
config,
1260-
),
1261-
[config],
1262-
);
1179+
const start = mountCallback(startTransition.bind(null, instance, config), [
1180+
config,
1181+
]);
12631182
12641183
const resolvedExpirationTime = NoWork;
12651184
hook.memoizedState = {
@@ -1355,15 +1274,9 @@ function updateTransition(
13551274
resolvedExpirationTime: newResolvedExpirationTime,
13561275
};
13571276
1358-
const start = updateCallback(
1359-
startTransition.bind(
1360-
null,
1361-
((currentlyRenderingFiber: any): Fiber),
1362-
instance,
1363-
config,
1364-
),
1365-
[config],
1366-
);
1277+
const start = updateCallback(startTransition.bind(null, instance, config), [
1278+
config,
1279+
]);
13671280
13681281
return [start, newIsPending];
13691282
}
@@ -1425,6 +1338,7 @@ function dispatchAction<S, A>(
14251338
} else {
14261339
const currentTime = requestCurrentTimeForUpdate();
14271340
const suspenseConfig = requestCurrentSuspenseConfig();
1341+
const transition = requestCurrentTransition();
14281342
const expirationTime = computeExpirationForFiber(
14291343
currentTime,
14301344
fiber,
@@ -1505,6 +1419,20 @@ function dispatchAction<S, A>(
15051419
warnIfNotCurrentlyActingUpdatesInDev(fiber);
15061420
}
15071421
}
1422+
1423+
if (transition !== null) {
1424+
const prevPendingTransition = queue.pendingTransition;
1425+
if (transition !== prevPendingTransition) {
1426+
queue.pendingTransition = transition;
1427+
if (prevPendingTransition !== null) {
1428+
// There's already a pending transition on this queue. The new
1429+
// transition supersedes the old one. Turn of the `isPending` state
1430+
// of the previous transition.
1431+
cancelPendingTransition(prevPendingTransition);
1432+
}
1433+
}
1434+
}
1435+
15081436
scheduleWork(fiber, expirationTime);
15091437
}
15101438
}

packages/react-reconciler/src/ReactFiberSuspenseConfig.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99

1010
import ReactSharedInternals from 'shared/ReactSharedInternals';
1111

12+
// TODO: Remove React.unstable_withSuspenseConfig and move this to the renderer
1213
const {ReactCurrentBatchConfig} = ReactSharedInternals;
1314

1415
export type SuspenseConfig = {|
Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,140 @@
1+
/**
2+
* Copyright (c) Facebook, Inc. and its affiliates.
3+
*
4+
* This source code is licensed under the MIT license found in the
5+
* LICENSE file in the root directory of this source tree.
6+
*
7+
* @flow
8+
*/
9+
10+
import type {Fiber} from './ReactFiber';
11+
import type {ExpirationTime} from './ReactFiberExpirationTime';
12+
import type {SuspenseConfig} from './ReactFiberSuspenseConfig';
13+
14+
import ReactSharedInternals from 'shared/ReactSharedInternals';
15+
16+
import {
17+
UserBlockingPriority,
18+
NormalPriority,
19+
runWithPriority,
20+
getCurrentPriorityLevel,
21+
} from './SchedulerWithReactIntegration';
22+
import {
23+
scheduleUpdateOnFiber,
24+
computeExpirationForFiber,
25+
requestCurrentTimeForUpdate,
26+
} from './ReactFiberWorkLoop';
27+
import {NoWork} from './ReactFiberExpirationTime';
28+
29+
const {ReactCurrentBatchConfig} = ReactSharedInternals;
30+
31+
export type TransitionInstance = {|
32+
pendingExpirationTime: ExpirationTime,
33+
fiber: Fiber,
34+
|};
35+
36+
// Inside `startTransition`, this is the transition instance that corresponds to
37+
// the `useTransition` hook.
38+
let currentTransition: TransitionInstance | null = null;
39+
40+
// Inside `startTransition`, this is the expiration time of the update that
41+
// turns on `isPending`. We also use it to turn off the `isPending` of previous
42+
// transitions, if they exists.
43+
let userBlockingExpirationTime = NoWork;
44+
45+
export function requestCurrentTransition(): TransitionInstance | null {
46+
return currentTransition;
47+
}
48+
49+
export function startTransition(
50+
transitionInstance: TransitionInstance,
51+
config: SuspenseConfig | null | void,
52+
callback: () => void,
53+
) {
54+
const fiber = transitionInstance.fiber;
55+
56+
let resolvedConfig: SuspenseConfig | null =
57+
config === undefined ? null : config;
58+
59+
// TODO: runWithPriority shouldn't be necessary here. React should manage its
60+
// own concept of priority, and only consult Scheduler for updates that are
61+
// scheduled from outside a React context.
62+
const priorityLevel = getCurrentPriorityLevel();
63+
runWithPriority(
64+
priorityLevel < UserBlockingPriority ? UserBlockingPriority : priorityLevel,
65+
() => {
66+
const currentTime = requestCurrentTimeForUpdate();
67+
userBlockingExpirationTime = computeExpirationForFiber(
68+
currentTime,
69+
fiber,
70+
null,
71+
);
72+
scheduleUpdateOnFiber(fiber, userBlockingExpirationTime);
73+
},
74+
);
75+
runWithPriority(
76+
priorityLevel > NormalPriority ? NormalPriority : priorityLevel,
77+
() => {
78+
const currentTime = requestCurrentTimeForUpdate();
79+
let expirationTime = computeExpirationForFiber(
80+
currentTime,
81+
fiber,
82+
resolvedConfig,
83+
);
84+
// Set the expiration time at which the pending transition will finish.
85+
// Because there's only a single transition per useTransition hook, we
86+
// don't need a queue here; we can cheat by only tracking the most
87+
// recently scheduled transition.
88+
// TODO: This trick depends on transition expiration times being
89+
// monotonically decreasing in priority, but since expiration times
90+
// currently correspond to `timeoutMs`, that might not be true if
91+
// `timeoutMs` changes to something smaller before the previous transition
92+
// resolves. But this is a temporary edge case, since we're about to
93+
// remove the correspondence between `timeoutMs` and the expiration time.
94+
const oldPendingExpirationTime = transitionInstance.pendingExpirationTime;
95+
while (
96+
oldPendingExpirationTime !== NoWork &&
97+
oldPendingExpirationTime <= expirationTime
98+
) {
99+
// Temporary hack to make pendingExpirationTime monotonically decreasing
100+
if (resolvedConfig === null) {
101+
resolvedConfig = {
102+
timeoutMs: 5250,
103+
};
104+
} else {
105+
resolvedConfig = {
106+
timeoutMs: (resolvedConfig.timeoutMs | 0 || 5000) + 250,
107+
busyDelayMs: resolvedConfig.busyDelayMs,
108+
busyMinDurationMs: resolvedConfig.busyMinDurationMs,
109+
};
110+
}
111+
expirationTime = computeExpirationForFiber(
112+
currentTime,
113+
fiber,
114+
resolvedConfig,
115+
);
116+
}
117+
transitionInstance.pendingExpirationTime = expirationTime;
118+
119+
scheduleUpdateOnFiber(fiber, expirationTime);
120+
const previousConfig = ReactCurrentBatchConfig.suspense;
121+
const previousTransition = currentTransition;
122+
ReactCurrentBatchConfig.suspense = resolvedConfig;
123+
currentTransition = transitionInstance;
124+
try {
125+
callback();
126+
} finally {
127+
ReactCurrentBatchConfig.suspense = previousConfig;
128+
currentTransition = previousTransition;
129+
userBlockingExpirationTime = NoWork;
130+
}
131+
},
132+
);
133+
}
134+
135+
export function cancelPendingTransition(prevTransition: TransitionInstance) {
136+
// Turn off the `isPending` state of the previous transition, at the same
137+
// priority we use to turn on the `isPending` state of the current transition.
138+
prevTransition.pendingExpirationTime = NoWork;
139+
scheduleUpdateOnFiber(prevTransition.fiber, userBlockingExpirationTime);
140+
}

0 commit comments

Comments
 (0)