@samitouri / QOS-React-2 / commits / 8e80592a3a

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(