Skip to content

Commit bb9fcad

Browse files
committed
Implement useSyncExternalStore on server
Adds a third argument called `getServerSnapshot`. On the server, React calls this one instead of the normal `getSnapshot`. We also call it during hydration. So it represents the snapshot that is used to generate the initial, server-rendered HTML. The purpose is to avoid server-client mismatches. What we render during hydration needs to match up exactly with what we render on the server. The pattern is for the server to send down a serialized copy of the store that was used to generate the initial HTML. On the client, React will call either `getSnapshot` or `getServerSnapshot` on the client as appropriate, depending on whether it's currently hydrating. The argument is optional for fully client rendered use cases. If the user does attempt to omit `getServerSnapshot`, and the hook is called on the server, React will abort that subtree on the server and revert to client rendering, up to the nearest Suspense boundary. For the userspace shim, we will need to use a heuristic (canUseDOM) to determine whether we are in a server environment. I'll do that in a follow up.
1 parent f50ff35 commit bb9fcad

File tree

12 files changed

+341
-71
lines changed

12 files changed

+341
-71
lines changed

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

+1
Original file line numberDiff line numberDiff line change
@@ -281,6 +281,7 @@ function useMutableSource<Source, Snapshot>(
281281
function useSyncExternalStore<T>(
282282
subscribe: (() => void) => () => void,
283283
getSnapshot: () => T,
284+
getServerSnapshot?: () => T,
284285
): T {
285286
// useSyncExternalStore() composes multiple hooks internally.
286287
// Advance the current hook index the same number of times

packages/react-dom/src/__tests__/ReactDOMFizzServer-test.js

+157
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,8 @@ let ReactDOM;
1717
let ReactDOMFizzServer;
1818
let Suspense;
1919
let SuspenseList;
20+
let useSyncExternalStore;
21+
let useSyncExternalStoreExtra;
2022
let PropTypes;
2123
let textCache;
2224
let document;
@@ -39,6 +41,9 @@ describe('ReactDOMFizzServer', () => {
3941
Stream = require('stream');
4042
Suspense = React.Suspense;
4143
SuspenseList = React.SuspenseList;
44+
useSyncExternalStore = React.unstable_useSyncExternalStore;
45+
useSyncExternalStoreExtra = require('use-sync-external-store/extra')
46+
.useSyncExternalStoreExtra;
4247
PropTypes = require('prop-types');
4348

4449
textCache = new Map();
@@ -1478,4 +1483,156 @@ describe('ReactDOMFizzServer', () => {
14781483
// We should've been able to display the content without waiting for the rest of the fallback.
14791484
expect(getVisibleChildren(container)).toEqual(<div>Hello</div>);
14801485
});
1486+
1487+
// @gate supportsNativeUseSyncExternalStore
1488+
// @gate experimental
1489+
it('calls getServerSnapshot instead of getSnapshot', async () => {
1490+
const ref = React.createRef();
1491+
1492+
function getServerSnapshot() {
1493+
return 'server';
1494+
}
1495+
1496+
function getClientSnapshot() {
1497+
return 'client';
1498+
}
1499+
1500+
function subscribe() {
1501+
return () => {};
1502+
}
1503+
1504+
function Child({text}) {
1505+
Scheduler.unstable_yieldValue(text);
1506+
return text;
1507+
}
1508+
1509+
function App() {
1510+
const value = useSyncExternalStore(
1511+
subscribe,
1512+
getClientSnapshot,
1513+
getServerSnapshot,
1514+
);
1515+
return (
1516+
<div ref={ref}>
1517+
<Child text={value} />
1518+
</div>
1519+
);
1520+
}
1521+
1522+
const loggedErrors = [];
1523+
await act(async () => {
1524+
const {startWriting} = ReactDOMFizzServer.pipeToNodeWritable(
1525+
<Suspense fallback="Loading...">
1526+
<App />
1527+
</Suspense>,
1528+
writable,
1529+
{
1530+
onError(x) {
1531+
loggedErrors.push(x);
1532+
},
1533+
},
1534+
);
1535+
startWriting();
1536+
});
1537+
expect(Scheduler).toHaveYielded(['server']);
1538+
1539+
const serverRenderedDiv = container.getElementsByTagName('div')[0];
1540+
1541+
ReactDOM.hydrateRoot(container, <App />);
1542+
1543+
// The first paint uses the server snapshot
1544+
expect(Scheduler).toFlushUntilNextPaint(['server']);
1545+
expect(getVisibleChildren(container)).toEqual(<div>server</div>);
1546+
// Hydration succeeded
1547+
expect(ref.current).toEqual(serverRenderedDiv);
1548+
1549+
// Asynchronously we detect that the store has changed on the client,
1550+
// and patch up the inconsistency
1551+
expect(Scheduler).toFlushUntilNextPaint(['client']);
1552+
expect(getVisibleChildren(container)).toEqual(<div>client</div>);
1553+
expect(ref.current).toEqual(serverRenderedDiv);
1554+
});
1555+
1556+
// The selector implementation uses the lazy ref initialization pattern
1557+
// @gate !(enableUseRefAccessWarning && __DEV__)
1558+
// @gate supportsNativeUseSyncExternalStore
1559+
// @gate experimental
1560+
it('calls getServerSnapshot instead of getSnapshot (with selector and isEqual)', async () => {
1561+
// Same as previous test, but with a selector that returns a complex object
1562+
// that is memoized with a custom `isEqual` function.
1563+
const ref = React.createRef();
1564+
1565+
function getServerSnapshot() {
1566+
return {env: 'server', other: 'unrelated'};
1567+
}
1568+
1569+
function getClientSnapshot() {
1570+
return {env: 'client', other: 'unrelated'};
1571+
}
1572+
1573+
function selector({env}) {
1574+
return {env};
1575+
}
1576+
1577+
function isEqual(a, b) {
1578+
return a.env === b.env;
1579+
}
1580+
1581+
function subscribe() {
1582+
return () => {};
1583+
}
1584+
1585+
function Child({text}) {
1586+
Scheduler.unstable_yieldValue(text);
1587+
return text;
1588+
}
1589+
1590+
function App() {
1591+
const {env} = useSyncExternalStoreExtra(
1592+
subscribe,
1593+
getClientSnapshot,
1594+
getServerSnapshot,
1595+
selector,
1596+
isEqual,
1597+
);
1598+
return (
1599+
<div ref={ref}>
1600+
<Child text={env} />
1601+
</div>
1602+
);
1603+
}
1604+
1605+
const loggedErrors = [];
1606+
await act(async () => {
1607+
const {startWriting} = ReactDOMFizzServer.pipeToNodeWritable(
1608+
<Suspense fallback="Loading...">
1609+
<App />
1610+
</Suspense>,
1611+
writable,
1612+
{
1613+
onError(x) {
1614+
loggedErrors.push(x);
1615+
},
1616+
},
1617+
);
1618+
startWriting();
1619+
});
1620+
expect(Scheduler).toHaveYielded(['server']);
1621+
1622+
const serverRenderedDiv = container.getElementsByTagName('div')[0];
1623+
1624+
ReactDOM.hydrateRoot(container, <App />);
1625+
1626+
// The first paint uses the server snapshot
1627+
expect(Scheduler).toFlushUntilNextPaint(['server']);
1628+
expect(getVisibleChildren(container)).toEqual(<div>server</div>);
1629+
// Hydration succeeded
1630+
expect(ref.current).toEqual(serverRenderedDiv);
1631+
1632+
// Asynchronously we detect that the store has changed on the client,
1633+
// and patch up the inconsistency
1634+
expect(Scheduler).toFlushUntilNextPaint(['client']);
1635+
expect(getVisibleChildren(container)).toEqual(<div>client</div>);
1636+
expect(ref.current).toEqual(serverRenderedDiv);
1637+
});
14811638
});

packages/react-dom/src/server/ReactPartialRendererHooks.js

+9-1
Original file line numberDiff line numberDiff line change
@@ -481,8 +481,16 @@ function useMutableSource<Source, Snapshot>(
481481
function useSyncExternalStore<T>(
482482
subscribe: (() => void) => () => void,
483483
getSnapshot: () => T,
484+
getServerSnapshot?: () => T,
484485
): T {
485-
throw new Error('Not yet implemented');
486+
if (getServerSnapshot === undefined) {
487+
invariant(
488+
false,
489+
'Missing getServerSnapshot, which is required for ' +
490+
'server-rendered content. Will revert to client rendering.',
491+
);
492+
}
493+
return getServerSnapshot();
486494
}
487495

488496
function useDeferredValue<T>(value: T): T {

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

+67-30
Original file line numberDiff line numberDiff line change
@@ -1267,23 +1267,64 @@ function updateMutableSource<Source, Snapshot>(
12671267
function mountSyncExternalStore<T>(
12681268
subscribe: (() => void) => () => void,
12691269
getSnapshot: () => T,
1270+
getServerSnapshot?: () => T,
12701271
): T {
12711272
const fiber = currentlyRenderingFiber;
12721273
const hook = mountWorkInProgressHook();
1273-
// Read the current snapshot from the store on every render. This breaks the
1274-
// normal rules of React, and only works because store updates are
1275-
// always synchronous.
1276-
const nextSnapshot = getSnapshot();
1277-
if (__DEV__) {
1278-
if (!didWarnUncachedGetSnapshot) {
1279-
if (nextSnapshot !== getSnapshot()) {
1280-
console.error(
1281-
'The result of getSnapshot should be cached to avoid an infinite loop',
1282-
);
1283-
didWarnUncachedGetSnapshot = true;
1274+
1275+
let nextSnapshot;
1276+
const isHydrating = getIsHydrating();
1277+
if (isHydrating) {
1278+
if (getServerSnapshot === undefined) {
1279+
invariant(
1280+
false,
1281+
'Missing getServerSnapshot, which is required for ' +
1282+
'server-rendered content. Will revert to client rendering.',
1283+
);
1284+
}
1285+
nextSnapshot = getServerSnapshot();
1286+
if (__DEV__) {
1287+
if (!didWarnUncachedGetSnapshot) {
1288+
if (nextSnapshot !== getServerSnapshot()) {
1289+
console.error(
1290+
'The result of getServerSnapshot should be cached to avoid an infinite loop',
1291+
);
1292+
didWarnUncachedGetSnapshot = true;
1293+
}
12841294
}
12851295
}
1296+
} else {
1297+
nextSnapshot = getSnapshot();
1298+
if (__DEV__) {
1299+
if (!didWarnUncachedGetSnapshot) {
1300+
if (nextSnapshot !== getSnapshot()) {
1301+
console.error(
1302+
'The result of getSnapshot should be cached to avoid an infinite loop',
1303+
);
1304+
didWarnUncachedGetSnapshot = true;
1305+
}
1306+
}
1307+
}
1308+
// Unless we're rendering a blocking lane, schedule a consistency check.
1309+
// Right before committing, we will walk the tree and check if any of the
1310+
// stores were mutated.
1311+
//
1312+
// We won't do this if we're hydrating server-rendered content, because if
1313+
// the content is stale, it's already visible anyway. Instead we'll patch
1314+
// it up in a passive effect.
1315+
const root: FiberRoot | null = getWorkInProgressRoot();
1316+
invariant(
1317+
root !== null,
1318+
'Expected a work-in-progress root. This is a bug in React. Please file an issue.',
1319+
);
1320+
if (!includesBlockingLane(root, renderLanes)) {
1321+
pushStoreConsistencyCheck(fiber, getSnapshot, nextSnapshot);
1322+
}
12861323
}
1324+
1325+
// Read the current snapshot from the store on every render. This breaks the
1326+
// normal rules of React, and only works because store updates are
1327+
// always synchronous.
12871328
hook.memoizedState = nextSnapshot;
12881329
const inst: StoreInstance<T> = {
12891330
value: nextSnapshot,
@@ -1309,24 +1350,13 @@ function mountSyncExternalStore<T>(
13091350
null,
13101351
);
13111352

1312-
// Unless we're rendering a blocking lane, schedule a consistency check. Right
1313-
// before committing, we will walk the tree and check if any of the stores
1314-
// were mutated.
1315-
const root: FiberRoot | null = getWorkInProgressRoot();
1316-
invariant(
1317-
root !== null,
1318-
'Expected a work-in-progress root. This is a bug in React. Please file an issue.',
1319-
);
1320-
if (!includesBlockingLane(root, renderLanes)) {
1321-
pushStoreConsistencyCheck(fiber, getSnapshot, nextSnapshot);
1322-
}
1323-
13241353
return nextSnapshot;
13251354
}
13261355

13271356
function updateSyncExternalStore<T>(
13281357
subscribe: (() => void) => () => void,
13291358
getSnapshot: () => T,
1359+
getServerSnapshot?: () => T,
13301360
): T {
13311361
const fiber = currentlyRenderingFiber;
13321362
const hook = updateWorkInProgressHook();
@@ -2577,10 +2607,11 @@ if (__DEV__) {
25772607
useSyncExternalStore<T>(
25782608
subscribe: (() => void) => () => void,
25792609
getSnapshot: () => T,
2610+
getServerSnapshot?: () => T,
25802611
): T {
25812612
currentHookNameInDev = 'useSyncExternalStore';
25822613
mountHookTypesDev();
2583-
return mountSyncExternalStore(subscribe, getSnapshot);
2614+
return mountSyncExternalStore(subscribe, getSnapshot, getServerSnapshot);
25842615
},
25852616
useOpaqueIdentifier(): OpaqueIDType | void {
25862617
currentHookNameInDev = 'useOpaqueIdentifier';
@@ -2717,10 +2748,11 @@ if (__DEV__) {
27172748
useSyncExternalStore<T>(
27182749
subscribe: (() => void) => () => void,
27192750
getSnapshot: () => T,
2751+
getServerSnapshot?: () => T,
27202752
): T {
27212753
currentHookNameInDev = 'useSyncExternalStore';
27222754
updateHookTypesDev();
2723-
return mountSyncExternalStore(subscribe, getSnapshot);
2755+
return mountSyncExternalStore(subscribe, getSnapshot, getServerSnapshot);
27242756
},
27252757
useOpaqueIdentifier(): OpaqueIDType | void {
27262758
currentHookNameInDev = 'useOpaqueIdentifier';
@@ -2857,10 +2889,11 @@ if (__DEV__) {
28572889
useSyncExternalStore<T>(
28582890
subscribe: (() => void) => () => void,
28592891
getSnapshot: () => T,
2892+
getServerSnapshot?: () => T,
28602893
): T {
28612894
currentHookNameInDev = 'useSyncExternalStore';
28622895
updateHookTypesDev();
2863-
return updateSyncExternalStore(subscribe, getSnapshot);
2896+
return updateSyncExternalStore(subscribe, getSnapshot, getServerSnapshot);
28642897
},
28652898
useOpaqueIdentifier(): OpaqueIDType | void {
28662899
currentHookNameInDev = 'useOpaqueIdentifier';
@@ -2998,10 +3031,11 @@ if (__DEV__) {
29983031
useSyncExternalStore<T>(
29993032
subscribe: (() => void) => () => void,
30003033
getSnapshot: () => T,
3034+
getServerSnapshot?: () => T,
30013035
): T {
30023036
currentHookNameInDev = 'useSyncExternalStore';
30033037
updateHookTypesDev();
3004-
return updateSyncExternalStore(subscribe, getSnapshot);
3038+
return updateSyncExternalStore(subscribe, getSnapshot, getServerSnapshot);
30053039
},
30063040
useOpaqueIdentifier(): OpaqueIDType | void {
30073041
currentHookNameInDev = 'useOpaqueIdentifier';
@@ -3153,11 +3187,12 @@ if (__DEV__) {
31533187
useSyncExternalStore<T>(
31543188
subscribe: (() => void) => () => void,
31553189
getSnapshot: () => T,
3190+
getServerSnapshot?: () => T,
31563191
): T {
31573192
currentHookNameInDev = 'useSyncExternalStore';
31583193
warnInvalidHookAccess();
31593194
mountHookTypesDev();
3160-
return mountSyncExternalStore(subscribe, getSnapshot);
3195+
return mountSyncExternalStore(subscribe, getSnapshot, getServerSnapshot);
31613196
},
31623197
useOpaqueIdentifier(): OpaqueIDType | void {
31633198
currentHookNameInDev = 'useOpaqueIdentifier';
@@ -3310,11 +3345,12 @@ if (__DEV__) {
33103345
useSyncExternalStore<T>(
33113346
subscribe: (() => void) => () => void,
33123347
getSnapshot: () => T,
3348+
getServerSnapshot?: () => T,
33133349
): T {
33143350
currentHookNameInDev = 'useSyncExternalStore';
33153351
warnInvalidHookAccess();
33163352
updateHookTypesDev();
3317-
return updateSyncExternalStore(subscribe, getSnapshot);
3353+
return updateSyncExternalStore(subscribe, getSnapshot, getServerSnapshot);
33183354
},
33193355
useOpaqueIdentifier(): OpaqueIDType | void {
33203356
currentHookNameInDev = 'useOpaqueIdentifier';
@@ -3468,11 +3504,12 @@ if (__DEV__) {
34683504
useSyncExternalStore<T>(
34693505
subscribe: (() => void) => () => void,
34703506
getSnapshot: () => T,
3507+
getServerSnapshot?: () => T,
34713508
): T {
34723509
currentHookNameInDev = 'useSyncExternalStore';
34733510
warnInvalidHookAccess();
34743511
updateHookTypesDev();
3475-
return updateSyncExternalStore(subscribe, getSnapshot);
3512+
return updateSyncExternalStore(subscribe, getSnapshot, getServerSnapshot);
34763513
},
34773514
useOpaqueIdentifier(): OpaqueIDType | void {
34783515
currentHookNameInDev = 'useOpaqueIdentifier';

0 commit comments

Comments
 (0)