Skip to content

Commit 86b3e24

Browse files
authored
Implement useSyncExternalStore on server (#22347)
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 57e4d68 commit 86b3e24

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
@@ -258,6 +258,7 @@ function useMemo<T>(
258258
function useSyncExternalStore<T>(
259259
subscribe: (() => void) => () => void,
260260
getSnapshot: () => T,
261+
getServerSnapshot?: () => T,
261262
): T {
262263
// useSyncExternalStore() composes multiple hooks internally.
263264
// 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
@@ -464,8 +464,16 @@ export function useCallback<T>(
464464
function useSyncExternalStore<T>(
465465
subscribe: (() => void) => () => void,
466466
getSnapshot: () => T,
467+
getServerSnapshot?: () => T,
467468
): T {
468-
throw new Error('Not yet implemented');
469+
if (getServerSnapshot === undefined) {
470+
invariant(
471+
false,
472+
'Missing getServerSnapshot, which is required for ' +
473+
'server-rendered content. Will revert to client rendering.',
474+
);
475+
}
476+
return getServerSnapshot();
469477
}
470478

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

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

+67-30
Original file line numberDiff line numberDiff line change
@@ -938,23 +938,64 @@ function rerenderReducer<S, I, A>(
938938
function mountSyncExternalStore<T>(
939939
subscribe: (() => void) => () => void,
940940
getSnapshot: () => T,
941+
getServerSnapshot?: () => T,
941942
): T {
942943
const fiber = currentlyRenderingFiber;
943944
const hook = mountWorkInProgressHook();
944-
// Read the current snapshot from the store on every render. This breaks the
945-
// normal rules of React, and only works because store updates are
946-
// always synchronous.
947-
const nextSnapshot = getSnapshot();
948-
if (__DEV__) {
949-
if (!didWarnUncachedGetSnapshot) {
950-
if (nextSnapshot !== getSnapshot()) {
951-
console.error(
952-
'The result of getSnapshot should be cached to avoid an infinite loop',
953-
);
954-
didWarnUncachedGetSnapshot = true;
945+
946+
let nextSnapshot;
947+
const isHydrating = getIsHydrating();
948+
if (isHydrating) {
949+
if (getServerSnapshot === undefined) {
950+
invariant(
951+
false,
952+
'Missing getServerSnapshot, which is required for ' +
953+
'server-rendered content. Will revert to client rendering.',
954+
);
955+
}
956+
nextSnapshot = getServerSnapshot();
957+
if (__DEV__) {
958+
if (!didWarnUncachedGetSnapshot) {
959+
if (nextSnapshot !== getServerSnapshot()) {
960+
console.error(
961+
'The result of getServerSnapshot should be cached to avoid an infinite loop',
962+
);
963+
didWarnUncachedGetSnapshot = true;
964+
}
955965
}
956966
}
967+
} else {
968+
nextSnapshot = getSnapshot();
969+
if (__DEV__) {
970+
if (!didWarnUncachedGetSnapshot) {
971+
if (nextSnapshot !== getSnapshot()) {
972+
console.error(
973+
'The result of getSnapshot should be cached to avoid an infinite loop',
974+
);
975+
didWarnUncachedGetSnapshot = true;
976+
}
977+
}
978+
}
979+
// Unless we're rendering a blocking lane, schedule a consistency check.
980+
// Right before committing, we will walk the tree and check if any of the
981+
// stores were mutated.
982+
//
983+
// We won't do this if we're hydrating server-rendered content, because if
984+
// the content is stale, it's already visible anyway. Instead we'll patch
985+
// it up in a passive effect.
986+
const root: FiberRoot | null = getWorkInProgressRoot();
987+
invariant(
988+
root !== null,
989+
'Expected a work-in-progress root. This is a bug in React. Please file an issue.',
990+
);
991+
if (!includesBlockingLane(root, renderLanes)) {
992+
pushStoreConsistencyCheck(fiber, getSnapshot, nextSnapshot);
993+
}
957994
}
995+
996+
// Read the current snapshot from the store on every render. This breaks the
997+
// normal rules of React, and only works because store updates are
998+
// always synchronous.
958999
hook.memoizedState = nextSnapshot;
9591000
const inst: StoreInstance<T> = {
9601001
value: nextSnapshot,
@@ -980,24 +1021,13 @@ function mountSyncExternalStore<T>(
9801021
null,
9811022
);
9821023

983-
// Unless we're rendering a blocking lane, schedule a consistency check. Right
984-
// before committing, we will walk the tree and check if any of the stores
985-
// were mutated.
986-
const root: FiberRoot | null = getWorkInProgressRoot();
987-
invariant(
988-
root !== null,
989-
'Expected a work-in-progress root. This is a bug in React. Please file an issue.',
990-
);
991-
if (!includesBlockingLane(root, renderLanes)) {
992-
pushStoreConsistencyCheck(fiber, getSnapshot, nextSnapshot);
993-
}
994-
9951024
return nextSnapshot;
9961025
}
9971026

9981027
function updateSyncExternalStore<T>(
9991028
subscribe: (() => void) => () => void,
10001029
getSnapshot: () => T,
1030+
getServerSnapshot?: () => T,
10011031
): T {
10021032
const fiber = currentlyRenderingFiber;
10031033
const hook = updateWorkInProgressHook();
@@ -2235,10 +2265,11 @@ if (__DEV__) {
22352265
useSyncExternalStore<T>(
22362266
subscribe: (() => void) => () => void,
22372267
getSnapshot: () => T,
2268+
getServerSnapshot?: () => T,
22382269
): T {
22392270
currentHookNameInDev = 'useSyncExternalStore';
22402271
mountHookTypesDev();
2241-
return mountSyncExternalStore(subscribe, getSnapshot);
2272+
return mountSyncExternalStore(subscribe, getSnapshot, getServerSnapshot);
22422273
},
22432274
useOpaqueIdentifier(): OpaqueIDType | void {
22442275
currentHookNameInDev = 'useOpaqueIdentifier';
@@ -2366,10 +2397,11 @@ if (__DEV__) {
23662397
useSyncExternalStore<T>(
23672398
subscribe: (() => void) => () => void,
23682399
getSnapshot: () => T,
2400+
getServerSnapshot?: () => T,
23692401
): T {
23702402
currentHookNameInDev = 'useSyncExternalStore';
23712403
updateHookTypesDev();
2372-
return mountSyncExternalStore(subscribe, getSnapshot);
2404+
return mountSyncExternalStore(subscribe, getSnapshot, getServerSnapshot);
23732405
},
23742406
useOpaqueIdentifier(): OpaqueIDType | void {
23752407
currentHookNameInDev = 'useOpaqueIdentifier';
@@ -2497,10 +2529,11 @@ if (__DEV__) {
24972529
useSyncExternalStore<T>(
24982530
subscribe: (() => void) => () => void,
24992531
getSnapshot: () => T,
2532+
getServerSnapshot?: () => T,
25002533
): T {
25012534
currentHookNameInDev = 'useSyncExternalStore';
25022535
updateHookTypesDev();
2503-
return updateSyncExternalStore(subscribe, getSnapshot);
2536+
return updateSyncExternalStore(subscribe, getSnapshot, getServerSnapshot);
25042537
},
25052538
useOpaqueIdentifier(): OpaqueIDType | void {
25062539
currentHookNameInDev = 'useOpaqueIdentifier';
@@ -2629,10 +2662,11 @@ if (__DEV__) {
26292662
useSyncExternalStore<T>(
26302663
subscribe: (() => void) => () => void,
26312664
getSnapshot: () => T,
2665+
getServerSnapshot?: () => T,
26322666
): T {
26332667
currentHookNameInDev = 'useSyncExternalStore';
26342668
updateHookTypesDev();
2635-
return updateSyncExternalStore(subscribe, getSnapshot);
2669+
return updateSyncExternalStore(subscribe, getSnapshot, getServerSnapshot);
26362670
},
26372671
useOpaqueIdentifier(): OpaqueIDType | void {
26382672
currentHookNameInDev = 'useOpaqueIdentifier';
@@ -2774,11 +2808,12 @@ if (__DEV__) {
27742808
useSyncExternalStore<T>(
27752809
subscribe: (() => void) => () => void,
27762810
getSnapshot: () => T,
2811+
getServerSnapshot?: () => T,
27772812
): T {
27782813
currentHookNameInDev = 'useSyncExternalStore';
27792814
warnInvalidHookAccess();
27802815
mountHookTypesDev();
2781-
return mountSyncExternalStore(subscribe, getSnapshot);
2816+
return mountSyncExternalStore(subscribe, getSnapshot, getServerSnapshot);
27822817
},
27832818
useOpaqueIdentifier(): OpaqueIDType | void {
27842819
currentHookNameInDev = 'useOpaqueIdentifier';
@@ -2921,11 +2956,12 @@ if (__DEV__) {
29212956
useSyncExternalStore<T>(
29222957
subscribe: (() => void) => () => void,
29232958
getSnapshot: () => T,
2959+
getServerSnapshot?: () => T,
29242960
): T {
29252961
currentHookNameInDev = 'useSyncExternalStore';
29262962
warnInvalidHookAccess();
29272963
updateHookTypesDev();
2928-
return updateSyncExternalStore(subscribe, getSnapshot);
2964+
return updateSyncExternalStore(subscribe, getSnapshot, getServerSnapshot);
29292965
},
29302966
useOpaqueIdentifier(): OpaqueIDType | void {
29312967
currentHookNameInDev = 'useOpaqueIdentifier';
@@ -3069,11 +3105,12 @@ if (__DEV__) {
30693105
useSyncExternalStore<T>(
30703106
subscribe: (() => void) => () => void,
30713107
getSnapshot: () => T,
3108+
getServerSnapshot?: () => T,
30723109
): T {
30733110
currentHookNameInDev = 'useSyncExternalStore';
30743111
warnInvalidHookAccess();
30753112
updateHookTypesDev();
3076-
return updateSyncExternalStore(subscribe, getSnapshot);
3113+
return updateSyncExternalStore(subscribe, getSnapshot, getServerSnapshot);
30773114
},
30783115
useOpaqueIdentifier(): OpaqueIDType | void {
30793116
currentHookNameInDev = 'useOpaqueIdentifier';

0 commit comments

Comments
 (0)