main
js 1,088 lines 33 KB
Raw
1 /**
2 * Copyright (c) Meta Platforms, Inc. and 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
10 'use strict';
11
12 let useSyncExternalStore;
13 let useSyncExternalStoreWithSelector;
14 let React;
15 let ReactDOM;
16 let ReactDOMClient;
17 let Scheduler;
18 let act;
19 let useState;
20 let useEffect;
21 let useLayoutEffect;
22 let assertLog;
23 let assertConsoleErrorDev;
24
25 // This tests shared behavior between the built-in and shim implementations of
26 // of useSyncExternalStore.
27 describe('Shared useSyncExternalStore behavior (shim and built-in)', () => {
28 beforeEach(() => {
29 jest.resetModules();
30 if (gate(flags => flags.enableUseSyncExternalStoreShim)) {
31 // Test the shim against React 17.
32 jest.mock('react', () => {
33 return jest.requireActual(
34 __DEV__
35 ? 'react-17/umd/react.development.js'
36 : 'react-17/umd/react.production.min.js',
37 );
38 });
39 jest.mock('react-dom', () =>
40 jest.requireActual(
41 __DEV__
42 ? 'react-dom-17/umd/react-dom.development.js'
43 : 'react-dom-17/umd/react-dom.production.min.js',
44 ),
45 );
46 jest.mock('react-dom/client', () =>
47 jest.requireActual(
48 __DEV__
49 ? 'react-dom-17/umd/react-dom.development.js'
50 : 'react-dom-17/umd/react-dom.production.min.js',
51 ),
52 );
53 }
54 React = require('react');
55 ReactDOM = require('react-dom');
56 ReactDOMClient = require('react-dom/client');
57 Scheduler = require('scheduler');
58 useState = React.useState;
59 useEffect = React.useEffect;
60 useLayoutEffect = React.useLayoutEffect;
61 const InternalTestUtils = require('internal-test-utils');
62 assertLog = InternalTestUtils.assertLog;
63 assertConsoleErrorDev = InternalTestUtils.assertConsoleErrorDev;
64 const internalAct = require('internal-test-utils').act;
65
66 // The internal act implementation doesn't batch updates by default, since
67 // it's mostly used to test concurrent mode. But since these tests run
68 // in both concurrent and legacy mode, I'm adding batching here.
69 act = cb => internalAct(() => ReactDOM.unstable_batchedUpdates(cb));
70 if (gate(flags => flags.source)) {
71 // The `shim/with-selector` module composes the main
72 // `use-sync-external-store` entrypoint. In the compiled artifacts, this
73 // is resolved to the `shim` implementation by our build config, but when
74 // running the tests against the source files, we need to tell Jest how to
75 // resolve it. Because this is a source module, this mock has no affect on
76 // the build tests.
77 jest.mock('use-sync-external-store/src/useSyncExternalStore', () =>
78 jest.requireActual('use-sync-external-store/shim'),
79 );
80 }
81 useSyncExternalStore =
82 require('use-sync-external-store/shim').useSyncExternalStore;
83 useSyncExternalStoreWithSelector =
84 require('use-sync-external-store/shim/with-selector').useSyncExternalStoreWithSelector;
85 });
86 function Text({text}) {
87 Scheduler.log(text);
88 return text;
89 }
90 function createRoot(container) {
91 // This wrapper function exists so we can test both legacy roots and
92 // concurrent roots.
93 if (gate(flags => !flags.enableUseSyncExternalStoreShim)) {
94 // The native implementation only exists in 18+, so we test using
95 // concurrent mode. To test the legacy root behavior in the native
96 // implementation (which is supported in the sense that it needs to have
97 // the correct behavior, despite the fact that the legacy root API
98 // triggers a warning in 18), write a test that uses
99 // createLegacyRoot directly.
100 return ReactDOMClient.createRoot(container);
101 } else {
102 // This ReactDOM.render is from the React 17 npm module.
103 ReactDOM.render(null, container);
104 return {
105 render(children) {
106 ReactDOM.render(children, container);
107 },
108 };
109 }
110 }
111 function createExternalStore(initialState) {
112 const listeners = new Set();
113 let currentState = initialState;
114 return {
115 set(text) {
116 currentState = text;
117 ReactDOM.unstable_batchedUpdates(() => {
118 listeners.forEach(listener => listener());
119 });
120 },
121 subscribe(listener) {
122 listeners.add(listener);
123 return () => listeners.delete(listener);
124 },
125 getState() {
126 return currentState;
127 },
128 getSubscriberCount() {
129 return listeners.size;
130 },
131 };
132 }
133 it('basic usage', async () => {
134 const store = createExternalStore('Initial');
135 function App() {
136 const text = useSyncExternalStore(store.subscribe, store.getState);
137 return React.createElement(Text, {
138 text: text,
139 });
140 }
141 const container = document.createElement('div');
142 const root = createRoot(container);
143 await act(() => root.render(React.createElement(App, null)));
144 assertLog(['Initial']);
145 expect(container.textContent).toEqual('Initial');
146 await act(() => {
147 store.set('Updated');
148 });
149 assertLog(['Updated']);
150 expect(container.textContent).toEqual('Updated');
151 });
152 it('skips re-rendering if nothing changes', async () => {
153 const store = createExternalStore('Initial');
154 function App() {
155 const text = useSyncExternalStore(store.subscribe, store.getState);
156 return React.createElement(Text, {
157 text: text,
158 });
159 }
160 const container = document.createElement('div');
161 const root = createRoot(container);
162 await act(() => root.render(React.createElement(App, null)));
163 assertLog(['Initial']);
164 expect(container.textContent).toEqual('Initial');
165
166 // Update to the same value
167 await act(() => {
168 store.set('Initial');
169 });
170 // Should not re-render
171 assertLog([]);
172 expect(container.textContent).toEqual('Initial');
173 });
174 it('switch to a different store', async () => {
175 const storeA = createExternalStore(0);
176 const storeB = createExternalStore(0);
177 let setStore;
178 function App() {
179 const [store, _setStore] = useState(storeA);
180 setStore = _setStore;
181 const value = useSyncExternalStore(store.subscribe, store.getState);
182 return React.createElement(Text, {
183 text: value,
184 });
185 }
186 const container = document.createElement('div');
187 const root = createRoot(container);
188 await act(() => root.render(React.createElement(App, null)));
189 assertLog([0]);
190 expect(container.textContent).toEqual('0');
191 await act(() => {
192 storeA.set(1);
193 });
194 assertLog([1]);
195 expect(container.textContent).toEqual('1');
196
197 // Switch stores and update in the same batch
198 await act(() => {
199 ReactDOM.flushSync(() => {
200 // This update will be disregarded
201 storeA.set(2);
202 setStore(storeB);
203 });
204 });
205 // Now reading from B instead of A
206 assertLog([0]);
207 expect(container.textContent).toEqual('0');
208
209 // Update A
210 await act(() => {
211 storeA.set(3);
212 });
213 // Nothing happened, because we're no longer subscribed to A
214 assertLog([]);
215 expect(container.textContent).toEqual('0');
216
217 // Update B
218 await act(() => {
219 storeB.set(1);
220 });
221 assertLog([1]);
222 expect(container.textContent).toEqual('1');
223 });
224 it('selecting a specific value inside getSnapshot', async () => {
225 const store = createExternalStore({
226 a: 0,
227 b: 0,
228 });
229 function A() {
230 const a = useSyncExternalStore(store.subscribe, () => store.getState().a);
231 return React.createElement(Text, {
232 text: 'A' + a,
233 });
234 }
235 function B() {
236 const b = useSyncExternalStore(store.subscribe, () => store.getState().b);
237 return React.createElement(Text, {
238 text: 'B' + b,
239 });
240 }
241 function App() {
242 return React.createElement(
243 React.Fragment,
244 null,
245 React.createElement(A, null),
246 React.createElement(B, null),
247 );
248 }
249 const container = document.createElement('div');
250 const root = createRoot(container);
251 await act(() => root.render(React.createElement(App, null)));
252 assertLog(['A0', 'B0']);
253 expect(container.textContent).toEqual('A0B0');
254
255 // Update b but not a
256 await act(() => {
257 store.set({
258 a: 0,
259 b: 1,
260 });
261 });
262 // Only b re-renders
263 assertLog(['B1']);
264 expect(container.textContent).toEqual('A0B1');
265
266 // Update a but not b
267 await act(() => {
268 store.set({
269 a: 1,
270 b: 1,
271 });
272 });
273 // Only a re-renders
274 assertLog(['A1']);
275 expect(container.textContent).toEqual('A1B1');
276 });
277
278 // In React 18, you can't observe in between a sync render and its
279 // passive effects, so this is only relevant to legacy roots
280 // @gate enableUseSyncExternalStoreShim
281 it(
282 "compares to current state before bailing out, even when there's a " +
283 'mutation in between the sync and passive effects',
284 async () => {
285 const store = createExternalStore(0);
286 function App() {
287 const value = useSyncExternalStore(store.subscribe, store.getState);
288 useEffect(() => {
289 Scheduler.log('Passive effect: ' + value);
290 }, [value]);
291 return React.createElement(Text, {
292 text: value,
293 });
294 }
295 const container = document.createElement('div');
296 const root = createRoot(container);
297 await act(() => root.render(React.createElement(App, null)));
298 assertLog([0, 'Passive effect: 0']);
299
300 // Schedule an update. We'll intentionally not use `act` so that we can
301 // insert a mutation before React subscribes to the store in a
302 // passive effect.
303 store.set(1);
304 assertLog([
305 1,
306 // Passive effect hasn't fired yet
307 ]);
308 expect(container.textContent).toEqual('1');
309
310 // Flip the store state back to the previous value.
311 store.set(0);
312 assertLog([
313 'Passive effect: 1',
314 // Re-render. If the current state were tracked by updating a ref in a
315 // passive effect, then this would break because the previous render's
316 // passive effect hasn't fired yet, so we'd incorrectly think that
317 // the state hasn't changed.
318 0,
319 ]);
320 // Should flip back to 0
321 expect(container.textContent).toEqual('0');
322 },
323 );
324 it('mutating the store in between render and commit when getSnapshot has changed', async () => {
325 const store = createExternalStore({
326 a: 1,
327 b: 1,
328 });
329 const getSnapshotA = () => store.getState().a;
330 const getSnapshotB = () => store.getState().b;
331 function Child1({step}) {
332 const value = useSyncExternalStore(store.subscribe, store.getState);
333 useLayoutEffect(() => {
334 if (step === 1) {
335 // Update B in a layout effect. This happens in the same commit
336 // that changed the getSnapshot in Child2. Child2's effects haven't
337 // fired yet, so it doesn't have access to the latest getSnapshot. So
338 // it can't use the getSnapshot to bail out.
339 Scheduler.log('Update B in commit phase');
340 store.set({
341 a: value.a,
342 b: 2,
343 });
344 }
345 }, [step]);
346 return null;
347 }
348 function Child2({step}) {
349 const label = step === 0 ? 'A' : 'B';
350 const getSnapshot = step === 0 ? getSnapshotA : getSnapshotB;
351 const value = useSyncExternalStore(store.subscribe, getSnapshot);
352 return React.createElement(Text, {
353 text: label + value,
354 });
355 }
356 let setStep;
357 function App() {
358 const [step, _setStep] = useState(0);
359 setStep = _setStep;
360 return React.createElement(
361 React.Fragment,
362 null,
363 React.createElement(Child1, {
364 step: step,
365 }),
366 React.createElement(Child2, {
367 step: step,
368 }),
369 );
370 }
371 const container = document.createElement('div');
372 const root = createRoot(container);
373 await act(() => root.render(React.createElement(App, null)));
374 assertLog(['A1']);
375 expect(container.textContent).toEqual('A1');
376 await act(() => {
377 // Change getSnapshot and update the store in the same batch
378 setStep(1);
379 });
380 assertLog([
381 'B1',
382 'Update B in commit phase',
383 // If Child2 had used the old getSnapshot to bail out, then it would have
384 // incorrectly bailed out here instead of re-rendering.
385 'B2',
386 ]);
387 expect(container.textContent).toEqual('B2');
388 });
389 it('mutating the store in between render and commit when getSnapshot has _not_ changed', async () => {
390 // Same as previous test, but `getSnapshot` does not change
391 const store = createExternalStore({
392 a: 1,
393 b: 1,
394 });
395 const getSnapshotA = () => store.getState().a;
396 function Child1({step}) {
397 const value = useSyncExternalStore(store.subscribe, store.getState);
398 useLayoutEffect(() => {
399 if (step === 1) {
400 // Update B in a layout effect. This happens in the same commit
401 // that changed the getSnapshot in Child2. Child2's effects haven't
402 // fired yet, so it doesn't have access to the latest getSnapshot. So
403 // it can't use the getSnapshot to bail out.
404 Scheduler.log('Update B in commit phase');
405 store.set({
406 a: value.a,
407 b: 2,
408 });
409 }
410 }, [step]);
411 return null;
412 }
413 function Child2({step}) {
414 const value = useSyncExternalStore(store.subscribe, getSnapshotA);
415 return React.createElement(Text, {
416 text: 'A' + value,
417 });
418 }
419 let setStep;
420 function App() {
421 const [step, _setStep] = useState(0);
422 setStep = _setStep;
423 return React.createElement(
424 React.Fragment,
425 null,
426 React.createElement(Child1, {
427 step: step,
428 }),
429 React.createElement(Child2, {
430 step: step,
431 }),
432 );
433 }
434 const container = document.createElement('div');
435 const root = createRoot(container);
436 await act(() => root.render(React.createElement(App, null)));
437 assertLog(['A1']);
438 expect(container.textContent).toEqual('A1');
439
440 // This will cause a layout effect, and in the layout effect we'll update
441 // the store
442 await act(() => {
443 setStep(1);
444 });
445 assertLog([
446 'A1',
447 // This updates B, but since Child2 doesn't subscribe to B, it doesn't
448 // need to re-render.
449 'Update B in commit phase',
450 // No re-render
451 ]);
452 expect(container.textContent).toEqual('A1');
453 });
454 it("does not bail out if the previous update hasn't finished yet", async () => {
455 const store = createExternalStore(0);
456 function Child1() {
457 const value = useSyncExternalStore(store.subscribe, store.getState);
458 useLayoutEffect(() => {
459 if (value === 1) {
460 Scheduler.log('Reset back to 0');
461 store.set(0);
462 }
463 }, [value]);
464 return React.createElement(Text, {
465 text: value,
466 });
467 }
468 function Child2() {
469 const value = useSyncExternalStore(store.subscribe, store.getState);
470 return React.createElement(Text, {
471 text: value,
472 });
473 }
474 const container = document.createElement('div');
475 const root = createRoot(container);
476 await act(() =>
477 root.render(
478 React.createElement(
479 React.Fragment,
480 null,
481 React.createElement(Child1, null),
482 React.createElement(Child2, null),
483 ),
484 ),
485 );
486 assertLog([0, 0]);
487 expect(container.textContent).toEqual('00');
488 await act(() => {
489 store.set(1);
490 });
491 assertLog([1, 1, 'Reset back to 0', 0, 0]);
492 expect(container.textContent).toEqual('00');
493 });
494 it('uses the latest getSnapshot, even if it changed in the same batch as a store update', async () => {
495 const store = createExternalStore({
496 a: 0,
497 b: 0,
498 });
499 const getSnapshotA = () => store.getState().a;
500 const getSnapshotB = () => store.getState().b;
501 let setGetSnapshot;
502 function App() {
503 const [getSnapshot, _setGetSnapshot] = useState(() => getSnapshotA);
504 setGetSnapshot = _setGetSnapshot;
505 const text = useSyncExternalStore(store.subscribe, getSnapshot);
506 return React.createElement(Text, {
507 text: text,
508 });
509 }
510 const container = document.createElement('div');
511 const root = createRoot(container);
512 await act(() => root.render(React.createElement(App, null)));
513 assertLog([0]);
514
515 // Update the store and getSnapshot at the same time
516 await act(() => {
517 ReactDOM.flushSync(() => {
518 setGetSnapshot(() => getSnapshotB);
519 store.set({
520 a: 1,
521 b: 2,
522 });
523 });
524 });
525 // It should read from B instead of A
526 assertLog([2]);
527 expect(container.textContent).toEqual('2');
528 });
529 it('handles errors thrown by getSnapshot', async () => {
530 class ErrorBoundary extends React.Component {
531 state = {
532 error: null,
533 };
534 static getDerivedStateFromError(error) {
535 return {
536 error,
537 };
538 }
539 render() {
540 if (this.state.error) {
541 return React.createElement(Text, {
542 text: this.state.error.message,
543 });
544 }
545 return this.props.children;
546 }
547 }
548 const store = createExternalStore({
549 value: 0,
550 throwInGetSnapshot: false,
551 throwInIsEqual: false,
552 });
553 function App() {
554 const {value} = useSyncExternalStore(store.subscribe, () => {
555 const state = store.getState();
556 if (state.throwInGetSnapshot) {
557 throw new Error('Error in getSnapshot');
558 }
559 return state;
560 });
561 return React.createElement(Text, {
562 text: value,
563 });
564 }
565 const errorBoundary = React.createRef(null);
566 const container = document.createElement('div');
567 const root = createRoot(container);
568 await act(() =>
569 root.render(
570 React.createElement(
571 ErrorBoundary,
572 {
573 ref: errorBoundary,
574 },
575 React.createElement(App, null),
576 ),
577 ),
578 );
579 assertLog([0]);
580 expect(container.textContent).toEqual('0');
581
582 // Update that throws in a getSnapshot. We can catch it with an error boundary.
583 if (__DEV__ && gate(flags => flags.enableUseSyncExternalStoreShim)) {
584 // In 17, the error is re-thrown in DEV.
585 await expect(async () => {
586 await act(() => {
587 store.set({
588 value: 1,
589 throwInGetSnapshot: true,
590 throwInIsEqual: false,
591 });
592 });
593 }).rejects.toThrow('Error in getSnapshot');
594 } else {
595 await act(() => {
596 store.set({
597 value: 1,
598 throwInGetSnapshot: true,
599 throwInIsEqual: false,
600 });
601 });
602 }
603 assertLog(
604 gate(flags => flags.enableUseSyncExternalStoreShim)
605 ? ['Error in getSnapshot']
606 : [
607 'Error in getSnapshot',
608 // In a concurrent root, React renders a second time to attempt to
609 // recover from the error.
610 'Error in getSnapshot',
611 ],
612 );
613 expect(container.textContent).toEqual('Error in getSnapshot');
614 });
615 it('Infinite loop if getSnapshot keeps returning new reference', async () => {
616 const store = createExternalStore({});
617 function App() {
618 const text = useSyncExternalStore(store.subscribe, () => ({}));
619 return React.createElement(Text, {
620 text: JSON.stringify(text),
621 });
622 }
623 const container = document.createElement('div');
624 const root = createRoot(container);
625 await expect(async () => {
626 await act(() => {
627 ReactDOM.flushSync(async () =>
628 root.render(React.createElement(App, null)),
629 );
630 });
631 }).rejects.toThrow(
632 'Maximum update depth exceeded. This can happen when a component repeatedly ' +
633 'calls setState inside componentWillUpdate or componentDidUpdate. React limits ' +
634 'the number of nested updates to prevent infinite loops.',
635 );
636
637 assertConsoleErrorDev(
638 gate(flags => flags.enableUseSyncExternalStoreShim)
639 ? [
640 'The result of getSnapshot should be cached to avoid an infinite loop',
641 'Error: Maximum update depth exceeded. ' +
642 'This can happen when a component repeatedly calls setState inside componentWillUpdate or componentDidUpdate. ' +
643 'React limits the number of nested updates to prevent infinite loops.' +
644 '\n in <stack>',
645 'The above error occurred in the <App> component:\n\n' +
646 ' in App (at **)\n\n' +
647 'Consider adding an error boundary to your tree to customize error handling behavior.\n' +
648 'Visit https://reactjs.org/link/error-boundaries to learn more about error boundaries.',
649 ]
650 : [
651 'The result of getSnapshot should be cached to avoid an infinite loop' +
652 '\n in App (at **)',
653 ],
654 );
655 });
656 it('getSnapshot can return NaN without infinite loop warning', async () => {
657 const store = createExternalStore('not a number');
658 function App() {
659 const value = useSyncExternalStore(store.subscribe, () =>
660 parseInt(store.getState(), 10),
661 );
662 return React.createElement(Text, {
663 text: value,
664 });
665 }
666 const container = document.createElement('div');
667 const root = createRoot(container);
668
669 // Initial render that reads a snapshot of NaN. This is OK because we use
670 // Object.is algorithm to compare values.
671 await act(() => root.render(React.createElement(App, null)));
672 expect(container.textContent).toEqual('NaN');
673 assertLog([NaN]);
674
675 // Update to real number
676 await act(() => store.set(123));
677 expect(container.textContent).toEqual('123');
678 assertLog([123]);
679
680 // Update back to NaN
681 await act(() => store.set('not a number'));
682 expect(container.textContent).toEqual('NaN');
683 assertLog([NaN]);
684 });
685 describe('extra features implemented in user-space', () => {
686 it('memoized selectors are only called once per update', async () => {
687 const store = createExternalStore({
688 a: 0,
689 b: 0,
690 });
691 function selector(state) {
692 Scheduler.log('Selector');
693 return state.a;
694 }
695 function App() {
696 Scheduler.log('App');
697 const a = useSyncExternalStoreWithSelector(
698 store.subscribe,
699 store.getState,
700 null,
701 selector,
702 );
703 return React.createElement(Text, {
704 text: 'A' + a,
705 });
706 }
707 const container = document.createElement('div');
708 const root = createRoot(container);
709 await act(() => root.render(React.createElement(App, null)));
710 assertLog(['App', 'Selector', 'A0']);
711 expect(container.textContent).toEqual('A0');
712
713 // Update the store
714 await act(() => {
715 store.set({
716 a: 1,
717 b: 0,
718 });
719 });
720 assertLog([
721 // The selector runs before React starts rendering
722 'Selector',
723 'App',
724 // And because the selector didn't change during render, we can reuse
725 // the previous result without running the selector again
726 'A1',
727 ]);
728 expect(container.textContent).toEqual('A1');
729 });
730 it('Using isEqual to bailout', async () => {
731 const store = createExternalStore({
732 a: 0,
733 b: 0,
734 });
735 function A() {
736 const {a} = useSyncExternalStoreWithSelector(
737 store.subscribe,
738 store.getState,
739 null,
740 state => ({
741 a: state.a,
742 }),
743 (state1, state2) => state1.a === state2.a,
744 );
745 return React.createElement(Text, {
746 text: 'A' + a,
747 });
748 }
749 function B() {
750 const {b} = useSyncExternalStoreWithSelector(
751 store.subscribe,
752 store.getState,
753 null,
754 state => {
755 return {
756 b: state.b,
757 };
758 },
759 (state1, state2) => state1.b === state2.b,
760 );
761 return React.createElement(Text, {
762 text: 'B' + b,
763 });
764 }
765 function App() {
766 return React.createElement(
767 React.Fragment,
768 null,
769 React.createElement(A, null),
770 React.createElement(B, null),
771 );
772 }
773 const container = document.createElement('div');
774 const root = createRoot(container);
775 await act(() => root.render(React.createElement(App, null)));
776 assertLog(['A0', 'B0']);
777 expect(container.textContent).toEqual('A0B0');
778
779 // Update b but not a
780 await act(() => {
781 store.set({
782 a: 0,
783 b: 1,
784 });
785 });
786 // Only b re-renders
787 assertLog(['B1']);
788 expect(container.textContent).toEqual('A0B1');
789
790 // Update a but not b
791 await act(() => {
792 store.set({
793 a: 1,
794 b: 1,
795 });
796 });
797 // Only a re-renders
798 assertLog(['A1']);
799 expect(container.textContent).toEqual('A1B1');
800 });
801 it('basic server hydration', async () => {
802 const store = createExternalStore('client');
803 const ref = React.createRef();
804 function App() {
805 const text = useSyncExternalStore(
806 store.subscribe,
807 store.getState,
808 () => 'server',
809 );
810 useEffect(() => {
811 Scheduler.log('Passive effect: ' + text);
812 }, [text]);
813 return React.createElement(
814 'div',
815 {
816 ref: ref,
817 },
818 React.createElement(Text, {
819 text: text,
820 }),
821 );
822 }
823 const container = document.createElement('div');
824 container.innerHTML = '<div>server</div>';
825 const serverRenderedDiv = container.getElementsByTagName('div')[0];
826 if (gate(flags => !flags.enableUseSyncExternalStoreShim)) {
827 await act(() => {
828 ReactDOMClient.hydrateRoot(container, React.createElement(App, null));
829 });
830 assertLog([
831 // First it hydrates the server rendered HTML
832 'server',
833 'Passive effect: server',
834 // Then in a second paint, it re-renders with the client state
835 'client',
836 'Passive effect: client',
837 ]);
838 } else {
839 // In the userspace shim, there's no mechanism to detect whether we're
840 // currently hydrating, so `getServerSnapshot` is not called on the
841 // client. To avoid this server mismatch warning, user must account for
842 // this themselves and return the correct value inside `getSnapshot`.
843 await act(() => {
844 ReactDOM.hydrate(React.createElement(App, null), container);
845 });
846 assertConsoleErrorDev([
847 'Warning: Text content did not match. Server: "server" Client: "client"\n' +
848 ' in Text (at **)\n' +
849 ' in div (at **)\n' +
850 ' in App (at **)',
851 ]);
852 assertLog(['client', 'Passive effect: client']);
853 }
854 expect(container.textContent).toEqual('client');
855 expect(ref.current).toEqual(serverRenderedDiv);
856 });
857 });
858 it('regression test for #23150', async () => {
859 const store = createExternalStore('Initial');
860 function App() {
861 const text = useSyncExternalStore(store.subscribe, store.getState);
862 const [derivedText, setDerivedText] = useState(text);
863 useEffect(() => {}, []);
864 if (derivedText !== text.toUpperCase()) {
865 setDerivedText(text.toUpperCase());
866 }
867 return React.createElement(Text, {
868 text: derivedText,
869 });
870 }
871 const container = document.createElement('div');
872 const root = createRoot(container);
873 await act(() => root.render(React.createElement(App, null)));
874 assertLog(['INITIAL']);
875 expect(container.textContent).toEqual('INITIAL');
876 await act(() => {
877 store.set('Updated');
878 });
879 assertLog(['UPDATED']);
880 expect(container.textContent).toEqual('UPDATED');
881 });
882 it('compares selection to rendered selection even if selector changes', async () => {
883 const store = createExternalStore({
884 items: ['A', 'B'],
885 });
886 const shallowEqualArray = (a, b) => {
887 if (a.length !== b.length) {
888 return false;
889 }
890 for (let i = 0; i < a.length; i++) {
891 if (a[i] !== b[i]) {
892 return false;
893 }
894 }
895 return true;
896 };
897 const List = React.memo(({items}) => {
898 return React.createElement(
899 'ul',
900 null,
901 items.map(text =>
902 React.createElement(
903 'li',
904 {
905 key: text,
906 },
907 React.createElement(Text, {
908 key: text,
909 text: text,
910 }),
911 ),
912 ),
913 );
914 });
915 function App({step}) {
916 const inlineSelector = state => {
917 Scheduler.log('Inline selector');
918 return [...state.items, 'C'];
919 };
920 const items = useSyncExternalStoreWithSelector(
921 store.subscribe,
922 store.getState,
923 null,
924 inlineSelector,
925 shallowEqualArray,
926 );
927 return React.createElement(
928 React.Fragment,
929 null,
930 React.createElement(List, {
931 items: items,
932 }),
933 React.createElement(Text, {
934 text: 'Sibling: ' + step,
935 }),
936 );
937 }
938 const container = document.createElement('div');
939 const root = createRoot(container);
940 await act(() => {
941 root.render(
942 React.createElement(App, {
943 step: 0,
944 }),
945 );
946 });
947 assertLog(['Inline selector', 'A', 'B', 'C', 'Sibling: 0']);
948 await act(() => {
949 root.render(
950 React.createElement(App, {
951 step: 1,
952 }),
953 );
954 });
955 assertLog([
956 // We had to call the selector again because it's not memoized
957 'Inline selector',
958 // But because the result was the same (according to isEqual) we can
959 // bail out of rendering the memoized list. These are skipped:
960 // 'A',
961 // 'B',
962 // 'C',
963
964 'Sibling: 1',
965 ]);
966 });
967 describe('selector and isEqual error handling in extra', () => {
968 let ErrorBoundary;
969 beforeEach(() => {
970 ErrorBoundary = class extends React.Component {
971 state = {
972 error: null,
973 };
974 static getDerivedStateFromError(error) {
975 return {
976 error,
977 };
978 }
979 render() {
980 if (this.state.error) {
981 return React.createElement(Text, {
982 text: this.state.error.message,
983 });
984 }
985 return this.props.children;
986 }
987 };
988 });
989 it('selector can throw on update', async () => {
990 const store = createExternalStore({
991 a: 'a',
992 });
993 const selector = state => {
994 if (typeof state.a !== 'string') {
995 throw new TypeError('Malformed state');
996 }
997 return state.a.toUpperCase();
998 };
999 function App() {
1000 const a = useSyncExternalStoreWithSelector(
1001 store.subscribe,
1002 store.getState,
1003 null,
1004 selector,
1005 );
1006 return React.createElement(Text, {
1007 text: a,
1008 });
1009 }
1010 const container = document.createElement('div');
1011 const root = createRoot(container);
1012 await act(() =>
1013 root.render(
1014 React.createElement(
1015 ErrorBoundary,
1016 null,
1017 React.createElement(App, null),
1018 ),
1019 ),
1020 );
1021 assertLog(['A']);
1022 expect(container.textContent).toEqual('A');
1023 if (__DEV__ && gate(flags => flags.enableUseSyncExternalStoreShim)) {
1024 // In 17, the error is re-thrown in DEV.
1025 await expect(async () => {
1026 await act(() => {
1027 store.set({});
1028 });
1029 }).rejects.toThrow('Malformed state');
1030 } else {
1031 await act(() => {
1032 store.set({});
1033 });
1034 }
1035 expect(container.textContent).toEqual('Malformed state');
1036 });
1037 it('isEqual can throw on update', async () => {
1038 const store = createExternalStore({
1039 a: 'A',
1040 });
1041 const selector = state => state.a;
1042 const isEqual = (left, right) => {
1043 if (typeof left.a !== 'string' || typeof right.a !== 'string') {
1044 throw new TypeError('Malformed state');
1045 }
1046 return left.a.trim() === right.a.trim();
1047 };
1048 function App() {
1049 const a = useSyncExternalStoreWithSelector(
1050 store.subscribe,
1051 store.getState,
1052 null,
1053 selector,
1054 isEqual,
1055 );
1056 return React.createElement(Text, {
1057 text: a,
1058 });
1059 }
1060 const container = document.createElement('div');
1061 const root = createRoot(container);
1062 await act(() =>
1063 root.render(
1064 React.createElement(
1065 ErrorBoundary,
1066 null,
1067 React.createElement(App, null),
1068 ),
1069 ),
1070 );
1071 assertLog(['A']);
1072 expect(container.textContent).toEqual('A');
1073 if (__DEV__ && gate(flags => flags.enableUseSyncExternalStoreShim)) {
1074 // In 17, the error is re-thrown in DEV.
1075 await expect(async () => {
1076 await act(() => {
1077 store.set({});
1078 });
1079 }).rejects.toThrow('Malformed state');
1080 } else {
1081 await act(() => {
1082 store.set({});
1083 });
1084 }
1085 expect(container.textContent).toEqual('Malformed state');
1086 });
1087 });
1088 });