Skip to content

Commit 6f29295

Browse files
committed
Implement getServerSnapshot in userspace shim
If the DOM is not present, we assume that we are running in a server environment and return the result of `getServerSnapshot`. This heuristic doesn't work in React Native, so we'll need to provide a separate native build (using the `.native` extension). I've left this for a follow-up. We can't call `getServerSnapshot` on the client, because in versions of React before 18, there's no built-in mechanism to detect whether we're hydrating. To avoid a server mismatch warning, users must account for this themselves and return the correct value inside `getSnapshot`. Note that none of this is relevant to the built-in API that is being added in 18. This only affects the userspace shim that is provided for backwards compatibility with versions 16 and 17.
1 parent e64270d commit 6f29295

File tree

4 files changed

+182
-6
lines changed

4 files changed

+182
-6
lines changed

packages/use-sync-external-store/src/__tests__/useSyncExternalStoreShared-test.js

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -688,6 +688,58 @@ describe('Shared useSyncExternalStore behavior (shim and built-in)', () => {
688688
expect(Scheduler).toHaveYielded(['A1']);
689689
expect(container.textContent).toEqual('A1B1');
690690
});
691+
692+
test('basic server hydration', async () => {
693+
const store = createExternalStore('client');
694+
695+
const ref = React.createRef();
696+
function App() {
697+
const text = useSyncExternalStore(
698+
store.subscribe,
699+
store.getState,
700+
() => 'server',
701+
);
702+
useEffect(() => {
703+
Scheduler.unstable_yieldValue('Passive effect: ' + text);
704+
}, [text]);
705+
return (
706+
<div ref={ref}>
707+
<Text text={text} />
708+
</div>
709+
);
710+
}
711+
712+
const container = document.createElement('div');
713+
container.innerHTML = '<div>server</div>';
714+
const serverRenderedDiv = container.getElementsByTagName('div')[0];
715+
716+
if (gate(flags => flags.supportsNativeUseSyncExternalStore)) {
717+
act(() => {
718+
ReactDOM.hydrateRoot(container, <App />);
719+
});
720+
expect(Scheduler).toHaveYielded([
721+
// First it hydrates the server rendered HTML
722+
'server',
723+
'Passive effect: server',
724+
// Then in a second paint, it re-renders with the client state
725+
'client',
726+
'Passive effect: client',
727+
]);
728+
} else {
729+
// In the userspace shim, there's no mechanism to detect whether we're
730+
// currently hydrating, so `getServerSnapshot` is not called on the
731+
// client. To avoid this server mismatch warning, user must account for
732+
// this themselves and return the correct value inside `getSnapshot`.
733+
act(() => {
734+
expect(() => ReactDOM.hydrate(<App />, container)).toErrorDev(
735+
'Text content did not match',
736+
);
737+
});
738+
expect(Scheduler).toHaveYielded(['client', 'Passive effect: client']);
739+
}
740+
expect(container.textContent).toEqual('client');
741+
expect(ref.current).toEqual(serverRenderedDiv);
742+
});
691743
});
692744

693745
// The selector implementation uses the lazy ref initialization pattern
Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
/**
2+
* Copyright (c) Facebook, Inc. and its affiliates.
3+
*
4+
* This source code is licensed under the MIT license found in the
5+
* LICENSE file in the root directory of this source tree.
6+
*
7+
* @emails react-core
8+
*
9+
* @jest-environment node
10+
*/
11+
12+
'use strict';
13+
14+
let useSyncExternalStore;
15+
let React;
16+
let ReactDOM;
17+
let ReactDOMServer;
18+
let Scheduler;
19+
20+
// This tests the userspace shim of `useSyncExternalStore` in a server-rendering
21+
// (Node) environment
22+
describe('useSyncExternalStore (userspace shim, server rendering)', () => {
23+
beforeEach(() => {
24+
jest.resetModules();
25+
26+
// Remove useSyncExternalStore from the React imports so that we use the
27+
// shim instead. Also removing startTransition, since we use that to detect
28+
// outdated 18 alphas that don't yet include useSyncExternalStore.
29+
//
30+
// Longer term, we'll probably test this branch using an actual build of
31+
// React 17.
32+
jest.mock('react', () => {
33+
const {
34+
// eslint-disable-next-line no-unused-vars
35+
startTransition: _,
36+
// eslint-disable-next-line no-unused-vars
37+
useSyncExternalStore: __,
38+
// eslint-disable-next-line no-unused-vars
39+
unstable_useSyncExternalStore: ___,
40+
...otherExports
41+
} = jest.requireActual('react');
42+
return otherExports;
43+
});
44+
45+
React = require('react');
46+
ReactDOM = require('react-dom');
47+
ReactDOMServer = require('react-dom/server');
48+
Scheduler = require('scheduler');
49+
50+
useSyncExternalStore = require('use-sync-external-store')
51+
.useSyncExternalStore;
52+
});
53+
54+
function Text({text}) {
55+
Scheduler.unstable_yieldValue(text);
56+
return text;
57+
}
58+
59+
function createExternalStore(initialState) {
60+
const listeners = new Set();
61+
let currentState = initialState;
62+
return {
63+
set(text) {
64+
currentState = text;
65+
ReactDOM.unstable_batchedUpdates(() => {
66+
listeners.forEach(listener => listener());
67+
});
68+
},
69+
subscribe(listener) {
70+
listeners.add(listener);
71+
return () => listeners.delete(listener);
72+
},
73+
getState() {
74+
return currentState;
75+
},
76+
getSubscriberCount() {
77+
return listeners.size;
78+
},
79+
};
80+
}
81+
82+
test('basic server render', async () => {
83+
const store = createExternalStore('client');
84+
85+
function App() {
86+
const text = useSyncExternalStore(
87+
store.subscribe,
88+
store.getState,
89+
() => 'server',
90+
);
91+
return <Text text={text} />;
92+
}
93+
94+
const html = ReactDOMServer.renderToString(<App />);
95+
expect(Scheduler).toHaveYielded(['server']);
96+
expect(html).toEqual('server');
97+
});
98+
});

packages/use-sync-external-store/src/useSyncExternalStore.js

Lines changed: 30 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,8 @@
99

1010
import * as React from 'react';
1111
import is from 'shared/objectIs';
12+
import invariant from 'shared/invariant';
13+
import {canUseDOM} from 'shared/ExecutionEnvironment';
1214

1315
// Intentionally not using named imports because Rollup uses dynamic
1416
// dispatch for CommonJS interop named imports.
@@ -21,17 +23,38 @@ const {
2123
unstable_useSyncExternalStore: builtInAPI,
2224
} = React;
2325

26+
// TODO: This heuristic doesn't work in React Native. We'll need to provide a
27+
// special build, using the `.native` extension.
28+
const isServerEnvironment = !canUseDOM;
29+
2430
// Prefer the built-in API, if it exists. If it doesn't exist, then we assume
2531
// we're in version 16 or 17, so rendering is always synchronous. The shim
2632
// does not support concurrent rendering, only the built-in API.
2733
export const useSyncExternalStore =
2834
builtInAPI !== undefined
29-
? ((builtInAPI: any): typeof useSyncExternalStore_shim)
30-
: useSyncExternalStore_shim;
35+
? ((builtInAPI: any): typeof useSyncExternalStore_client)
36+
: isServerEnvironment
37+
? useSyncExternalStore_server
38+
: useSyncExternalStore_client;
3139

3240
let didWarnOld18Alpha = false;
3341
let didWarnUncachedGetSnapshot = false;
3442

43+
function useSyncExternalStore_server<T>(
44+
subscribe: (() => void) => () => void,
45+
getSnapshot: () => T,
46+
getServerSnapshot?: () => T,
47+
): T {
48+
if (getServerSnapshot === undefined) {
49+
invariant(
50+
false,
51+
'Missing getServerSnapshot, which is required for server-' +
52+
'rendered content.',
53+
);
54+
}
55+
return getServerSnapshot();
56+
}
57+
3558
// Disclaimer: This shim breaks many of the rules of React, and only works
3659
// because of a very particular set of implementation details and assumptions
3760
// -- change any one of them and it will break. The most important assumption
@@ -42,10 +65,13 @@ let didWarnUncachedGetSnapshot = false;
4265
//
4366
// Do not assume that the clever hacks used by this hook also work in general.
4467
// The point of this shim is to replace the need for hacks by other libraries.
45-
function useSyncExternalStore_shim<T>(
68+
function useSyncExternalStore_client<T>(
4669
subscribe: (() => void) => () => void,
4770
getSnapshot: () => T,
48-
// TODO: Add a canUseDOM check and use this one on the server
71+
// Note: The client shim does not use getServerSnapshot, because pre-18
72+
// versions of React do not expose a way to check if we're hydrating. So
73+
// users of the shim will need to track that themselves and return the
74+
// correct value from `getSnapshot`.
4975
getServerSnapshot?: () => T,
5076
): T {
5177
if (__DEV__) {
@@ -97,7 +123,6 @@ function useSyncExternalStore_shim<T>(
97123
// Track the latest getSnapshot function with a ref. This needs to be updated
98124
// in the layout phase so we can access it during the tearing check that
99125
// happens on subscribe.
100-
// TODO: Circumvent SSR warning with canUseDOM check
101126
useLayoutEffect(() => {
102127
inst.value = value;
103128
inst.getSnapshot = getSnapshot;

scripts/error-codes/codes.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -395,5 +395,6 @@
395395
"404": "Invalid hook call. Hooks can only be called inside of the body of a function component.",
396396
"405": "hydrateRoot(...): Target container is not a DOM element.",
397397
"406": "act(...) is not supported in production builds of React.",
398-
"407": "Missing getServerSnapshot, which is required for server-rendered content. Will revert to client rendering."
398+
"407": "Missing getServerSnapshot, which is required for server-rendered content. Will revert to client rendering.",
399+
"408": "Missing getServerSnapshot, which is required for server-rendered content."
399400
}

0 commit comments

Comments
 (0)