@samitouri / QOS-React-2 / commits / 79b8fc6670

Implement getServerSnapshot in userspace shim (#22359)

* Convert useSES shim tests to use React DOM Idea is that eventually we'll run these tests against an actual build of React DOM 17 to test backwards compatibility. * 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.

Andrew Clark committed Sep 20, 2021 at 11:32 UTC 79b8fc66704a9a24588bf41b25afbcd818358b80
4 files changed +264 -75
packages/use-sync-external-store/src/__tests__/useSyncExternalStoreShared-test.js
+134 -69
@@ -12,7 +12,7 @@
12 let useSyncExternalStore;
13 let useSyncExternalStoreExtra;
14 let React;
15 -let ReactNoop;
15 +let ReactDOM;
16 let Scheduler;
17 let act;
18 let useState;
@@ -27,11 +27,7 @@ describe('Shared useSyncExternalStore behavior (shim and built-in)', () => {
27
28 // Remove the built-in API from the React exports to force the package to
29 // use the shim.
30 - // TODO: Don't do this during a variant test run. That way these tests run
31 - // against both the shim and the built-in implementation.
32 - if (gate(flags => flags.variant)) {
33 - // We'll use the variant flag to represent the native implementation
34 - } else {
30 + if (!gate(flags => flags.supportsNativeUseSyncExternalStore)) {
31 // and the non-variant tests for the shim.
32 //
33 // Remove useSyncExternalStore from the React imports so that we use the
@@ -55,7 +51,7 @@ describe('Shared useSyncExternalStore behavior (shim and built-in)', () => {
51 }
52
53 React = require('react');
58 - ReactNoop = require('react-noop-renderer');
54 + ReactDOM = require('react-dom');
55 Scheduler = require('scheduler');
56 useState = React.useState;
57 useEffect = React.useEffect;
@@ -66,7 +62,7 @@ describe('Shared useSyncExternalStore behavior (shim and built-in)', () => {
62 // The internal act implementation doesn't batch updates by default, since
63 // it's mostly used to test concurrent mode. But since these tests run
64 // in both concurrent and legacy mode, I'm adding batching here.
69 - act = cb => internalAct(() => ReactNoop.batchedUpdates(cb));
65 + act = cb => internalAct(() => ReactDOM.unstable_batchedUpdates(cb));
66
67 useSyncExternalStore = require('use-sync-external-store')
68 .useSyncExternalStore;
@@ -79,19 +75,24 @@ describe('Shared useSyncExternalStore behavior (shim and built-in)', () => {
75 return text;
76 }
77
82 - function createRoot(element) {
78 + function createRoot(container) {
79 // This wrapper function exists so we can test both legacy roots and
80 // concurrent roots.
85 - if (gate(flags => flags.variant)) {
81 + if (gate(flags => flags.supportsNativeUseSyncExternalStore)) {
82 // The native implementation only exists in 18+, so we test using
83 // concurrent mode. To test the legacy root behavior in the native
84 // implementation (which is supported in the sense that it needs to have
85 // the correct behavior, despite the fact that the legacy root API
86 // triggers a warning in 18), write a test that uses
87 // createLegacyRoot directly.
92 - return ReactNoop.createRoot();
88 + return ReactDOM.createRoot(container);
89 } else {
94 - return ReactNoop.createLegacyRoot();
90 + ReactDOM.render(null, container);
91 + return {
92 + render(children) {
93 + ReactDOM.render(children, container);
94 + },
95 + };
96 }
97 }
98
@@ -101,7 +102,7 @@ describe('Shared useSyncExternalStore behavior (shim and built-in)', () => {
102 return {
103 set(text) {
104 currentState = text;
104 - ReactNoop.batchedUpdates(() => {
105 + ReactDOM.unstable_batchedUpdates(() => {
106 listeners.forEach(listener => listener());
107 });
108 },
@@ -126,17 +127,18 @@ describe('Shared useSyncExternalStore behavior (shim and built-in)', () => {
127 return <Text text={text} />;
128 }
129
129 - const root = createRoot();
130 + const container = document.createElement('div');
131 + const root = createRoot(container);
132 await act(() => root.render(<App />));
133
134 expect(Scheduler).toHaveYielded(['Initial']);
133 - expect(root).toMatchRenderedOutput('Initial');
135 + expect(container.textContent).toEqual('Initial');
136
137 await act(() => {
138 store.set('Updated');
139 });
140 expect(Scheduler).toHaveYielded(['Updated']);
139 - expect(root).toMatchRenderedOutput('Updated');
141 + expect(container.textContent).toEqual('Updated');
142 });
143
144 test('skips re-rendering if nothing changes', () => {
@@ -147,11 +149,12 @@ describe('Shared useSyncExternalStore behavior (shim and built-in)', () => {
149 return <Text text={text} />;
150 }
151
150 - const root = createRoot();
152 + const container = document.createElement('div');
153 + const root = createRoot(container);
154 act(() => root.render(<App />));
155
156 expect(Scheduler).toHaveYielded(['Initial']);
154 - expect(root).toMatchRenderedOutput('Initial');
157 + expect(container.textContent).toEqual('Initial');
158
159 // Update to the same value
160 act(() => {
@@ -159,7 +162,7 @@ describe('Shared useSyncExternalStore behavior (shim and built-in)', () => {
162 });
163 // Should not re-render
164 expect(Scheduler).toHaveYielded([]);
162 - expect(root).toMatchRenderedOutput('Initial');
165 + expect(container.textContent).toEqual('Initial');
166 });
167
168 test('switch to a different store', async () => {
@@ -174,21 +177,22 @@ describe('Shared useSyncExternalStore behavior (shim and built-in)', () => {
177 return <Text text={value} />;
178 }
179
177 - const root = createRoot();
180 + const container = document.createElement('div');
181 + const root = createRoot(container);
182 await act(() => root.render(<App />));
183
184 expect(Scheduler).toHaveYielded([0]);
181 - expect(root).toMatchRenderedOutput('0');
185 + expect(container.textContent).toEqual('0');
186
187 await act(() => {
188 storeA.set(1);
189 });
190 expect(Scheduler).toHaveYielded([1]);
187 - expect(root).toMatchRenderedOutput('1');
191 + expect(container.textContent).toEqual('1');
192
193 // Switch stores and update in the same batch
194 act(() => {
191 - ReactNoop.flushSync(() => {
195 + ReactDOM.flushSync(() => {
196 // This update will be disregarded
197 storeA.set(2);
198 setStore(storeB);
@@ -196,7 +200,7 @@ describe('Shared useSyncExternalStore behavior (shim and built-in)', () => {
200 });
201 // Now reading from B instead of A
202 expect(Scheduler).toHaveYielded([0]);
199 - expect(root).toMatchRenderedOutput('0');
203 + expect(container.textContent).toEqual('0');
204
205 // Update A
206 await act(() => {
@@ -204,14 +208,14 @@ describe('Shared useSyncExternalStore behavior (shim and built-in)', () => {
208 });
209 // Nothing happened, because we're no longer subscribed to A
210 expect(Scheduler).toHaveYielded([]);
207 - expect(root).toMatchRenderedOutput('0');
211 + expect(container.textContent).toEqual('0');
212
213 // Update B
214 await act(() => {
215 storeB.set(1);
216 });
217 expect(Scheduler).toHaveYielded([1]);
214 - expect(root).toMatchRenderedOutput('1');
218 + expect(container.textContent).toEqual('1');
219 });
220
221 test('selecting a specific value inside getSnapshot', async () => {
@@ -235,11 +239,12 @@ describe('Shared useSyncExternalStore behavior (shim and built-in)', () => {
239 );
240 }
241
238 - const root = createRoot();
242 + const container = document.createElement('div');
243 + const root = createRoot(container);
244 act(() => root.render(<App />));
245
246 expect(Scheduler).toHaveYielded(['A0', 'B0']);
242 - expect(root).toMatchRenderedOutput('A0B0');
247 + expect(container.textContent).toEqual('A0B0');
248
249 // Update b but not a
250 await act(() => {
@@ -247,7 +252,7 @@ describe('Shared useSyncExternalStore behavior (shim and built-in)', () => {
252 });
253 // Only b re-renders
254 expect(Scheduler).toHaveYielded(['B1']);
250 - expect(root).toMatchRenderedOutput('A0B1');
255 + expect(container.textContent).toEqual('A0B1');
256
257 // Update a but not b
258 await act(() => {
@@ -255,12 +260,12 @@ describe('Shared useSyncExternalStore behavior (shim and built-in)', () => {
260 });
261 // Only a re-renders
262 expect(Scheduler).toHaveYielded(['A1']);
258 - expect(root).toMatchRenderedOutput('A1B1');
263 + expect(container.textContent).toEqual('A1B1');
264 });
265
266 // In React 18, you can't observe in between a sync render and its
267 // passive effects, so this is only relevant to legacy roots
263 - // @gate !variant
268 + // @gate !supportsNativeUseSyncExternalStore
269 test(
270 "compares to current state before bailing out, even when there's a " +
271 'mutation in between the sync and passive effects',
@@ -275,7 +280,8 @@ describe('Shared useSyncExternalStore behavior (shim and built-in)', () => {
280 return <Text text={value} />;
281 }
282
278 - const root = createRoot();
283 + const container = document.createElement('div');
284 + const root = createRoot(container);
285 act(() => root.render(<App />));
286 expect(Scheduler).toHaveYielded([0, 'Passive effect: 0']);
287
@@ -287,7 +293,7 @@ describe('Shared useSyncExternalStore behavior (shim and built-in)', () => {
293 1,
294 // Passive effect hasn't fired yet
295 ]);
290 - expect(root).toMatchRenderedOutput('1');
296 + expect(container.textContent).toEqual('1');
297
298 // Flip the store state back to the previous value.
299 store.set(0);
@@ -300,7 +306,7 @@ describe('Shared useSyncExternalStore behavior (shim and built-in)', () => {
306 0,
307 ]);
308 // Should flip back to 0
303 - expect(root).toMatchRenderedOutput('0');
309 + expect(container.textContent).toEqual('0');
310 },
311 );
312
@@ -344,10 +350,11 @@ describe('Shared useSyncExternalStore behavior (shim and built-in)', () => {
350 );
351 }
352
347 - const root = createRoot();
353 + const container = document.createElement('div');
354 + const root = createRoot(container);
355 act(() => root.render(<App />));
356 expect(Scheduler).toHaveYielded(['A1']);
350 - expect(root).toMatchRenderedOutput('A1');
357 + expect(container.textContent).toEqual('A1');
358
359 act(() => {
360 // Change getSnapshot and update the store in the same batch
@@ -360,7 +367,7 @@ describe('Shared useSyncExternalStore behavior (shim and built-in)', () => {
367 // incorrectly bailed out here instead of re-rendering.
368 'B2',
369 ]);
363 - expect(root).toMatchRenderedOutput('B2');
370 + expect(container.textContent).toEqual('B2');
371 });
372
373 test('mutating the store in between render and commit when getSnapshot has _not_ changed', () => {
@@ -401,10 +408,11 @@ describe('Shared useSyncExternalStore behavior (shim and built-in)', () => {
408 );
409 }
410
404 - const root = createRoot();
411 + const container = document.createElement('div');
412 + const root = createRoot(container);
413 act(() => root.render(<App />));
414 expect(Scheduler).toHaveYielded(['A1']);
407 - expect(root).toMatchRenderedOutput('A1');
415 + expect(container.textContent).toEqual('A1');
416
417 // This will cause a layout effect, and in the layout effect we'll update
418 // the store
@@ -418,7 +426,7 @@ describe('Shared useSyncExternalStore behavior (shim and built-in)', () => {
426 'Update B in commit phase',
427 // No re-render
428 ]);
421 - expect(root).toMatchRenderedOutput('A1');
429 + expect(container.textContent).toEqual('A1');
430 });
431
432 test("does not bail out if the previous update hasn't finished yet", async () => {
@@ -440,7 +448,8 @@ describe('Shared useSyncExternalStore behavior (shim and built-in)', () => {
448 return <Text text={value} />;
449 }
450
443 - const root = createRoot();
451 + const container = document.createElement('div');
452 + const root = createRoot(container);
453 act(() =>
454 root.render(
455 <>
@@ -450,13 +459,13 @@ describe('Shared useSyncExternalStore behavior (shim and built-in)', () => {
459 ),
460 );
461 expect(Scheduler).toHaveYielded([0, 0]);
453 - expect(root).toMatchRenderedOutput('00');
462 + expect(container.textContent).toEqual('00');
463
464 await act(() => {
465 store.set(1);
466 });
467 expect(Scheduler).toHaveYielded([1, 1, 'Reset back to 0', 0, 0]);
459 - expect(root).toMatchRenderedOutput('00');
468 + expect(container.textContent).toEqual('00');
469 });
470
471 test('uses the latest getSnapshot, even if it changed in the same batch as a store update', () => {
@@ -473,20 +482,21 @@ describe('Shared useSyncExternalStore behavior (shim and built-in)', () => {
482 return <Text text={text} />;
483 }
484
476 - const root = createRoot();
485 + const container = document.createElement('div');
486 + const root = createRoot(container);
487 act(() => root.render(<App />));
488 expect(Scheduler).toHaveYielded([0]);
489
490 // Update the store and getSnapshot at the same time
491 act(() => {
482 - ReactNoop.flushSync(() => {
492 + ReactDOM.flushSync(() => {
493 setGetSnapshot(() => getSnapshotB);
494 store.set({a: 1, b: 2});
495 });
496 });
497 // It should read from B instead of A
498 expect(Scheduler).toHaveYielded([2]);
489 - expect(root).toMatchRenderedOutput('2');
499 + expect(container.textContent).toEqual('2');
500 });
501
502 test('handles errors thrown by getSnapshot', async () => {
@@ -521,7 +531,8 @@ describe('Shared useSyncExternalStore behavior (shim and built-in)', () => {
531 }
532
533 const errorBoundary = React.createRef(null);
524 - const root = createRoot();
534 + const container = document.createElement('div');
535 + const root = createRoot(container);
536 act(() =>
537 root.render(
538 <ErrorBoundary ref={errorBoundary}>
@@ -530,13 +541,13 @@ describe('Shared useSyncExternalStore behavior (shim and built-in)', () => {
541 ),
542 );
543 expect(Scheduler).toHaveYielded([0]);
533 - expect(root).toMatchRenderedOutput('0');
544 + expect(container.textContent).toEqual('0');
545
546 // Update that throws in a getSnapshot. We can catch it with an error boundary.
547 await act(() => {
548 store.set({value: 1, throwInGetSnapshot: true, throwInIsEqual: false});
549 });
539 - if (gate(flags => flags.variant)) {
550 + if (gate(flags => flags.supportsNativeUseSyncExternalStore)) {
551 expect(Scheduler).toHaveYielded([
552 'Error in getSnapshot',
553 // In a concurrent root, React renders a second time to attempt to
@@ -546,7 +557,7 @@ describe('Shared useSyncExternalStore behavior (shim and built-in)', () => {
557 } else {
558 expect(Scheduler).toHaveYielded(['Error in getSnapshot']);
559 }
549 - expect(root).toMatchRenderedOutput('Error in getSnapshot');
560 + expect(container.textContent).toEqual('Error in getSnapshot');
561 });
562
563 test('Infinite loop if getSnapshot keeps returning new reference', () => {
@@ -557,19 +568,18 @@ describe('Shared useSyncExternalStore behavior (shim and built-in)', () => {
568 return <Text text={JSON.stringify(text)} />;
569 }
570
560 - spyOnDev(console, 'error');
561 - const root = createRoot();
571 + const container = document.createElement('div');
572 + const root = createRoot(container);
573
563 - expect(() => act(() => root.render(<App />))).toThrow(
564 - 'Maximum update depth exceeded. This can happen when a component repeatedly ' +
565 - 'calls setState inside componentWillUpdate or componentDidUpdate. React limits ' +
566 - 'the number of nested updates to prevent infinite loops.',
567 - );
568 - if (__DEV__) {
569 - expect(console.error.calls.argsFor(0)[0]).toMatch(
570 - 'The result of getSnapshot should be cached to avoid an infinite loop',
574 + expect(() => {
575 + expect(() => act(() => root.render(<App />))).toThrow(
576 + 'Maximum update depth exceeded. This can happen when a component repeatedly ' +
577 + 'calls setState inside componentWillUpdate or componentDidUpdate. React limits ' +
578 + 'the number of nested updates to prevent infinite loops.',
579 );
572 - }
580 + }).toErrorDev(
581 + 'The result of getSnapshot should be cached to avoid an infinite loop',
582 + );
583 });
584
585 describe('extra features implemented in user-space', () => {
@@ -594,11 +604,12 @@ describe('Shared useSyncExternalStore behavior (shim and built-in)', () => {
604 return <Text text={'A' + a} />;
605 }
606
597 - const root = createRoot();
607 + const container = document.createElement('div');
608 + const root = createRoot(container);
609 act(() => root.render(<App />));
610
611 expect(Scheduler).toHaveYielded(['App', 'Selector', 'A0']);
601 - expect(root).toMatchRenderedOutput('A0');
612 + expect(container.textContent).toEqual('A0');
613
614 // Update the store
615 await act(() => {
@@ -612,7 +623,7 @@ describe('Shared useSyncExternalStore behavior (shim and built-in)', () => {
623 // the previous result without running the selector again
624 'A1',
625 ]);
615 - expect(root).toMatchRenderedOutput('A1');
626 + expect(container.textContent).toEqual('A1');
627 });
628
629 // The selector implementation uses the lazy ref initialization pattern
@@ -652,11 +663,12 @@ describe('Shared useSyncExternalStore behavior (shim and built-in)', () => {
663 );
664 }
665
655 - const root = createRoot();
666 + const container = document.createElement('div');
667 + const root = createRoot(container);
668 act(() => root.render(<App />));
669
670 expect(Scheduler).toHaveYielded(['A0', 'B0']);
659 - expect(root).toMatchRenderedOutput('A0B0');
671 + expect(container.textContent).toEqual('A0B0');
672
673 // Update b but not a
674 await act(() => {
@@ -664,7 +676,7 @@ describe('Shared useSyncExternalStore behavior (shim and built-in)', () => {
676 });
677 // Only b re-renders
678 expect(Scheduler).toHaveYielded(['B1']);
667 - expect(root).toMatchRenderedOutput('A0B1');
679 + expect(container.textContent).toEqual('A0B1');
680
681 // Update a but not b
682 await act(() => {
@@ -672,7 +684,59 @@ describe('Shared useSyncExternalStore behavior (shim and built-in)', () => {
684 });
685 // Only a re-renders
686 expect(Scheduler).toHaveYielded(['A1']);
675 - expect(root).toMatchRenderedOutput('A1B1');
687 + expect(container.textContent).toEqual('A1B1');
688 + });
689 +
690 + test('basic server hydration', async () => {
691 + const store = createExternalStore('client');
692 +
693 + const ref = React.createRef();
694 + function App() {
695 + const text = useSyncExternalStore(
696 + store.subscribe,
697 + store.getState,
698 + () => 'server',
699 + );
700 + useEffect(() => {
701 + Scheduler.unstable_yieldValue('Passive effect: ' + text);
702 + }, [text]);
703 + return (
704 + <div ref={ref}>
705 + <Text text={text} />
706 + </div>
707 + );
708 + }
709 +
710 + const container = document.createElement('div');
711 + container.innerHTML = '<div>server</div>';
712 + const serverRenderedDiv = container.getElementsByTagName('div')[0];
713 +
714 + if (gate(flags => flags.supportsNativeUseSyncExternalStore)) {
715 + act(() => {
716 + ReactDOM.hydrateRoot(container, <App />);
717 + });
718 + expect(Scheduler).toHaveYielded([
719 + // First it hydrates the server rendered HTML
720 + 'server',
721 + 'Passive effect: server',
722 + // Then in a second paint, it re-renders with the client state
723 + 'client',
724 + 'Passive effect: client',
725 + ]);
726 + } else {
727 + // In the userspace shim, there's no mechanism to detect whether we're
728 + // currently hydrating, so `getServerSnapshot` is not called on the
729 + // client. To avoid this server mismatch warning, user must account for
730 + // this themselves and return the correct value inside `getSnapshot`.
731 + act(() => {
732 + expect(() => ReactDOM.hydrate(<App />, container)).toErrorDev(
733 + 'Text content did not match',
734 + );
735 + });
736 + expect(Scheduler).toHaveYielded(['client', 'Passive effect: client']);
737 + }
738 + expect(container.textContent).toEqual('client');
739 + expect(ref.current).toEqual(serverRenderedDiv);
740 });
741 });
742
@@ -725,7 +789,8 @@ describe('Shared useSyncExternalStore behavior (shim and built-in)', () => {
789 );
790 }
791
728 - const root = createRoot();
792 + const container = document.createElement('div');
793 + const root = createRoot(container);
794 await act(() => {
795 root.render(<App step={0} />);
796 });
packages/use-sync-external-store/src/__tests__/useSyncExternalStoreShimServer-test.js new
+98
@@ -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
+30 -5
@@ -9,6 +9,8 @@
9
10 import * as React from 'react';
11 import is from 'shared/objectIs';
12 +import invariant from 'shared/invariant';
13 +import {canUseDOM} from 'shared/ExecutionEnvironment';
14
15 // Intentionally not using named imports because Rollup uses dynamic
16 // dispatch for CommonJS interop named imports.
@@ -21,17 +23,38 @@ const {
23 unstable_useSyncExternalStore: builtInAPI,
24 } = React;
25
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 +
30 // Prefer the built-in API, if it exists. If it doesn't exist, then we assume
31 // we're in version 16 or 17, so rendering is always synchronous. The shim
32 // does not support concurrent rendering, only the built-in API.
33 export const useSyncExternalStore =
34 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;
39
40 let didWarnOld18Alpha = false;
41 let didWarnUncachedGetSnapshot = false;
42
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 +
58 // Disclaimer: This shim breaks many of the rules of React, and only works
59 // because of a very particular set of implementation details and assumptions
60 // -- change any one of them and it will break. The most important assumption
@@ -42,10 +65,13 @@ let didWarnUncachedGetSnapshot = false;
65 //
66 // Do not assume that the clever hacks used by this hook also work in general.
67 // 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>(
69 subscribe: (() => void) => () => void,
70 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`.
75 getServerSnapshot?: () => T,
76 ): T {
77 if (__DEV__) {
@@ -97,7 +123,6 @@ function useSyncExternalStore_shim<T>(
123 // Track the latest getSnapshot function with a ref. This needs to be updated
124 // in the layout phase so we can access it during the tearing check that
125 // happens on subscribe.
100 - // TODO: Circumvent SSR warning with canUseDOM check
126 useLayoutEffect(() => {
127 inst.value = value;
128 inst.getSnapshot = getSnapshot;
scripts/error-codes/codes.json
+2 -1
@@ -395,5 +395,6 @@
395 "404": "Invalid hook call. Hooks can only be called inside of the body of a function component.",
396 "405": "hydrateRoot(...): Target container is not a DOM element.",
397 "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."
400 }