Skip to content

Commit 4a51283

Browse files
committed
Implement useSyncExternalStore in Fiber
This adds an initial implementation of useSyncExternalStore to the fiber reconciler. It's mostly a copy-paste of the userspace implementation, which is not ideal but is a good enough starting place. The main change we'll want to make to this native implementation is to move the tearing checks from the layout phase to an earlier, pre-commit phase so that code that runs in the commit phase always observes a consistent tree. Follow-ups: - Implement in Fizz - Implement in old SSR renderer - Implement in react-debug-hooks
1 parent e934b11 commit 4a51283

File tree

3 files changed

+261
-63
lines changed

3 files changed

+261
-63
lines changed

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

+115-2
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,7 @@ import {
5151
markRootMutableRead,
5252
} from './ReactFiberLane.new';
5353
import {
54+
DiscreteEventPriority,
5455
ContinuousEventPriority,
5556
getCurrentUpdatePriority,
5657
setCurrentUpdatePriority,
@@ -1246,14 +1247,126 @@ function mountSyncExternalStore<T>(
12461247
subscribe: (() => void) => () => void,
12471248
getSnapshot: () => T,
12481249
): T {
1249-
throw new Error('Not yet implemented');
1250+
const hook = mountWorkInProgressHook();
1251+
return useSyncExternalStore(hook, subscribe, getSnapshot);
12501252
}
12511253

12521254
function updateSyncExternalStore<T>(
12531255
subscribe: (() => void) => () => void,
12541256
getSnapshot: () => T,
12551257
): T {
1256-
throw new Error('Not yet implemented');
1258+
const hook = updateWorkInProgressHook();
1259+
return useSyncExternalStore(hook, subscribe, getSnapshot);
1260+
}
1261+
1262+
function useSyncExternalStore<T>(
1263+
hook: Hook,
1264+
subscribe: (() => void) => () => void,
1265+
getSnapshot: () => T,
1266+
): T {
1267+
// TODO: This is a copy-paste of the userspace shim. We can improve the
1268+
// built-in implementation using lower-level APIs. We also intend to move
1269+
// the tearing checks to an earlier, pre-commit phase so that the layout
1270+
// effects always observe a consistent tree.
1271+
1272+
const dispatcher = ReactCurrentDispatcher.current;
1273+
1274+
// Read the current snapshot from the store on every render. Again, this
1275+
// breaks the rules of React, and only works here because of specific
1276+
// implementation details, most importantly that updates are
1277+
// always synchronous.
1278+
const value = getSnapshot();
1279+
1280+
// Because updates are synchronous, we don't queue them. Instead we force a
1281+
// re-render whenever the subscribed state changes by updating an some
1282+
// arbitrary useState hook. Then, during render, we call getSnapshot to read
1283+
// the current value.
1284+
//
1285+
// Because we don't actually use the state returned by the useState hook, we
1286+
// can save a bit of memory by storing other stuff in that slot.
1287+
//
1288+
// To implement the early bailout, we need to track some things on a mutable
1289+
// object. Usually, we would put that in a useRef hook, but we can stash it in
1290+
// our useState hook instead.
1291+
//
1292+
// To force a re-render, we call forceUpdate({inst}). That works because the
1293+
// new object always fails an equality check.
1294+
const [{inst}, forceUpdate] = dispatcher.useState({
1295+
inst: {value, getSnapshot},
1296+
});
1297+
1298+
// Track the latest getSnapshot function with a ref. This needs to be updated
1299+
// in the layout phase so we can access it during the tearing check that
1300+
// happens on subscribe.
1301+
// TODO: Circumvent SSR warning
1302+
dispatcher.useLayoutEffect(() => {
1303+
inst.value = value;
1304+
inst.getSnapshot = getSnapshot;
1305+
1306+
// Whenever getSnapshot or subscribe changes, we need to check in the
1307+
// commit phase if there was an interleaved mutation. In concurrent mode
1308+
// this can happen all the time, but even in synchronous mode, an earlier
1309+
// effect may have mutated the store.
1310+
if (checkIfSnapshotChanged(inst)) {
1311+
// Force a re-render.
1312+
const prevTransition = ReactCurrentBatchConfig.transition;
1313+
const prevPriority = getCurrentUpdatePriority();
1314+
ReactCurrentBatchConfig.transition = 0;
1315+
setCurrentUpdatePriority(DiscreteEventPriority);
1316+
forceUpdate({inst});
1317+
setCurrentUpdatePriority(prevPriority);
1318+
ReactCurrentBatchConfig.transition = prevTransition;
1319+
}
1320+
}, [subscribe, value, getSnapshot]);
1321+
1322+
dispatcher.useEffect(() => {
1323+
// Check for changes right before subscribing. Subsequent changes will be
1324+
// detected in the subscription handler.
1325+
if (checkIfSnapshotChanged(inst)) {
1326+
// Force a re-render.
1327+
const prevTransition = ReactCurrentBatchConfig.transition;
1328+
const prevPriority = getCurrentUpdatePriority();
1329+
ReactCurrentBatchConfig.transition = 0;
1330+
setCurrentUpdatePriority(DiscreteEventPriority);
1331+
forceUpdate({inst});
1332+
setCurrentUpdatePriority(prevPriority);
1333+
ReactCurrentBatchConfig.transition = prevTransition;
1334+
}
1335+
const handleStoreChange = () => {
1336+
// TODO: Because there is no cross-renderer API for batching updates, it's
1337+
// up to the consumer of this library to wrap their subscription event
1338+
// with unstable_batchedUpdates. Should we try to detect when this isn't
1339+
// the case and print a warning in development?
1340+
1341+
// The store changed. Check if the snapshot changed since the last time we
1342+
// read from the store.
1343+
if (checkIfSnapshotChanged(inst)) {
1344+
// Force a re-render.
1345+
const prevTransition = ReactCurrentBatchConfig.transition;
1346+
const prevPriority = getCurrentUpdatePriority();
1347+
ReactCurrentBatchConfig.transition = 0;
1348+
setCurrentUpdatePriority(DiscreteEventPriority);
1349+
forceUpdate({inst});
1350+
setCurrentUpdatePriority(prevPriority);
1351+
ReactCurrentBatchConfig.transition = prevTransition;
1352+
}
1353+
};
1354+
// Subscribe to the store and return a clean-up function.
1355+
return subscribe(handleStoreChange);
1356+
}, [subscribe]);
1357+
1358+
return value;
1359+
}
1360+
1361+
function checkIfSnapshotChanged(inst) {
1362+
const latestGetSnapshot = inst.getSnapshot;
1363+
const prevValue = inst.value;
1364+
try {
1365+
const nextValue = latestGetSnapshot();
1366+
return !is(prevValue, nextValue);
1367+
} catch (error) {
1368+
return true;
1369+
}
12571370
}
12581371

12591372
function mountState<S>(

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

+115-2
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,7 @@ import {
5151
markRootMutableRead,
5252
} from './ReactFiberLane.old';
5353
import {
54+
DiscreteEventPriority,
5455
ContinuousEventPriority,
5556
getCurrentUpdatePriority,
5657
setCurrentUpdatePriority,
@@ -1246,14 +1247,126 @@ function mountSyncExternalStore<T>(
12461247
subscribe: (() => void) => () => void,
12471248
getSnapshot: () => T,
12481249
): T {
1249-
throw new Error('Not yet implemented');
1250+
const hook = mountWorkInProgressHook();
1251+
return useSyncExternalStore(hook, subscribe, getSnapshot);
12501252
}
12511253

12521254
function updateSyncExternalStore<T>(
12531255
subscribe: (() => void) => () => void,
12541256
getSnapshot: () => T,
12551257
): T {
1256-
throw new Error('Not yet implemented');
1258+
const hook = updateWorkInProgressHook();
1259+
return useSyncExternalStore(hook, subscribe, getSnapshot);
1260+
}
1261+
1262+
function useSyncExternalStore<T>(
1263+
hook: Hook,
1264+
subscribe: (() => void) => () => void,
1265+
getSnapshot: () => T,
1266+
): T {
1267+
// TODO: This is a copy-paste of the userspace shim. We can improve the
1268+
// built-in implementation using lower-level APIs. We also intend to move
1269+
// the tearing checks to an earlier, pre-commit phase so that the layout
1270+
// effects always observe a consistent tree.
1271+
1272+
const dispatcher = ReactCurrentDispatcher.current;
1273+
1274+
// Read the current snapshot from the store on every render. Again, this
1275+
// breaks the rules of React, and only works here because of specific
1276+
// implementation details, most importantly that updates are
1277+
// always synchronous.
1278+
const value = getSnapshot();
1279+
1280+
// Because updates are synchronous, we don't queue them. Instead we force a
1281+
// re-render whenever the subscribed state changes by updating an some
1282+
// arbitrary useState hook. Then, during render, we call getSnapshot to read
1283+
// the current value.
1284+
//
1285+
// Because we don't actually use the state returned by the useState hook, we
1286+
// can save a bit of memory by storing other stuff in that slot.
1287+
//
1288+
// To implement the early bailout, we need to track some things on a mutable
1289+
// object. Usually, we would put that in a useRef hook, but we can stash it in
1290+
// our useState hook instead.
1291+
//
1292+
// To force a re-render, we call forceUpdate({inst}). That works because the
1293+
// new object always fails an equality check.
1294+
const [{inst}, forceUpdate] = dispatcher.useState({
1295+
inst: {value, getSnapshot},
1296+
});
1297+
1298+
// Track the latest getSnapshot function with a ref. This needs to be updated
1299+
// in the layout phase so we can access it during the tearing check that
1300+
// happens on subscribe.
1301+
// TODO: Circumvent SSR warning
1302+
dispatcher.useLayoutEffect(() => {
1303+
inst.value = value;
1304+
inst.getSnapshot = getSnapshot;
1305+
1306+
// Whenever getSnapshot or subscribe changes, we need to check in the
1307+
// commit phase if there was an interleaved mutation. In concurrent mode
1308+
// this can happen all the time, but even in synchronous mode, an earlier
1309+
// effect may have mutated the store.
1310+
if (checkIfSnapshotChanged(inst)) {
1311+
// Force a re-render.
1312+
const prevTransition = ReactCurrentBatchConfig.transition;
1313+
const prevPriority = getCurrentUpdatePriority();
1314+
ReactCurrentBatchConfig.transition = 0;
1315+
setCurrentUpdatePriority(DiscreteEventPriority);
1316+
forceUpdate({inst});
1317+
setCurrentUpdatePriority(prevPriority);
1318+
ReactCurrentBatchConfig.transition = prevTransition;
1319+
}
1320+
}, [subscribe, value, getSnapshot]);
1321+
1322+
dispatcher.useEffect(() => {
1323+
// Check for changes right before subscribing. Subsequent changes will be
1324+
// detected in the subscription handler.
1325+
if (checkIfSnapshotChanged(inst)) {
1326+
// Force a re-render.
1327+
const prevTransition = ReactCurrentBatchConfig.transition;
1328+
const prevPriority = getCurrentUpdatePriority();
1329+
ReactCurrentBatchConfig.transition = 0;
1330+
setCurrentUpdatePriority(DiscreteEventPriority);
1331+
forceUpdate({inst});
1332+
setCurrentUpdatePriority(prevPriority);
1333+
ReactCurrentBatchConfig.transition = prevTransition;
1334+
}
1335+
const handleStoreChange = () => {
1336+
// TODO: Because there is no cross-renderer API for batching updates, it's
1337+
// up to the consumer of this library to wrap their subscription event
1338+
// with unstable_batchedUpdates. Should we try to detect when this isn't
1339+
// the case and print a warning in development?
1340+
1341+
// The store changed. Check if the snapshot changed since the last time we
1342+
// read from the store.
1343+
if (checkIfSnapshotChanged(inst)) {
1344+
// Force a re-render.
1345+
const prevTransition = ReactCurrentBatchConfig.transition;
1346+
const prevPriority = getCurrentUpdatePriority();
1347+
ReactCurrentBatchConfig.transition = 0;
1348+
setCurrentUpdatePriority(DiscreteEventPriority);
1349+
forceUpdate({inst});
1350+
setCurrentUpdatePriority(prevPriority);
1351+
ReactCurrentBatchConfig.transition = prevTransition;
1352+
}
1353+
};
1354+
// Subscribe to the store and return a clean-up function.
1355+
return subscribe(handleStoreChange);
1356+
}, [subscribe]);
1357+
1358+
return value;
1359+
}
1360+
1361+
function checkIfSnapshotChanged(inst) {
1362+
const latestGetSnapshot = inst.getSnapshot;
1363+
const prevValue = inst.value;
1364+
try {
1365+
const nextValue = latestGetSnapshot();
1366+
return !is(prevValue, nextValue);
1367+
} catch (error) {
1368+
return true;
1369+
}
12571370
}
12581371

12591372
function mountState<S>(

0 commit comments

Comments
 (0)