Skip to content

Commit 33226fa

Browse files
authored
Check for store mutations before commit (#22290)
* [useSyncExternalStore] Remove extra hook object Because we already track `getSnapshot` and `value` on the store instance, we don't need to also track them as effect dependencies. And because the effect doesn't require any clean-up, we don't need to track a `destroy` function. So, we don't need to store any additional state for this effect. We can call `pushEffect` directly, and only during renders where something has changed. This saves some memory, but my main motivation is because I plan to use this same logic to schedule a pre-commit consistency check. (See the inline comments for more details.) * Split shouldTimeSlice into two separate functions Lanes that are blocking (SyncLane, and DefaultLane inside a blocking- by-default root) are always blocking for a given root. Whereas expired lanes can expire while the render phase is already in progress. I want to check if a lane is blocking without checking whether it expired, so I split `shouldTimeSlice` into two separate functions. I'll use this in the next step. * Check for store mutations before commit When a store is read for the first time, or when `subscribe` or `getSnapshot` changes, during a concurrent render, we have to check at the end of the render phase whether the store was mutated by an concurrent event. In the userspace shim, we perform this check in a layout effect, and patch up any inconsistencies by scheduling another render + commit. However, even though we patch them up in the next render, the parent layout effects that fire in the original render will still observe an inconsistent tree. In the native implementation, we can instead check for inconsistencies right after the root is completed, before entering the commit phase. If we do detect a mutaiton, we can discard the tree and re-render before firing any effects. The re-render is synchronous to block further concurrent mutations (which is also what we do to recover from tearing bugs that result in an error). After the synchronous re-render, we can assume the tree the tree is consistent and continue with the normal algorithm for finishing a completed root (i.e. either suspend or commit). The result is that layout effects will always observe a consistent tree.
1 parent 86c7ca7 commit 33226fa

10 files changed

+747
-201
lines changed

packages/react-debug-tools/src/ReactDebugHooks.js

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -273,7 +273,6 @@ function useSyncExternalStore<T>(
273273
// Advance the current hook index the same number of times
274274
// so that subsequent hooks have the right memoized state.
275275
nextHook(); // SyncExternalStore
276-
nextHook(); // LayoutEffect
277276
nextHook(); // Effect
278277
const value = getSnapshot();
279278
hookLog.push({

packages/react-reconciler/src/ReactFiberFlags.js

Lines changed: 28 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -12,51 +12,53 @@ import {enableCreateEventHandleAPI} from 'shared/ReactFeatureFlags';
1212
export type Flags = number;
1313

1414
// Don't change these two values. They're used by React Dev Tools.
15-
export const NoFlags = /* */ 0b00000000000000000000000;
16-
export const PerformedWork = /* */ 0b00000000000000000000001;
15+
export const NoFlags = /* */ 0b000000000000000000000000;
16+
export const PerformedWork = /* */ 0b000000000000000000000001;
1717

1818
// You can change the rest (and add more).
19-
export const Placement = /* */ 0b00000000000000000000010;
20-
export const Update = /* */ 0b00000000000000000000100;
19+
export const Placement = /* */ 0b000000000000000000000010;
20+
export const Update = /* */ 0b000000000000000000000100;
2121
export const PlacementAndUpdate = /* */ Placement | Update;
22-
export const Deletion = /* */ 0b00000000000000000001000;
23-
export const ChildDeletion = /* */ 0b00000000000000000010000;
24-
export const ContentReset = /* */ 0b00000000000000000100000;
25-
export const Callback = /* */ 0b00000000000000001000000;
26-
export const DidCapture = /* */ 0b00000000000000010000000;
27-
export const Ref = /* */ 0b00000000000000100000000;
28-
export const Snapshot = /* */ 0b00000000000001000000000;
29-
export const Passive = /* */ 0b00000000000010000000000;
30-
export const Hydrating = /* */ 0b00000000000100000000000;
22+
export const Deletion = /* */ 0b000000000000000000001000;
23+
export const ChildDeletion = /* */ 0b000000000000000000010000;
24+
export const ContentReset = /* */ 0b000000000000000000100000;
25+
export const Callback = /* */ 0b000000000000000001000000;
26+
export const DidCapture = /* */ 0b000000000000000010000000;
27+
export const Ref = /* */ 0b000000000000000100000000;
28+
export const Snapshot = /* */ 0b000000000000001000000000;
29+
export const Passive = /* */ 0b000000000000010000000000;
30+
export const Hydrating = /* */ 0b000000000000100000000000;
3131
export const HydratingAndUpdate = /* */ Hydrating | Update;
32-
export const Visibility = /* */ 0b00000000001000000000000;
32+
export const Visibility = /* */ 0b000000000001000000000000;
33+
export const StoreConsistency = /* */ 0b000000000010000000000000;
3334

34-
export const LifecycleEffectMask = Passive | Update | Callback | Ref | Snapshot;
35+
export const LifecycleEffectMask =
36+
Passive | Update | Callback | Ref | Snapshot | StoreConsistency;
3537

3638
// Union of all commit flags (flags with the lifetime of a particular commit)
37-
export const HostEffectMask = /* */ 0b00000000001111111111111;
39+
export const HostEffectMask = /* */ 0b000000000011111111111111;
3840

3941
// These are not really side effects, but we still reuse this field.
40-
export const Incomplete = /* */ 0b00000000010000000000000;
41-
export const ShouldCapture = /* */ 0b00000000100000000000000;
42-
export const ForceUpdateForLegacySuspense = /* */ 0b00000001000000000000000;
43-
export const DidPropagateContext = /* */ 0b00000010000000000000000;
44-
export const NeedsPropagation = /* */ 0b00000100000000000000000;
42+
export const Incomplete = /* */ 0b000000000100000000000000;
43+
export const ShouldCapture = /* */ 0b000000001000000000000000;
44+
export const ForceUpdateForLegacySuspense = /* */ 0b000000010000000000000000;
45+
export const DidPropagateContext = /* */ 0b000000100000000000000000;
46+
export const NeedsPropagation = /* */ 0b000001000000000000000000;
4547

4648
// Static tags describe aspects of a fiber that are not specific to a render,
4749
// e.g. a fiber uses a passive effect (even if there are no updates on this particular render).
4850
// This enables us to defer more work in the unmount case,
4951
// since we can defer traversing the tree during layout to look for Passive effects,
5052
// and instead rely on the static flag as a signal that there may be cleanup work.
51-
export const RefStatic = /* */ 0b00001000000000000000000;
52-
export const LayoutStatic = /* */ 0b00010000000000000000000;
53-
export const PassiveStatic = /* */ 0b00100000000000000000000;
53+
export const RefStatic = /* */ 0b000010000000000000000000;
54+
export const LayoutStatic = /* */ 0b000100000000000000000000;
55+
export const PassiveStatic = /* */ 0b001000000000000000000000;
5456

5557
// These flags allow us to traverse to fibers that have effects on mount
5658
// without traversing the entire tree after every commit for
5759
// double invoking
58-
export const MountLayoutDev = /* */ 0b01000000000000000000000;
59-
export const MountPassiveDev = /* */ 0b10000000000000000000000;
60+
export const MountLayoutDev = /* */ 0b010000000000000000000000;
61+
export const MountPassiveDev = /* */ 0b100000000000000000000000;
6062

6163
// Groups of flags that are used in the commit phase to skip over trees that
6264
// don't contain effects, by checking subtreeFlags.

packages/react-reconciler/src/ReactFiberHooks.new.js

Lines changed: 136 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@ import {
4444
SyncLane,
4545
NoLanes,
4646
isSubsetOfLanes,
47+
includesBlockingLane,
4748
mergeLanes,
4849
removeLanes,
4950
intersectLanes,
@@ -68,6 +69,7 @@ import {
6869
PassiveStatic as PassiveStaticEffect,
6970
StaticMask as StaticMaskEffect,
7071
Update as UpdateEffect,
72+
StoreConsistency,
7173
} from './ReactFiberFlags';
7274
import {
7375
HasEffect as HookHasEffect,
@@ -166,7 +168,15 @@ type StoreInstance<T> = {|
166168
getSnapshot: () => T,
167169
|};
168170

169-
export type FunctionComponentUpdateQueue = {|lastEffect: Effect | null|};
171+
type StoreConsistencyCheck<T> = {|
172+
value: T,
173+
getSnapshot: () => T,
174+
|};
175+
176+
export type FunctionComponentUpdateQueue = {|
177+
lastEffect: Effect | null,
178+
stores: Array<StoreConsistencyCheck<any>> | null,
179+
|};
170180

171181
type BasicStateAction<S> = (S => S) | S;
172182

@@ -689,6 +699,7 @@ function updateWorkInProgressHook(): Hook {
689699
function createFunctionComponentUpdateQueue(): FunctionComponentUpdateQueue {
690700
return {
691701
lastEffect: null,
702+
stores: null,
692703
};
693704
}
694705

@@ -1256,6 +1267,7 @@ function mountSyncExternalStore<T>(
12561267
subscribe: (() => void) => () => void,
12571268
getSnapshot: () => T,
12581269
): T {
1270+
const fiber = currentlyRenderingFiber;
12591271
const hook = mountWorkInProgressHook();
12601272
// Read the current snapshot from the store on every render. This breaks the
12611273
// normal rules of React, and only works because store updates are
@@ -1277,13 +1289,45 @@ function mountSyncExternalStore<T>(
12771289
getSnapshot,
12781290
};
12791291
hook.queue = inst;
1280-
return useSyncExternalStore(hook, inst, subscribe, getSnapshot, nextSnapshot);
1292+
1293+
// Schedule an effect to subscribe to the store.
1294+
mountEffect(subscribeToStore.bind(null, fiber, inst, subscribe), [subscribe]);
1295+
1296+
// Schedule an effect to update the mutable instance fields. We will update
1297+
// this whenever subscribe, getSnapshot, or value changes. Because there's no
1298+
// clean-up function, and we track the deps correctly, we can call pushEffect
1299+
// directly, without storing any additional state. For the same reason, we
1300+
// don't need to set a static flag, either.
1301+
// TODO: We can move this to the passive phase once we add a pre-commit
1302+
// consistency check. See the next comment.
1303+
fiber.flags |= PassiveEffect;
1304+
pushEffect(
1305+
HookHasEffect | HookPassive,
1306+
updateStoreInstance.bind(null, fiber, inst, nextSnapshot, getSnapshot),
1307+
undefined,
1308+
null,
1309+
);
1310+
1311+
// Unless we're rendering a blocking lane, schedule a consistency check. Right
1312+
// before committing, we will walk the tree and check if any of the stores
1313+
// were mutated.
1314+
const root: FiberRoot | null = getWorkInProgressRoot();
1315+
invariant(
1316+
root !== null,
1317+
'Expected a work-in-progress root. This is a bug in React. Please file an issue.',
1318+
);
1319+
if (!includesBlockingLane(root, renderLanes)) {
1320+
pushStoreConsistencyCheck(fiber, getSnapshot, nextSnapshot);
1321+
}
1322+
1323+
return nextSnapshot;
12811324
}
12821325

12831326
function updateSyncExternalStore<T>(
12841327
subscribe: (() => void) => () => void,
12851328
getSnapshot: () => T,
12861329
): T {
1330+
const fiber = currentlyRenderingFiber;
12871331
const hook = updateWorkInProgressHook();
12881332
// Read the current snapshot from the store on every render. This breaks the
12891333
// normal rules of React, and only works because store updates are
@@ -1300,66 +1344,109 @@ function updateSyncExternalStore<T>(
13001344
}
13011345
}
13021346
const prevSnapshot = hook.memoizedState;
1303-
if (!is(prevSnapshot, nextSnapshot)) {
1347+
const snapshotChanged = !is(prevSnapshot, nextSnapshot);
1348+
if (snapshotChanged) {
13041349
hook.memoizedState = nextSnapshot;
13051350
markWorkInProgressReceivedUpdate();
13061351
}
13071352
const inst = hook.queue;
1308-
return useSyncExternalStore(hook, inst, subscribe, getSnapshot, nextSnapshot);
1353+
1354+
updateEffect(subscribeToStore.bind(null, fiber, inst, subscribe), [
1355+
subscribe,
1356+
]);
1357+
1358+
// Whenever getSnapshot or subscribe changes, we need to check in the
1359+
// commit phase if there was an interleaved mutation. In concurrent mode
1360+
// this can happen all the time, but even in synchronous mode, an earlier
1361+
// effect may have mutated the store.
1362+
if (
1363+
inst.getSnapshot !== getSnapshot ||
1364+
snapshotChanged ||
1365+
// Check if the susbcribe function changed. We can save some memory by
1366+
// checking whether we scheduled a subscription effect above.
1367+
(workInProgressHook !== null &&
1368+
workInProgressHook.memoizedState.tag & HookHasEffect)
1369+
) {
1370+
fiber.flags |= PassiveEffect;
1371+
pushEffect(
1372+
HookHasEffect | HookPassive,
1373+
updateStoreInstance.bind(null, fiber, inst, nextSnapshot, getSnapshot),
1374+
undefined,
1375+
null,
1376+
);
1377+
1378+
// Unless we're rendering a blocking lane, schedule a consistency check.
1379+
// Right before committing, we will walk the tree and check if any of the
1380+
// stores were mutated.
1381+
const root: FiberRoot | null = getWorkInProgressRoot();
1382+
invariant(
1383+
root !== null,
1384+
'Expected a work-in-progress root. This is a bug in React. Please file an issue.',
1385+
);
1386+
if (!includesBlockingLane(root, renderLanes)) {
1387+
pushStoreConsistencyCheck(fiber, getSnapshot, nextSnapshot);
1388+
}
1389+
}
1390+
1391+
return nextSnapshot;
13091392
}
13101393

1311-
function useSyncExternalStore<T>(
1312-
hook: Hook,
1313-
inst: StoreInstance<T>,
1314-
subscribe: (() => void) => () => void,
1394+
function pushStoreConsistencyCheck<T>(
1395+
fiber: Fiber,
13151396
getSnapshot: () => T,
1397+
renderedSnapshot: T,
1398+
) {
1399+
fiber.flags |= StoreConsistency;
1400+
const check: StoreConsistencyCheck<T> = {
1401+
getSnapshot,
1402+
value: renderedSnapshot,
1403+
};
1404+
let componentUpdateQueue: null | FunctionComponentUpdateQueue = (currentlyRenderingFiber.updateQueue: any);
1405+
if (componentUpdateQueue === null) {
1406+
componentUpdateQueue = createFunctionComponentUpdateQueue();
1407+
currentlyRenderingFiber.updateQueue = (componentUpdateQueue: any);
1408+
componentUpdateQueue.stores = [check];
1409+
} else {
1410+
const stores = componentUpdateQueue.stores;
1411+
if (stores === null) {
1412+
componentUpdateQueue.stores = [check];
1413+
} else {
1414+
stores.push(check);
1415+
}
1416+
}
1417+
}
1418+
1419+
function updateStoreInstance<T>(
1420+
fiber: Fiber,
1421+
inst: StoreInstance<T>,
13161422
nextSnapshot: T,
1317-
): T {
1318-
const fiber = currentlyRenderingFiber;
1319-
const dispatcher = ReactCurrentDispatcher.current;
1423+
getSnapshot: () => T,
1424+
) {
1425+
// These are updated in the passive phase
1426+
inst.value = nextSnapshot;
1427+
inst.getSnapshot = getSnapshot;
1428+
1429+
// Something may have been mutated in between render and commit. This could
1430+
// have been in an event that fired before the passive effects, or it could
1431+
// have been in a layout effect. In that case, we would have used the old
1432+
// snapsho and getSnapshot values to bail out. We need to check one more time.
1433+
if (checkIfSnapshotChanged(inst)) {
1434+
// Force a re-render.
1435+
forceStoreRerender(fiber);
1436+
}
1437+
}
13201438

1321-
// Track the latest getSnapshot function with a ref. This needs to be updated
1322-
// in the layout phase so we can access it during the tearing check that
1323-
// happens on subscribe.
1324-
// TODO: Circumvent SSR warning
1325-
dispatcher.useLayoutEffect(() => {
1326-
inst.value = nextSnapshot;
1327-
inst.getSnapshot = getSnapshot;
1328-
1329-
// Whenever getSnapshot or subscribe changes, we need to check in the
1330-
// commit phase if there was an interleaved mutation. In concurrent mode
1331-
// this can happen all the time, but even in synchronous mode, an earlier
1332-
// effect may have mutated the store.
1333-
// TODO: Move the tearing checks to an earlier, pre-commit phase so that the
1334-
// layout effects always observe a consistent tree.
1439+
function subscribeToStore(fiber, inst, subscribe) {
1440+
const handleStoreChange = () => {
1441+
// The store changed. Check if the snapshot changed since the last time we
1442+
// read from the store.
13351443
if (checkIfSnapshotChanged(inst)) {
13361444
// Force a re-render.
13371445
forceStoreRerender(fiber);
13381446
}
1339-
}, [subscribe, nextSnapshot, getSnapshot]);
1340-
1341-
dispatcher.useEffect(() => {
1342-
const handleStoreChange = () => {
1343-
// TODO: Because there is no cross-renderer API for batching updates, it's
1344-
// up to the consumer of this library to wrap their subscription event
1345-
// with unstable_batchedUpdates. Should we try to detect when this isn't
1346-
// the case and print a warning in development?
1347-
1348-
// The store changed. Check if the snapshot changed since the last time we
1349-
// read from the store.
1350-
if (checkIfSnapshotChanged(inst)) {
1351-
// Force a re-render.
1352-
forceStoreRerender(fiber);
1353-
}
1354-
};
1355-
// Check for changes right before subscribing. Subsequent changes will be
1356-
// detected in the subscription handler.
1357-
handleStoreChange();
1358-
// Subscribe to the store and return a clean-up function.
1359-
return subscribe(handleStoreChange);
1360-
}, [subscribe]);
1361-
1362-
return nextSnapshot;
1447+
};
1448+
// Subscribe to the store and return a clean-up function.
1449+
return subscribe(handleStoreChange);
13631450
}
13641451

13651452
function checkIfSnapshotChanged(inst) {

0 commit comments

Comments
 (0)