Remove state queue from useSyncExternalStore (#22265)
The userspace shim of useSyncExternalStore uses a useState hook because it's the only way to trigger a re-render. We don't actually use the queue to store anything, because we read the current value directly from the store. In the native implementation, we can schedule an update on the fiber directly, without the overhead of a queue.
Andrew Clark committed
Sep 7, 2021 at 13:27 UTC
8e80592a3ab2bdbd036f5af77c89fcf937b8f43b
2 files changed
+136
-124
packages/react-reconciler/src/ReactFiberHooks.new.js
+68
-62
@@ -41,6 +41,7 @@ import {
41
} from './ReactTypeOfMode';
42
import {
43
NoLane,
44
+ SyncLane,
45
NoLanes,
46
isSubsetOfLanes,
47
mergeLanes,
@@ -49,9 +50,9 @@ import {
50
isTransitionLane,
51
markRootEntangled,
52
markRootMutableRead,
53
+ NoTimestamp,
54
} from './ReactFiberLane.new';
55
import {
54
- DiscreteEventPriority,
56
ContinuousEventPriority,
57
getCurrentUpdatePriority,
58
setCurrentUpdatePriority,
@@ -147,7 +148,7 @@ export type Hook = {|
148
memoizedState: any,
149
baseState: any,
150
baseQueue: Update<any, any> | null,
150
- queue: UpdateQueue<any, any> | null,
151
+ queue: any,
152
next: Hook | null,
153
|};
154
@@ -159,6 +160,11 @@ export type Effect = {|
160
next: Effect,
161
|};
162
163
+type StoreInstance<T> = {|
164
+ value: T,
165
+ getSnapshot: () => T,
166
+|};
167
+
168
export type FunctionComponentUpdateQueue = {|lastEffect: Effect | null|};
169
170
type BasicStateAction<S> = (S => S) | S;
@@ -703,14 +709,15 @@ function mountReducer<S, I, A>(
709
initialState = ((initialArg: any): S);
710
}
711
hook.memoizedState = hook.baseState = initialState;
706
- const queue = (hook.queue = {
712
+ const queue: UpdateQueue<S, A> = {
713
pending: null,
714
interleaved: null,
715
lanes: NoLanes,
716
dispatch: null,
717
lastRenderedReducer: reducer,
718
lastRenderedState: (initialState: any),
713
- });
719
+ };
720
+ hook.queue = queue;
721
const dispatch: Dispatch<A> = (queue.dispatch = (dispatchAction.bind(
722
null,
723
currentlyRenderingFiber,
@@ -1196,7 +1203,7 @@ function useMutableSource<Source, Snapshot>(
1203
// So if there are interleaved updates, they get pushed to the older queue.
1204
// When this becomes current, the previous queue and dispatch method will be discarded,
1205
// including any interleaving updates that occur.
1199
- const newQueue = {
1206
+ const newQueue: UpdateQueue<Snapshot, BasicStateAction<Snapshot>> = {
1207
pending: null,
1208
interleaved: null,
1209
lanes: NoLanes,
@@ -1249,7 +1256,27 @@ function mountSyncExternalStore<T>(
1256
getSnapshot: () => T,
1257
): T {
1258
const hook = mountWorkInProgressHook();
1252
- return useSyncExternalStore(hook, subscribe, getSnapshot);
1259
+ // Read the current snapshot from the store on every render. This breaks the
1260
+ // normal rules of React, and only works because store updates are
1261
+ // always synchronous.
1262
+ const nextSnapshot = getSnapshot();
1263
+ if (__DEV__) {
1264
+ if (!didWarnUncachedGetSnapshot) {
1265
+ if (nextSnapshot !== getSnapshot()) {
1266
+ console.error(
1267
+ 'The result of getSnapshot should be cached to avoid an infinite loop',
1268
+ );
1269
+ didWarnUncachedGetSnapshot = true;
1270
+ }
1271
+ }
1272
+ }
1273
+ hook.memoizedState = nextSnapshot;
1274
+ const inst: StoreInstance<T> = {
1275
+ value: nextSnapshot,
1276
+ getSnapshot,
1277
+ };
1278
+ hook.queue = inst;
1279
+ return useSyncExternalStore(hook, inst, subscribe, getSnapshot, nextSnapshot);
1280
}
1281
1282
function updateSyncExternalStore<T>(
@@ -1257,29 +1284,13 @@ function updateSyncExternalStore<T>(
1284
getSnapshot: () => T,
1285
): T {
1286
const hook = updateWorkInProgressHook();
1260
- return useSyncExternalStore(hook, subscribe, getSnapshot);
1261
-}
1262
-
1263
-function useSyncExternalStore<T>(
1264
- hook: Hook,
1265
- subscribe: (() => void) => () => void,
1266
- getSnapshot: () => T,
1267
-): T {
1268
- // TODO: This is a copy-paste of the userspace shim. We can improve the
1269
- // built-in implementation using lower-level APIs. We also intend to move
1270
- // the tearing checks to an earlier, pre-commit phase so that the layout
1271
- // effects always observe a consistent tree.
1272
-
1273
- const dispatcher = ReactCurrentDispatcher.current;
1274
-
1275
- // Read the current snapshot from the store on every render. Again, this
1276
- // breaks the rules of React, and only works here because of specific
1277
- // implementation details, most importantly that updates are
1287
+ // Read the current snapshot from the store on every render. This breaks the
1288
+ // normal rules of React, and only works because store updates are
1289
// always synchronous.
1279
- const value = getSnapshot();
1290
+ const nextSnapshot = getSnapshot();
1291
if (__DEV__) {
1292
if (!didWarnUncachedGetSnapshot) {
1282
- if (value !== getSnapshot()) {
1293
+ if (nextSnapshot !== getSnapshot()) {
1294
console.error(
1295
'The result of getSnapshot should be cached to avoid an infinite loop',
1296
);
@@ -1287,48 +1298,44 @@ function useSyncExternalStore<T>(
1298
}
1299
}
1300
}
1301
+ const prevSnapshot = hook.memoizedState;
1302
+ if (!is(prevSnapshot, nextSnapshot)) {
1303
+ hook.memoizedState = nextSnapshot;
1304
+ markWorkInProgressReceivedUpdate();
1305
+ }
1306
+ const inst = hook.queue;
1307
+ return useSyncExternalStore(hook, inst, subscribe, getSnapshot, nextSnapshot);
1308
+}
1309
1291
- // Because updates are synchronous, we don't queue them. Instead we force a
1292
- // re-render whenever the subscribed state changes by updating an some
1293
- // arbitrary useState hook. Then, during render, we call getSnapshot to read
1294
- // the current value.
1295
- //
1296
- // Because we don't actually use the state returned by the useState hook, we
1297
- // can save a bit of memory by storing other stuff in that slot.
1298
- //
1299
- // To implement the early bailout, we need to track some things on a mutable
1300
- // object. Usually, we would put that in a useRef hook, but we can stash it in
1301
- // our useState hook instead.
1302
- //
1303
- // To force a re-render, we call forceUpdate({inst}). That works because the
1304
- // new object always fails an equality check.
1305
- const [{inst}, forceUpdate] = dispatcher.useState({
1306
- inst: {value, getSnapshot},
1307
- });
1310
+function useSyncExternalStore<T>(
1311
+ hook: Hook,
1312
+ inst: StoreInstance<T>,
1313
+ subscribe: (() => void) => () => void,
1314
+ getSnapshot: () => T,
1315
+ nextSnapshot: T,
1316
+): T {
1317
+ const fiber = currentlyRenderingFiber;
1318
+ const dispatcher = ReactCurrentDispatcher.current;
1319
1320
// Track the latest getSnapshot function with a ref. This needs to be updated
1321
// in the layout phase so we can access it during the tearing check that
1322
// happens on subscribe.
1323
// TODO: Circumvent SSR warning
1324
dispatcher.useLayoutEffect(() => {
1314
- inst.value = value;
1325
+ inst.value = nextSnapshot;
1326
inst.getSnapshot = getSnapshot;
1327
1328
// Whenever getSnapshot or subscribe changes, we need to check in the
1329
// commit phase if there was an interleaved mutation. In concurrent mode
1330
// this can happen all the time, but even in synchronous mode, an earlier
1331
// effect may have mutated the store.
1332
+ // TODO: Move the tearing checks to an earlier, pre-commit phase so that the
1333
+ // layout effects always observe a consistent tree.
1334
if (checkIfSnapshotChanged(inst)) {
1335
// Force a re-render.
1323
- const prevTransition = ReactCurrentBatchConfig.transition;
1324
- const prevPriority = getCurrentUpdatePriority();
1325
- ReactCurrentBatchConfig.transition = 0;
1326
- setCurrentUpdatePriority(DiscreteEventPriority);
1327
- forceUpdate({inst});
1328
- setCurrentUpdatePriority(prevPriority);
1329
- ReactCurrentBatchConfig.transition = prevTransition;
1336
+ forceStoreRerender(fiber);
1337
}
1331
- }, [subscribe, value, getSnapshot]);
1338
+ }, [subscribe, nextSnapshot, getSnapshot]);
1339
1340
dispatcher.useEffect(() => {
1341
const handleStoreChange = () => {
@@ -1341,13 +1348,7 @@ function useSyncExternalStore<T>(
1348
// read from the store.
1349
if (checkIfSnapshotChanged(inst)) {
1350
// Force a re-render.
1344
- const prevTransition = ReactCurrentBatchConfig.transition;
1345
- const prevPriority = getCurrentUpdatePriority();
1346
- ReactCurrentBatchConfig.transition = 0;
1347
- setCurrentUpdatePriority(DiscreteEventPriority);
1348
- forceUpdate({inst});
1349
- setCurrentUpdatePriority(prevPriority);
1350
- ReactCurrentBatchConfig.transition = prevTransition;
1351
+ forceStoreRerender(fiber);
1352
}
1353
};
1354
// Check for changes right before subscribing. Subsequent changes will be
@@ -1357,7 +1358,7 @@ function useSyncExternalStore<T>(
1358
return subscribe(handleStoreChange);
1359
}, [subscribe]);
1360
1360
- return value;
1361
+ return nextSnapshot;
1362
}
1363
1364
function checkIfSnapshotChanged(inst) {
@@ -1371,6 +1372,10 @@ function checkIfSnapshotChanged(inst) {
1372
}
1373
}
1374
1375
+function forceStoreRerender(fiber) {
1376
+ scheduleUpdateOnFiber(fiber, SyncLane, NoTimestamp);
1377
+}
1378
+
1379
function mountState<S>(
1380
initialState: (() => S) | S,
1381
): [S, Dispatch<BasicStateAction<S>>] {
@@ -1380,14 +1385,15 @@ function mountState<S>(
1385
initialState = initialState();
1386
}
1387
hook.memoizedState = hook.baseState = initialState;
1383
- const queue = (hook.queue = {
1388
+ const queue: UpdateQueue<S, BasicStateAction<S>> = {
1389
pending: null,
1390
interleaved: null,
1391
lanes: NoLanes,
1392
dispatch: null,
1393
lastRenderedReducer: basicStateReducer,
1394
lastRenderedState: (initialState: any),
1390
- });
1395
+ };
1396
+ hook.queue = queue;
1397
const dispatch: Dispatch<
1398
BasicStateAction<S>,
1399
> = (queue.dispatch = (dispatchAction.bind(
packages/react-reconciler/src/ReactFiberHooks.old.js
+68
-62
@@ -41,6 +41,7 @@ import {
41
} from './ReactTypeOfMode';
42
import {
43
NoLane,
44
+ SyncLane,
45
NoLanes,
46
isSubsetOfLanes,
47
mergeLanes,
@@ -49,9 +50,9 @@ import {
50
isTransitionLane,
51
markRootEntangled,
52
markRootMutableRead,
53
+ NoTimestamp,
54
} from './ReactFiberLane.old';
55
import {
54
- DiscreteEventPriority,
56
ContinuousEventPriority,
57
getCurrentUpdatePriority,
58
setCurrentUpdatePriority,
@@ -147,7 +148,7 @@ export type Hook = {|
148
memoizedState: any,
149
baseState: any,
150
baseQueue: Update<any, any> | null,
150
- queue: UpdateQueue<any, any> | null,
151
+ queue: any,
152
next: Hook | null,
153
|};
154
@@ -159,6 +160,11 @@ export type Effect = {|
160
next: Effect,
161
|};
162
163
+type StoreInstance<T> = {|
164
+ value: T,
165
+ getSnapshot: () => T,
166
+|};
167
+
168
export type FunctionComponentUpdateQueue = {|lastEffect: Effect | null|};
169
170
type BasicStateAction<S> = (S => S) | S;
@@ -703,14 +709,15 @@ function mountReducer<S, I, A>(
709
initialState = ((initialArg: any): S);
710
}
711
hook.memoizedState = hook.baseState = initialState;
706
- const queue = (hook.queue = {
712
+ const queue: UpdateQueue<S, A> = {
713
pending: null,
714
interleaved: null,
715
lanes: NoLanes,
716
dispatch: null,
717
lastRenderedReducer: reducer,
718
lastRenderedState: (initialState: any),
713
- });
719
+ };
720
+ hook.queue = queue;
721
const dispatch: Dispatch<A> = (queue.dispatch = (dispatchAction.bind(
722
null,
723
currentlyRenderingFiber,
@@ -1196,7 +1203,7 @@ function useMutableSource<Source, Snapshot>(
1203
// So if there are interleaved updates, they get pushed to the older queue.
1204
// When this becomes current, the previous queue and dispatch method will be discarded,
1205
// including any interleaving updates that occur.
1199
- const newQueue = {
1206
+ const newQueue: UpdateQueue<Snapshot, BasicStateAction<Snapshot>> = {
1207
pending: null,
1208
interleaved: null,
1209
lanes: NoLanes,
@@ -1249,7 +1256,27 @@ function mountSyncExternalStore<T>(
1256
getSnapshot: () => T,
1257
): T {
1258
const hook = mountWorkInProgressHook();
1252
- return useSyncExternalStore(hook, subscribe, getSnapshot);
1259
+ // Read the current snapshot from the store on every render. This breaks the
1260
+ // normal rules of React, and only works because store updates are
1261
+ // always synchronous.
1262
+ const nextSnapshot = getSnapshot();
1263
+ if (__DEV__) {
1264
+ if (!didWarnUncachedGetSnapshot) {
1265
+ if (nextSnapshot !== getSnapshot()) {
1266
+ console.error(
1267
+ 'The result of getSnapshot should be cached to avoid an infinite loop',
1268
+ );
1269
+ didWarnUncachedGetSnapshot = true;
1270
+ }
1271
+ }
1272
+ }
1273
+ hook.memoizedState = nextSnapshot;
1274
+ const inst: StoreInstance<T> = {
1275
+ value: nextSnapshot,
1276
+ getSnapshot,
1277
+ };
1278
+ hook.queue = inst;
1279
+ return useSyncExternalStore(hook, inst, subscribe, getSnapshot, nextSnapshot);
1280
}
1281
1282
function updateSyncExternalStore<T>(
@@ -1257,29 +1284,13 @@ function updateSyncExternalStore<T>(
1284
getSnapshot: () => T,
1285
): T {
1286
const hook = updateWorkInProgressHook();
1260
- return useSyncExternalStore(hook, subscribe, getSnapshot);
1261
-}
1262
-
1263
-function useSyncExternalStore<T>(
1264
- hook: Hook,
1265
- subscribe: (() => void) => () => void,
1266
- getSnapshot: () => T,
1267
-): T {
1268
- // TODO: This is a copy-paste of the userspace shim. We can improve the
1269
- // built-in implementation using lower-level APIs. We also intend to move
1270
- // the tearing checks to an earlier, pre-commit phase so that the layout
1271
- // effects always observe a consistent tree.
1272
-
1273
- const dispatcher = ReactCurrentDispatcher.current;
1274
-
1275
- // Read the current snapshot from the store on every render. Again, this
1276
- // breaks the rules of React, and only works here because of specific
1277
- // implementation details, most importantly that updates are
1287
+ // Read the current snapshot from the store on every render. This breaks the
1288
+ // normal rules of React, and only works because store updates are
1289
// always synchronous.
1279
- const value = getSnapshot();
1290
+ const nextSnapshot = getSnapshot();
1291
if (__DEV__) {
1292
if (!didWarnUncachedGetSnapshot) {
1282
- if (value !== getSnapshot()) {
1293
+ if (nextSnapshot !== getSnapshot()) {
1294
console.error(
1295
'The result of getSnapshot should be cached to avoid an infinite loop',
1296
);
@@ -1287,48 +1298,44 @@ function useSyncExternalStore<T>(
1298
}
1299
}
1300
}
1301
+ const prevSnapshot = hook.memoizedState;
1302
+ if (!is(prevSnapshot, nextSnapshot)) {
1303
+ hook.memoizedState = nextSnapshot;
1304
+ markWorkInProgressReceivedUpdate();
1305
+ }
1306
+ const inst = hook.queue;
1307
+ return useSyncExternalStore(hook, inst, subscribe, getSnapshot, nextSnapshot);
1308
+}
1309
1291
- // Because updates are synchronous, we don't queue them. Instead we force a
1292
- // re-render whenever the subscribed state changes by updating an some
1293
- // arbitrary useState hook. Then, during render, we call getSnapshot to read
1294
- // the current value.
1295
- //
1296
- // Because we don't actually use the state returned by the useState hook, we
1297
- // can save a bit of memory by storing other stuff in that slot.
1298
- //
1299
- // To implement the early bailout, we need to track some things on a mutable
1300
- // object. Usually, we would put that in a useRef hook, but we can stash it in
1301
- // our useState hook instead.
1302
- //
1303
- // To force a re-render, we call forceUpdate({inst}). That works because the
1304
- // new object always fails an equality check.
1305
- const [{inst}, forceUpdate] = dispatcher.useState({
1306
- inst: {value, getSnapshot},
1307
- });
1310
+function useSyncExternalStore<T>(
1311
+ hook: Hook,
1312
+ inst: StoreInstance<T>,
1313
+ subscribe: (() => void) => () => void,
1314
+ getSnapshot: () => T,
1315
+ nextSnapshot: T,
1316
+): T {
1317
+ const fiber = currentlyRenderingFiber;
1318
+ const dispatcher = ReactCurrentDispatcher.current;
1319
1320
// Track the latest getSnapshot function with a ref. This needs to be updated
1321
// in the layout phase so we can access it during the tearing check that
1322
// happens on subscribe.
1323
// TODO: Circumvent SSR warning
1324
dispatcher.useLayoutEffect(() => {
1314
- inst.value = value;
1325
+ inst.value = nextSnapshot;
1326
inst.getSnapshot = getSnapshot;
1327
1328
// Whenever getSnapshot or subscribe changes, we need to check in the
1329
// commit phase if there was an interleaved mutation. In concurrent mode
1330
// this can happen all the time, but even in synchronous mode, an earlier
1331
// effect may have mutated the store.
1332
+ // TODO: Move the tearing checks to an earlier, pre-commit phase so that the
1333
+ // layout effects always observe a consistent tree.
1334
if (checkIfSnapshotChanged(inst)) {
1335
// Force a re-render.
1323
- const prevTransition = ReactCurrentBatchConfig.transition;
1324
- const prevPriority = getCurrentUpdatePriority();
1325
- ReactCurrentBatchConfig.transition = 0;
1326
- setCurrentUpdatePriority(DiscreteEventPriority);
1327
- forceUpdate({inst});
1328
- setCurrentUpdatePriority(prevPriority);
1329
- ReactCurrentBatchConfig.transition = prevTransition;
1336
+ forceStoreRerender(fiber);
1337
}
1331
- }, [subscribe, value, getSnapshot]);
1338
+ }, [subscribe, nextSnapshot, getSnapshot]);
1339
1340
dispatcher.useEffect(() => {
1341
const handleStoreChange = () => {
@@ -1341,13 +1348,7 @@ function useSyncExternalStore<T>(
1348
// read from the store.
1349
if (checkIfSnapshotChanged(inst)) {
1350
// Force a re-render.
1344
- const prevTransition = ReactCurrentBatchConfig.transition;
1345
- const prevPriority = getCurrentUpdatePriority();
1346
- ReactCurrentBatchConfig.transition = 0;
1347
- setCurrentUpdatePriority(DiscreteEventPriority);
1348
- forceUpdate({inst});
1349
- setCurrentUpdatePriority(prevPriority);
1350
- ReactCurrentBatchConfig.transition = prevTransition;
1351
+ forceStoreRerender(fiber);
1352
}
1353
};
1354
// Check for changes right before subscribing. Subsequent changes will be
@@ -1357,7 +1358,7 @@ function useSyncExternalStore<T>(
1358
return subscribe(handleStoreChange);
1359
}, [subscribe]);
1360
1360
- return value;
1361
+ return nextSnapshot;
1362
}
1363
1364
function checkIfSnapshotChanged(inst) {
@@ -1371,6 +1372,10 @@ function checkIfSnapshotChanged(inst) {
1372
}
1373
}
1374
1375
+function forceStoreRerender(fiber) {
1376
+ scheduleUpdateOnFiber(fiber, SyncLane, NoTimestamp);
1377
+}
1378
+
1379
function mountState<S>(
1380
initialState: (() => S) | S,
1381
): [S, Dispatch<BasicStateAction<S>>] {
@@ -1380,14 +1385,15 @@ function mountState<S>(
1385
initialState = initialState();
1386
}
1387
hook.memoizedState = hook.baseState = initialState;
1383
- const queue = (hook.queue = {
1388
+ const queue: UpdateQueue<S, BasicStateAction<S>> = {
1389
pending: null,
1390
interleaved: null,
1391
lanes: NoLanes,
1392
dispatch: null,
1393
lastRenderedReducer: basicStateReducer,
1394
lastRenderedState: (initialState: any),
1390
- });
1395
+ };
1396
+ hook.queue = queue;
1397
const dispatch: Dispatch<
1398
BasicStateAction<S>,
1399
> = (queue.dispatch = (dispatchAction.bind(