Skip to content

Commit 06f98c1

Browse files
authored
Implement useSyncExternalStore in Fiber (#22239)
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 77912d9 commit 06f98c1

File tree

3 files changed

+305
-76
lines changed

3 files changed

+305
-76
lines changed

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

+117-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,
@@ -136,6 +137,7 @@ export type UpdateQueue<S, A> = {|
136137

137138
let didWarnAboutMismatchedHooksForComponent;
138139
let didWarnAboutUseOpaqueIdentifier;
140+
let didWarnUncachedGetSnapshot;
139141
if (__DEV__) {
140142
didWarnAboutUseOpaqueIdentifier = {};
141143
didWarnAboutMismatchedHooksForComponent = new Set();
@@ -1246,14 +1248,127 @@ function mountSyncExternalStore<T>(
12461248
subscribe: (() => void) => () => void,
12471249
getSnapshot: () => T,
12481250
): T {
1249-
throw new Error('Not yet implemented');
1251+
const hook = mountWorkInProgressHook();
1252+
return useSyncExternalStore(hook, subscribe, getSnapshot);
12501253
}
12511254

12521255
function updateSyncExternalStore<T>(
12531256
subscribe: (() => void) => () => void,
12541257
getSnapshot: () => T,
12551258
): T {
1256-
throw new Error('Not yet implemented');
1259+
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
1278+
// always synchronous.
1279+
const value = getSnapshot();
1280+
if (__DEV__) {
1281+
if (!didWarnUncachedGetSnapshot) {
1282+
if (value !== getSnapshot()) {
1283+
console.error(
1284+
'The result of getSnapshot should be cached to avoid an infinite loop',
1285+
);
1286+
didWarnUncachedGetSnapshot = true;
1287+
}
1288+
}
1289+
}
1290+
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+
});
1308+
1309+
// Track the latest getSnapshot function with a ref. This needs to be updated
1310+
// in the layout phase so we can access it during the tearing check that
1311+
// happens on subscribe.
1312+
// TODO: Circumvent SSR warning
1313+
dispatcher.useLayoutEffect(() => {
1314+
inst.value = value;
1315+
inst.getSnapshot = getSnapshot;
1316+
1317+
// Whenever getSnapshot or subscribe changes, we need to check in the
1318+
// commit phase if there was an interleaved mutation. In concurrent mode
1319+
// this can happen all the time, but even in synchronous mode, an earlier
1320+
// effect may have mutated the store.
1321+
if (checkIfSnapshotChanged(inst)) {
1322+
// 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;
1330+
}
1331+
}, [subscribe, value, getSnapshot]);
1332+
1333+
dispatcher.useEffect(() => {
1334+
const handleStoreChange = () => {
1335+
// TODO: Because there is no cross-renderer API for batching updates, it's
1336+
// up to the consumer of this library to wrap their subscription event
1337+
// with unstable_batchedUpdates. Should we try to detect when this isn't
1338+
// the case and print a warning in development?
1339+
1340+
// The store changed. Check if the snapshot changed since the last time we
1341+
// read from the store.
1342+
if (checkIfSnapshotChanged(inst)) {
1343+
// 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+
}
1352+
};
1353+
// Check for changes right before subscribing. Subsequent changes will be
1354+
// detected in the subscription handler.
1355+
handleStoreChange();
1356+
// Subscribe to the store and return a clean-up function.
1357+
return subscribe(handleStoreChange);
1358+
}, [subscribe]);
1359+
1360+
return value;
1361+
}
1362+
1363+
function checkIfSnapshotChanged(inst) {
1364+
const latestGetSnapshot = inst.getSnapshot;
1365+
const prevValue = inst.value;
1366+
try {
1367+
const nextValue = latestGetSnapshot();
1368+
return !is(prevValue, nextValue);
1369+
} catch (error) {
1370+
return true;
1371+
}
12571372
}
12581373

12591374
function mountState<S>(

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

+117-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,
@@ -136,6 +137,7 @@ export type UpdateQueue<S, A> = {|
136137

137138
let didWarnAboutMismatchedHooksForComponent;
138139
let didWarnAboutUseOpaqueIdentifier;
140+
let didWarnUncachedGetSnapshot;
139141
if (__DEV__) {
140142
didWarnAboutUseOpaqueIdentifier = {};
141143
didWarnAboutMismatchedHooksForComponent = new Set();
@@ -1246,14 +1248,127 @@ function mountSyncExternalStore<T>(
12461248
subscribe: (() => void) => () => void,
12471249
getSnapshot: () => T,
12481250
): T {
1249-
throw new Error('Not yet implemented');
1251+
const hook = mountWorkInProgressHook();
1252+
return useSyncExternalStore(hook, subscribe, getSnapshot);
12501253
}
12511254

12521255
function updateSyncExternalStore<T>(
12531256
subscribe: (() => void) => () => void,
12541257
getSnapshot: () => T,
12551258
): T {
1256-
throw new Error('Not yet implemented');
1259+
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
1278+
// always synchronous.
1279+
const value = getSnapshot();
1280+
if (__DEV__) {
1281+
if (!didWarnUncachedGetSnapshot) {
1282+
if (value !== getSnapshot()) {
1283+
console.error(
1284+
'The result of getSnapshot should be cached to avoid an infinite loop',
1285+
);
1286+
didWarnUncachedGetSnapshot = true;
1287+
}
1288+
}
1289+
}
1290+
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+
});
1308+
1309+
// Track the latest getSnapshot function with a ref. This needs to be updated
1310+
// in the layout phase so we can access it during the tearing check that
1311+
// happens on subscribe.
1312+
// TODO: Circumvent SSR warning
1313+
dispatcher.useLayoutEffect(() => {
1314+
inst.value = value;
1315+
inst.getSnapshot = getSnapshot;
1316+
1317+
// Whenever getSnapshot or subscribe changes, we need to check in the
1318+
// commit phase if there was an interleaved mutation. In concurrent mode
1319+
// this can happen all the time, but even in synchronous mode, an earlier
1320+
// effect may have mutated the store.
1321+
if (checkIfSnapshotChanged(inst)) {
1322+
// 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;
1330+
}
1331+
}, [subscribe, value, getSnapshot]);
1332+
1333+
dispatcher.useEffect(() => {
1334+
const handleStoreChange = () => {
1335+
// TODO: Because there is no cross-renderer API for batching updates, it's
1336+
// up to the consumer of this library to wrap their subscription event
1337+
// with unstable_batchedUpdates. Should we try to detect when this isn't
1338+
// the case and print a warning in development?
1339+
1340+
// The store changed. Check if the snapshot changed since the last time we
1341+
// read from the store.
1342+
if (checkIfSnapshotChanged(inst)) {
1343+
// 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+
}
1352+
};
1353+
// Check for changes right before subscribing. Subsequent changes will be
1354+
// detected in the subscription handler.
1355+
handleStoreChange();
1356+
// Subscribe to the store and return a clean-up function.
1357+
return subscribe(handleStoreChange);
1358+
}, [subscribe]);
1359+
1360+
return value;
1361+
}
1362+
1363+
function checkIfSnapshotChanged(inst) {
1364+
const latestGetSnapshot = inst.getSnapshot;
1365+
const prevValue = inst.value;
1366+
try {
1367+
const nextValue = latestGetSnapshot();
1368+
return !is(prevValue, nextValue);
1369+
} catch (error) {
1370+
return true;
1371+
}
12571372
}
12581373

12591374
function mountState<S>(

0 commit comments

Comments
 (0)