Skip to content

Commit ee1d8a4

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 4d57c87 commit ee1d8a4

File tree

3 files changed

+621
-45
lines changed

3 files changed

+621
-45
lines changed

packages/react-reconciler/src/ReactFiberHooks.js

Lines changed: 198 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,
@@ -1138,6 +1140,14 @@ function updateReducer<S, I, A>(
11381140
init?: I => S,
11391141
): [S, Dispatch<A>] {
11401142
const hook = updateWorkInProgressHook();
1143+
return updateReducerImpl(hook, ((currentHook: any): Hook), reducer);
1144+
}
1145+
1146+
function updateReducerImpl<S, A>(
1147+
hook: Hook,
1148+
current: Hook,
1149+
reducer: (S, A) => S,
1150+
): [S, Dispatch<A>] {
11411151
const queue = hook.queue;
11421152

11431153
if (queue === null) {
@@ -1148,10 +1158,8 @@ function updateReducer<S, I, A>(
11481158

11491159
queue.lastRenderedReducer = reducer;
11501160

1151-
const current: Hook = (currentHook: any);
1152-
11531161
// The last rebase update that is NOT part of the base state.
1154-
let baseQueue = current.baseQueue;
1162+
let baseQueue = hook.baseQueue;
11551163

11561164
// The last pending update that hasn't been processed yet.
11571165
const pendingQueue = queue.pending;
@@ -1182,7 +1190,7 @@ function updateReducer<S, I, A>(
11821190
if (baseQueue !== null) {
11831191
// We have a queue to process.
11841192
const first = baseQueue.next;
1185-
let newState = current.baseState;
1193+
let newState = hook.baseState;
11861194

11871195
let newBaseState = null;
11881196
let newBaseQueueFirst = null;
@@ -1208,6 +1216,7 @@ function updateReducer<S, I, A>(
12081216
// update/state.
12091217
const clone: Update<S, A> = {
12101218
lane: updateLane,
1219+
revertLane: update.revertLane,
12111220
action: update.action,
12121221
hasEagerState: update.hasEagerState,
12131222
eagerState: update.eagerState,
@@ -1230,18 +1239,68 @@ function updateReducer<S, I, A>(
12301239
} else {
12311240
// This update does have sufficient priority.
12321241

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

12471306
// Process this update.
@@ -1901,56 +1960,106 @@ function mountStateImpl<S>(initialState: (() => S) | S): Hook {
19011960
lastRenderedState: (initialState: any),
19021961
};
19031962
hook.queue = queue;
1904-
const dispatch: Dispatch<BasicStateAction<S>> = (dispatchSetState.bind(
1905-
null,
1906-
currentlyRenderingFiber,
1907-
queue,
1908-
): any);
1909-
queue.dispatch = dispatch;
19101963
return hook;
19111964
}
19121965

19131966
function mountState<S>(
19141967
initialState: (() => S) | S,
19151968
): [S, Dispatch<BasicStateAction<S>>] {
19161969
const hook = mountStateImpl(initialState);
1917-
return [hook.memoizedState, hook.queue.dispatch];
1970+
const queue = hook.queue;
1971+
const dispatch: Dispatch<BasicStateAction<S>> = (dispatchSetState.bind(
1972+
null,
1973+
currentlyRenderingFiber,
1974+
queue,
1975+
): any);
1976+
queue.dispatch = dispatch;
1977+
return [hook.memoizedState, dispatch];
19181978
}
19191979

19201980
function updateState<S>(
19211981
initialState: (() => S) | S,
19221982
): [S, Dispatch<BasicStateAction<S>>] {
1923-
return updateReducer(basicStateReducer, (initialState: any));
1983+
return updateReducer(basicStateReducer, initialState);
19241984
}
19251985

19261986
function rerenderState<S>(
19271987
initialState: (() => S) | S,
19281988
): [S, Dispatch<BasicStateAction<S>>] {
1929-
return rerenderReducer(basicStateReducer, (initialState: any));
1989+
return rerenderReducer(basicStateReducer, initialState);
19301990
}
19311991

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

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

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

19562065
function pushEffect(
@@ -2492,9 +2601,15 @@ function startTransition<S>(
24922601
higherEventPriority(previousPriority, ContinuousEventPriority),
24932602
);
24942603

2604+
// We don't really need to use an optimistic update here, because we schedule
2605+
// a second "revert" update below (which we use to suspend the transition
2606+
// until the async action scope has finished). But we'll use an optimistic
2607+
// update anyway to make it less likely the behavior accidentally diverges;
2608+
// for example, both an optimistic update and this one should share the
2609+
// same lane.
2610+
dispatchOptimisticSetState(fiber, false, queue, pendingState);
2611+
24952612
const prevTransition = ReactCurrentBatchConfig.transition;
2496-
ReactCurrentBatchConfig.transition = null;
2497-
dispatchSetState(fiber, queue, pendingState);
24982613
const currentTransition = (ReactCurrentBatchConfig.transition =
24992614
({}: BatchConfigTransition));
25002615

@@ -2829,6 +2944,7 @@ function dispatchReducerAction<S, A>(
28292944

28302945
const update: Update<S, A> = {
28312946
lane,
2947+
revertLane: NoLane,
28322948
action,
28332949
hasEagerState: false,
28342950
eagerState: null,
@@ -2867,6 +2983,7 @@ function dispatchSetState<S, A>(
28672983

28682984
const update: Update<S, A> = {
28692985
lane,
2986+
revertLane: NoLane,
28702987
action,
28712988
hasEagerState: false,
28722989
eagerState: null,
@@ -2930,6 +3047,54 @@ function dispatchSetState<S, A>(
29303047
markUpdateInDevTools(fiber, lane, action);
29313048
}
29323049

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

0 commit comments

Comments
 (0)