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
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
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;
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;
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
102
return {
103
set(text) {
104
currentState = text;
104
- ReactNoop.batchedUpdates(() => {
105
+ ReactDOM.unstable_batchedUpdates(() => {
106
listeners.forEach(listener => listener());
107
});
108
},
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', () => {
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(() => {
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 () => {
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);
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(() => {
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 () => {
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(() => {
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(() => {
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',
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
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);
306
0,
307
]);
308
// Should flip back to 0
303
- expect(root).toMatchRenderedOutput('0');
309
+ expect(container.textContent).toEqual('0');
310
},
311
);
312
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
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', () => {
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
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 () => {
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
<>
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', () => {
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 () => {
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}>
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
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', () => {
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', () => {
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(() => {
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
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(() => {
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(() => {
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
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
});