Skip to content

Commit ead7e6f

Browse files
committed
[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.)
1 parent 0fd195f commit ead7e6f

3 files changed

Lines changed: 176 additions & 97 deletions

File tree

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/ReactFiberHooks.new.js

Lines changed: 88 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -1256,6 +1256,7 @@ function mountSyncExternalStore<T>(
12561256
subscribe: (() => void) => () => void,
12571257
getSnapshot: () => T,
12581258
): T {
1259+
const fiber = currentlyRenderingFiber;
12591260
const hook = mountWorkInProgressHook();
12601261
// Read the current snapshot from the store on every render. This breaks the
12611262
// normal rules of React, and only works because store updates are
@@ -1277,13 +1278,37 @@ function mountSyncExternalStore<T>(
12771278
getSnapshot,
12781279
};
12791280
hook.queue = inst;
1280-
return useSyncExternalStore(hook, inst, subscribe, getSnapshot, nextSnapshot);
1281+
1282+
// Schedule an effect to subscribe to the store.
1283+
mountEffect(subscribeToStore.bind(null, fiber, inst, subscribe), [subscribe]);
1284+
1285+
// Schedule an effect to update the mutable instance fields. We will update
1286+
// this whenever subscribe, getSnapshot, or value changes. Because there's no
1287+
// clean-up function, and we track the deps correctly, we can call pushEffect
1288+
// directly, without storing any additional state. For the same reason, we
1289+
// don't need to set a static flag, either.
1290+
// TODO: We can move this to the passive phase once we add a pre-commit
1291+
// consistency check. See the next comment.
1292+
fiber.flags |= UpdateEffect;
1293+
pushEffect(
1294+
HookHasEffect | HookLayout,
1295+
updateStoreInstance.bind(null, fiber, inst, nextSnapshot, getSnapshot),
1296+
undefined,
1297+
null,
1298+
);
1299+
// TODO: Unless this is a synchronous render, schedule a consistency check.
1300+
// Right before committing, we will walk the tree and check if any of the
1301+
// stores were mutated.
1302+
// pushConsistencyCheck(inst, getSnapshot, nextSnapshot);
1303+
1304+
return nextSnapshot;
12811305
}
12821306

12831307
function updateSyncExternalStore<T>(
12841308
subscribe: (() => void) => () => void,
12851309
getSnapshot: () => T,
12861310
): T {
1311+
const fiber = currentlyRenderingFiber;
12871312
const hook = updateWorkInProgressHook();
12881313
// Read the current snapshot from the store on every render. This breaks the
12891314
// normal rules of React, and only works because store updates are
@@ -1300,66 +1325,81 @@ function updateSyncExternalStore<T>(
13001325
}
13011326
}
13021327
const prevSnapshot = hook.memoizedState;
1303-
if (!is(prevSnapshot, nextSnapshot)) {
1328+
const snapshotChanged = !is(prevSnapshot, nextSnapshot);
1329+
if (snapshotChanged) {
13041330
hook.memoizedState = nextSnapshot;
13051331
markWorkInProgressReceivedUpdate();
13061332
}
13071333
const inst = hook.queue;
1308-
return useSyncExternalStore(hook, inst, subscribe, getSnapshot, nextSnapshot);
1334+
1335+
updateEffect(subscribeToStore.bind(null, fiber, inst, subscribe), [
1336+
subscribe,
1337+
]);
1338+
1339+
// Whenever getSnapshot or subscribe changes, we need to check in the
1340+
// commit phase if there was an interleaved mutation. In concurrent mode
1341+
// this can happen all the time, but even in synchronous mode, an earlier
1342+
// effect may have mutated the store.
1343+
if (
1344+
inst.getSnapshot !== getSnapshot ||
1345+
snapshotChanged ||
1346+
// Check if the susbcribe function changed. We can save some memory by
1347+
// checking whether we scheduled a subscription effect above.
1348+
(workInProgressHook !== null &&
1349+
workInProgressHook.memoizedState.tag & HookHasEffect)
1350+
) {
1351+
// TODO: We can move this to the passive phase once we add a pre-commit
1352+
// consistency check. See the next comment.
1353+
fiber.flags |= UpdateEffect;
1354+
pushEffect(
1355+
HookHasEffect | HookLayout,
1356+
updateStoreInstance.bind(null, fiber, inst, nextSnapshot, getSnapshot),
1357+
undefined,
1358+
null,
1359+
);
1360+
1361+
// TODO: Unless this is a synchronous render, schedule a consistency check.
1362+
// Right before committing, we will walk the tree and check if any of the
1363+
// stores were mutated.
1364+
// pushConsistencyCheck(inst, getSnapshot, nextSnapshot);
1365+
}
1366+
1367+
return nextSnapshot;
13091368
}
13101369

1311-
function useSyncExternalStore<T>(
1312-
hook: Hook,
1370+
function updateStoreInstance<T>(
1371+
fiber: Fiber,
13131372
inst: StoreInstance<T>,
1314-
subscribe: (() => void) => () => void,
1315-
getSnapshot: () => T,
13161373
nextSnapshot: T,
1317-
): T {
1318-
const fiber = currentlyRenderingFiber;
1319-
const dispatcher = ReactCurrentDispatcher.current;
1374+
getSnapshot: () => T,
1375+
) {
1376+
inst.value = nextSnapshot;
1377+
inst.getSnapshot = getSnapshot;
13201378

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.
1379+
// TODO: Move the tearing checks to an earlier, pre-commit phase so that the
1380+
// layout effects always observe a consistent tree.
1381+
if (checkIfSnapshotChanged(inst)) {
1382+
// Force a re-render.
1383+
forceStoreRerender(fiber);
1384+
}
1385+
}
1386+
1387+
function subscribeToStore(fiber, inst, subscribe) {
1388+
const handleStoreChange = () => {
1389+
// The store changed. Check if the snapshot changed since the last time we
1390+
// read from the store.
13351391
if (checkIfSnapshotChanged(inst)) {
13361392
// Force a re-render.
13371393
forceStoreRerender(fiber);
13381394
}
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;
1395+
};
1396+
// Check for changes right before subscribing. Subsequent changes will be
1397+
// detected in the subscription handler.
1398+
// TODO: Once updateStoreInstance is moved to the passive phase, we can rely
1399+
// on that check instead of checking again here.
1400+
handleStoreChange();
1401+
// Subscribe to the store and return a clean-up function.
1402+
return subscribe(handleStoreChange);
13631403
}
13641404

13651405
function checkIfSnapshotChanged(inst) {

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

Lines changed: 88 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -1256,6 +1256,7 @@ function mountSyncExternalStore<T>(
12561256
subscribe: (() => void) => () => void,
12571257
getSnapshot: () => T,
12581258
): T {
1259+
const fiber = currentlyRenderingFiber;
12591260
const hook = mountWorkInProgressHook();
12601261
// Read the current snapshot from the store on every render. This breaks the
12611262
// normal rules of React, and only works because store updates are
@@ -1277,13 +1278,37 @@ function mountSyncExternalStore<T>(
12771278
getSnapshot,
12781279
};
12791280
hook.queue = inst;
1280-
return useSyncExternalStore(hook, inst, subscribe, getSnapshot, nextSnapshot);
1281+
1282+
// Schedule an effect to subscribe to the store.
1283+
mountEffect(subscribeToStore.bind(null, fiber, inst, subscribe), [subscribe]);
1284+
1285+
// Schedule an effect to update the mutable instance fields. We will update
1286+
// this whenever subscribe, getSnapshot, or value changes. Because there's no
1287+
// clean-up function, and we track the deps correctly, we can call pushEffect
1288+
// directly, without storing any additional state. For the same reason, we
1289+
// don't need to set a static flag, either.
1290+
// TODO: We can move this to the passive phase once we add a pre-commit
1291+
// consistency check. See the next comment.
1292+
fiber.flags |= UpdateEffect;
1293+
pushEffect(
1294+
HookHasEffect | HookLayout,
1295+
updateStoreInstance.bind(null, fiber, inst, nextSnapshot, getSnapshot),
1296+
undefined,
1297+
null,
1298+
);
1299+
// TODO: Unless this is a synchronous render, schedule a consistency check.
1300+
// Right before committing, we will walk the tree and check if any of the
1301+
// stores were mutated.
1302+
// pushConsistencyCheck(inst, getSnapshot, nextSnapshot);
1303+
1304+
return nextSnapshot;
12811305
}
12821306

12831307
function updateSyncExternalStore<T>(
12841308
subscribe: (() => void) => () => void,
12851309
getSnapshot: () => T,
12861310
): T {
1311+
const fiber = currentlyRenderingFiber;
12871312
const hook = updateWorkInProgressHook();
12881313
// Read the current snapshot from the store on every render. This breaks the
12891314
// normal rules of React, and only works because store updates are
@@ -1300,66 +1325,81 @@ function updateSyncExternalStore<T>(
13001325
}
13011326
}
13021327
const prevSnapshot = hook.memoizedState;
1303-
if (!is(prevSnapshot, nextSnapshot)) {
1328+
const snapshotChanged = !is(prevSnapshot, nextSnapshot);
1329+
if (snapshotChanged) {
13041330
hook.memoizedState = nextSnapshot;
13051331
markWorkInProgressReceivedUpdate();
13061332
}
13071333
const inst = hook.queue;
1308-
return useSyncExternalStore(hook, inst, subscribe, getSnapshot, nextSnapshot);
1334+
1335+
updateEffect(subscribeToStore.bind(null, fiber, inst, subscribe), [
1336+
subscribe,
1337+
]);
1338+
1339+
// Whenever getSnapshot or subscribe changes, we need to check in the
1340+
// commit phase if there was an interleaved mutation. In concurrent mode
1341+
// this can happen all the time, but even in synchronous mode, an earlier
1342+
// effect may have mutated the store.
1343+
if (
1344+
inst.getSnapshot !== getSnapshot ||
1345+
snapshotChanged ||
1346+
// Check if the susbcribe function changed. We can save some memory by
1347+
// checking whether we scheduled a subscription effect above.
1348+
(workInProgressHook !== null &&
1349+
workInProgressHook.memoizedState.tag & HookHasEffect)
1350+
) {
1351+
// TODO: We can move this to the passive phase once we add a pre-commit
1352+
// consistency check. See the next comment.
1353+
fiber.flags |= UpdateEffect;
1354+
pushEffect(
1355+
HookHasEffect | HookLayout,
1356+
updateStoreInstance.bind(null, fiber, inst, nextSnapshot, getSnapshot),
1357+
undefined,
1358+
null,
1359+
);
1360+
1361+
// TODO: Unless this is a synchronous render, schedule a consistency check.
1362+
// Right before committing, we will walk the tree and check if any of the
1363+
// stores were mutated.
1364+
// pushConsistencyCheck(inst, getSnapshot, nextSnapshot);
1365+
}
1366+
1367+
return nextSnapshot;
13091368
}
13101369

1311-
function useSyncExternalStore<T>(
1312-
hook: Hook,
1370+
function updateStoreInstance<T>(
1371+
fiber: Fiber,
13131372
inst: StoreInstance<T>,
1314-
subscribe: (() => void) => () => void,
1315-
getSnapshot: () => T,
13161373
nextSnapshot: T,
1317-
): T {
1318-
const fiber = currentlyRenderingFiber;
1319-
const dispatcher = ReactCurrentDispatcher.current;
1374+
getSnapshot: () => T,
1375+
) {
1376+
inst.value = nextSnapshot;
1377+
inst.getSnapshot = getSnapshot;
13201378

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.
1379+
// TODO: Move the tearing checks to an earlier, pre-commit phase so that the
1380+
// layout effects always observe a consistent tree.
1381+
if (checkIfSnapshotChanged(inst)) {
1382+
// Force a re-render.
1383+
forceStoreRerender(fiber);
1384+
}
1385+
}
1386+
1387+
function subscribeToStore(fiber, inst, subscribe) {
1388+
const handleStoreChange = () => {
1389+
// The store changed. Check if the snapshot changed since the last time we
1390+
// read from the store.
13351391
if (checkIfSnapshotChanged(inst)) {
13361392
// Force a re-render.
13371393
forceStoreRerender(fiber);
13381394
}
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;
1395+
};
1396+
// Check for changes right before subscribing. Subsequent changes will be
1397+
// detected in the subscription handler.
1398+
// TODO: Once updateStoreInstance is moved to the passive phase, we can rely
1399+
// on that check instead of checking again here.
1400+
handleStoreChange();
1401+
// Subscribe to the store and return a clean-up function.
1402+
return subscribe(handleStoreChange);
13631403
}
13641404

13651405
function checkIfSnapshotChanged(inst) {

0 commit comments

Comments
 (0)