Skip to content

Commit 6ff5a51

Browse files
committed
Implement experimental_useOptimisticState
This adds an experimental hook tentatively called useOptimisticState. (The actual name needs some bikeshedding.) The headline feature is that you can use it to implement optimistic updates. If you set some optimistic state during a transition/action, the state will be automatically reverted once the transition completes. Another feature is that the optimistic updates will be continually rebased on top of the latest state. It's easiest to explain with examples; we'll publish documentation as the API gets closer to stabilizing. See tests for now. Technically the use cases for this hook are broader than just optimistic updates; you could use it implement any sort of "pending" state, such as the ones exposed by useTransition and useFormStatus. But we expect people will most often reach for this hook to implement the optimistic update pattern; simpler cases are covered by those other hooks.
1 parent 26c11a0 commit 6ff5a51

File tree

3 files changed

+629
-60
lines changed

3 files changed

+629
-60
lines changed

packages/react-devtools-shared/src/__tests__/TimelineProfiler-test.js

Lines changed: 1 addition & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1243,10 +1243,9 @@ describe('Timeline profiler', () => {
12431243
function Example() {
12441244
const setHigh = React.useState(0)[1];
12451245
const setLow = React.useState(0)[1];
1246-
const startTransition = React.useTransition()[1];
12471246

12481247
updaterFn = () => {
1249-
startTransition(() => {
1248+
React.startTransition(() => {
12501249
setLow(prevLow => prevLow + 1);
12511250
});
12521251
setHigh(prevHigh => prevHigh + 1);
@@ -1265,24 +1264,6 @@ describe('Timeline profiler', () => {
12651264
const timelineData = stopProfilingAndGetTimelineData();
12661265
expect(timelineData.schedulingEvents).toMatchInlineSnapshot(`
12671266
[
1268-
{
1269-
"componentName": "Example",
1270-
"componentStack": "
1271-
in Example (at **)",
1272-
"lanes": "0b0000000000000000000000000001000",
1273-
"timestamp": 10,
1274-
"type": "schedule-state-update",
1275-
"warning": null,
1276-
},
1277-
{
1278-
"componentName": "Example",
1279-
"componentStack": "
1280-
in Example (at **)",
1281-
"lanes": "0b0000000000000000000000010000000",
1282-
"timestamp": 10,
1283-
"type": "schedule-state-update",
1284-
"warning": null,
1285-
},
12861267
{
12871268
"componentName": "Example",
12881269
"componentStack": "

packages/react-reconciler/src/ReactFiberHooks.js

Lines changed: 208 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -149,11 +149,13 @@ import type {ThenableState} from './ReactFiberThenable';
149149
import type {BatchConfigTransition} from './ReactFiberTracingMarkerComponent';
150150
import {requestAsyncActionContext} from './ReactFiberAsyncAction';
151151
import {HostTransitionContext} from './ReactFiberHostContext';
152+
import {requestTransitionLane} from './ReactFiberRootScheduler';
152153

153154
const {ReactCurrentDispatcher, ReactCurrentBatchConfig} = ReactSharedInternals;
154155

155156
export type Update<S, A> = {
156157
lane: Lane,
158+
revertLane: Lane,
157159
action: A,
158160
hasEagerState: boolean,
159161
eagerState: S | null,
@@ -1136,6 +1138,14 @@ function updateReducer<S, I, A>(
11361138
init?: I => S,
11371139
): [S, Dispatch<A>] {
11381140
const hook = updateWorkInProgressHook();
1141+
return updateReducerImpl(hook, ((currentHook: any): Hook), reducer);
1142+
}
1143+
1144+
function updateReducerImpl<S, A>(
1145+
hook: Hook,
1146+
current: Hook,
1147+
reducer: (S, A) => S,
1148+
): [S, Dispatch<A>] {
11391149
const queue = hook.queue;
11401150

11411151
if (queue === null) {
@@ -1146,10 +1156,8 @@ function updateReducer<S, I, A>(
11461156

11471157
queue.lastRenderedReducer = reducer;
11481158

1149-
const current: Hook = (currentHook: any);
1150-
11511159
// The last rebase update that is NOT part of the base state.
1152-
let baseQueue = current.baseQueue;
1160+
let baseQueue = hook.baseQueue;
11531161

11541162
// The last pending update that hasn't been processed yet.
11551163
const pendingQueue = queue.pending;
@@ -1180,7 +1188,7 @@ function updateReducer<S, I, A>(
11801188
if (baseQueue !== null) {
11811189
// We have a queue to process.
11821190
const first = baseQueue.next;
1183-
let newState = current.baseState;
1191+
let newState = hook.baseState;
11841192

11851193
let newBaseState = null;
11861194
let newBaseQueueFirst = null;
@@ -1206,6 +1214,7 @@ function updateReducer<S, I, A>(
12061214
// update/state.
12071215
const clone: Update<S, A> = {
12081216
lane: updateLane,
1217+
revertLane: update.revertLane,
12091218
action: update.action,
12101219
hasEagerState: update.hasEagerState,
12111220
eagerState: update.eagerState,
@@ -1228,18 +1237,68 @@ function updateReducer<S, I, A>(
12281237
} else {
12291238
// This update does have sufficient priority.
12301239

1231-
if (newBaseQueueLast !== null) {
1232-
const clone: Update<S, A> = {
1233-
// This update is going to be committed so we never want uncommit
1234-
// it. Using NoLane works because 0 is a subset of all bitmasks, so
1235-
// this will never be skipped by the check above.
1236-
lane: NoLane,
1237-
action: update.action,
1238-
hasEagerState: update.hasEagerState,
1239-
eagerState: update.eagerState,
1240-
next: (null: any),
1241-
};
1242-
newBaseQueueLast = newBaseQueueLast.next = clone;
1240+
// Check if this is an optimistic update.
1241+
const revertLane = update.revertLane;
1242+
if (!enableAsyncActions || revertLane === NoLane) {
1243+
// This is not an optimistic update, and we're going to apply it now.
1244+
// But, if there were earlier updates that were skipped, we need to
1245+
// leave this update in the queue so it can be rebased later.
1246+
if (newBaseQueueLast !== null) {
1247+
const clone: Update<S, A> = {
1248+
// This update is going to be committed so we never want uncommit
1249+
// it. Using NoLane works because 0 is a subset of all bitmasks, so
1250+
// this will never be skipped by the check above.
1251+
lane: NoLane,
1252+
revertLane: NoLane,
1253+
action: update.action,
1254+
hasEagerState: update.hasEagerState,
1255+
eagerState: update.eagerState,
1256+
next: (null: any),
1257+
};
1258+
newBaseQueueLast = newBaseQueueLast.next = clone;
1259+
}
1260+
} else {
1261+
// This is an optimistic update. If the "revert" priority is
1262+
// sufficient, don't apply the update. Otherwise, apply the update,
1263+
// but leave it in the queue so it can be either reverted or
1264+
// rebased in a subsequent render.
1265+
if (isSubsetOfLanes(renderLanes, revertLane)) {
1266+
// The transition that this optimistic update is associated with
1267+
// has finished. Pretend the update doesn't exist by skipping
1268+
// over it.
1269+
update = update.next;
1270+
continue;
1271+
} else {
1272+
const clone: Update<S, A> = {
1273+
// Once we commit an optimistic update, we shouldn't uncommit it
1274+
// until the transition it is associated with has finished
1275+
// (represented by revertLane). Using NoLane here works because 0
1276+
// is a subset of all bitmasks, so this will never be skipped by
1277+
// the check above.
1278+
lane: NoLane,
1279+
// Reuse the same revertLane so we know when the transition
1280+
// has finished.
1281+
revertLane: update.revertLane,
1282+
action: update.action,
1283+
hasEagerState: update.hasEagerState,
1284+
eagerState: update.eagerState,
1285+
next: (null: any),
1286+
};
1287+
if (newBaseQueueLast === null) {
1288+
newBaseQueueFirst = newBaseQueueLast = clone;
1289+
newBaseState = newState;
1290+
} else {
1291+
newBaseQueueLast = newBaseQueueLast.next = clone;
1292+
}
1293+
// Update the remaining priority in the queue.
1294+
// TODO: Don't need to accumulate this. Instead, we can remove
1295+
// renderLanes from the original lanes.
1296+
currentlyRenderingFiber.lanes = mergeLanes(
1297+
currentlyRenderingFiber.lanes,
1298+
revertLane,
1299+
);
1300+
markSkippedUpdateLanes(revertLane);
1301+
}
12431302
}
12441303

12451304
// Process this update.
@@ -1899,56 +1958,106 @@ function mountStateImpl<S>(initialState: (() => S) | S): Hook {
18991958
lastRenderedState: (initialState: any),
19001959
};
19011960
hook.queue = queue;
1902-
const dispatch: Dispatch<BasicStateAction<S>> = (dispatchSetState.bind(
1903-
null,
1904-
currentlyRenderingFiber,
1905-
queue,
1906-
): any);
1907-
queue.dispatch = dispatch;
19081961
return hook;
19091962
}
19101963

19111964
function mountState<S>(
19121965
initialState: (() => S) | S,
19131966
): [S, Dispatch<BasicStateAction<S>>] {
19141967
const hook = mountStateImpl(initialState);
1915-
return [hook.memoizedState, hook.queue.dispatch];
1968+
const queue = hook.queue;
1969+
const dispatch: Dispatch<BasicStateAction<S>> = (dispatchSetState.bind(
1970+
null,
1971+
currentlyRenderingFiber,
1972+
queue,
1973+
): any);
1974+
queue.dispatch = dispatch;
1975+
return [hook.memoizedState, dispatch];
19161976
}
19171977

19181978
function updateState<S>(
19191979
initialState: (() => S) | S,
19201980
): [S, Dispatch<BasicStateAction<S>>] {
1921-
return updateReducer(basicStateReducer, (initialState: any));
1981+
return updateReducer(basicStateReducer, initialState);
19221982
}
19231983

19241984
function rerenderState<S>(
19251985
initialState: (() => S) | S,
19261986
): [S, Dispatch<BasicStateAction<S>>] {
1927-
return rerenderReducer(basicStateReducer, (initialState: any));
1987+
return rerenderReducer(basicStateReducer, initialState);
19281988
}
19291989

19301990
function mountOptimisticState<S, A>(
19311991
passthrough: S,
19321992
reducer: ?(S, A) => S,
19331993
): [S, (A) => void] {
1934-
// $FlowFixMe - TODO: Actual implementation
1935-
return mountState(passthrough);
1994+
const hook = mountWorkInProgressHook();
1995+
hook.memoizedState = hook.baseState = passthrough;
1996+
const queue: UpdateQueue<S, A> = {
1997+
pending: null,
1998+
lanes: NoLanes,
1999+
dispatch: null,
2000+
// Optimistic state does not use the eager update optimization.
2001+
lastRenderedReducer: null,
2002+
lastRenderedState: null,
2003+
};
2004+
hook.queue = queue;
2005+
// This is different than the normal setState function.
2006+
const dispatch: A => void = (dispatchOptimisticSetState.bind(
2007+
null,
2008+
currentlyRenderingFiber,
2009+
true,
2010+
queue,
2011+
): any);
2012+
queue.dispatch = dispatch;
2013+
return [passthrough, dispatch];
19362014
}
19372015

19382016
function updateOptimisticState<S, A>(
19392017
passthrough: S,
19402018
reducer: ?(S, A) => S,
19412019
): [S, (A) => void] {
1942-
// $FlowFixMe - TODO: Actual implementation
1943-
return updateState(passthrough);
2020+
const hook = updateWorkInProgressHook();
2021+
2022+
// Optimistic updates are always rebased on top of the latest value passed in
2023+
// as an argument. It's called a passthrough because if there are no pending
2024+
// updates, it will be returned as-is.
2025+
//
2026+
// Reset the base state and memoized state to the passthrough. Future
2027+
// updates will be applied on top of this.
2028+
hook.baseState = hook.memoizedState = passthrough;
2029+
2030+
// If a reducer is not provided, default to the same one used by useState.
2031+
const resolvedReducer: (S, A) => S =
2032+
typeof reducer === 'function' ? reducer : (basicStateReducer: any);
2033+
2034+
return updateReducerImpl(hook, ((currentHook: any): Hook), resolvedReducer);
19442035
}
19452036

19462037
function rerenderOptimisticState<S, A>(
19472038
passthrough: S,
19482039
reducer: ?(S, A) => S,
19492040
): [S, (A) => void] {
1950-
// $FlowFixMe - TODO: Actual implementation
1951-
return rerenderState(passthrough);
2041+
// Unlike useState, useOptimisticState doesn't support render phase updates.
2042+
// Also unlike useState, we need to replay all pending updates again in case
2043+
// the passthrough value changed.
2044+
//
2045+
// So instead of a forked re-render implementation that knows how to handle
2046+
// render phase udpates, we can use the same implementation as during a
2047+
// regular mount or update.
2048+
2049+
if (currentHook !== null) {
2050+
// This is an update. Process the update queue.
2051+
return updateOptimisticState(passthrough, reducer);
2052+
}
2053+
2054+
// This is a mount. No updates to process.
2055+
const hook = updateWorkInProgressHook();
2056+
// Reset the base state and memoized state to the passthrough. Future
2057+
// updates will be applied on top of this.
2058+
hook.baseState = hook.memoizedState = passthrough;
2059+
const dispatch = hook.queue.dispatch;
2060+
return [passthrough, dispatch];
19522061
}
19532062

19542063
function pushEffect(
@@ -2491,8 +2600,20 @@ function startTransition<S>(
24912600
);
24922601

24932602
const prevTransition = ReactCurrentBatchConfig.transition;
2494-
ReactCurrentBatchConfig.transition = null;
2495-
dispatchSetState(fiber, queue, pendingState);
2603+
2604+
if (enableAsyncActions) {
2605+
// We don't really need to use an optimistic update here, because we
2606+
// schedule a second "revert" update below (which we use to suspend the
2607+
// transition until the async action scope has finished). But we'll use an
2608+
// optimistic update anyway to make it less likely the behavior accidentally
2609+
// diverges; for example, both an optimistic update and this one should
2610+
// share the same lane.
2611+
dispatchOptimisticSetState(fiber, false, queue, pendingState);
2612+
} else {
2613+
ReactCurrentBatchConfig.transition = null;
2614+
dispatchSetState(fiber, queue, pendingState);
2615+
}
2616+
24962617
const currentTransition = (ReactCurrentBatchConfig.transition =
24972618
({}: BatchConfigTransition));
24982619

@@ -2827,6 +2948,7 @@ function dispatchReducerAction<S, A>(
28272948

28282949
const update: Update<S, A> = {
28292950
lane,
2951+
revertLane: NoLane,
28302952
action,
28312953
hasEagerState: false,
28322954
eagerState: null,
@@ -2865,6 +2987,7 @@ function dispatchSetState<S, A>(
28652987

28662988
const update: Update<S, A> = {
28672989
lane,
2990+
revertLane: NoLane,
28682991
action,
28692992
hasEagerState: false,
28702993
eagerState: null,
@@ -2928,6 +3051,58 @@ function dispatchSetState<S, A>(
29283051
markUpdateInDevTools(fiber, lane, action);
29293052
}
29303053

3054+
function dispatchOptimisticSetState<S, A>(
3055+
fiber: Fiber,
3056+
throwIfDuringRender: boolean,
3057+
queue: UpdateQueue<S, A>,
3058+
action: A,
3059+
): void {
3060+
const update: Update<S, A> = {
3061+
// An optimistic update commits synchronously.
3062+
lane: SyncLane,
3063+
// After committing, the optimistic update is "reverted" using the same
3064+
// lane as the transition it's associated with.
3065+
//
3066+
// TODO: Warn if there's no transition/action associated with this
3067+
// optimistic update.
3068+
revertLane: requestTransitionLane(),
3069+
action,
3070+
hasEagerState: false,
3071+
eagerState: null,
3072+
next: (null: any),
3073+
};
3074+
3075+
if (isRenderPhaseUpdate(fiber)) {
3076+
// When calling startTransition during render, this warns instead of
3077+
// throwing because throwing would be a breaking change. setOptimisticState
3078+
// is a new API so it's OK to throw.
3079+
if (throwIfDuringRender) {
3080+
throw new Error('Cannot update optimistic state while rendering.');
3081+
} else {
3082+
// startTransition was called during render. We don't need to do anything
3083+
// besides warn here because the render phase update would be overidden by
3084+
// the second update, anyway. We can remove this branch and make it throw
3085+
// in a future release.
3086+
if (__DEV__) {
3087+
console.error('Cannot call startTransition state while rendering.');
3088+
}
3089+
}
3090+
} else {
3091+
const root = enqueueConcurrentHookUpdate(fiber, queue, update, SyncLane);
3092+
if (root !== null) {
3093+
// NOTE: The optimistic update implementation assumes that the transition
3094+
// will never be attempted before the optimistic update. This currently
3095+
// holds because the optimistic update is always synchronous. If we ever
3096+
// change that, we'll need to account for this.
3097+
scheduleUpdateOnFiber(root, fiber, SyncLane);
3098+
// Optimistic updates are always synchronous, so we don't need to call
3099+
// entangleTransitionUpdate here.
3100+
}
3101+
}
3102+
3103+
markUpdateInDevTools(fiber, SyncLane, action);
3104+
}
3105+
29313106
function isRenderPhaseUpdate(fiber: Fiber): boolean {
29323107
const alternate = fiber.alternate;
29333108
return (

0 commit comments

Comments
 (0)