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
+
10
+'use strict';
11
+
12
+let useSyncExternalStore;
13
+let useSyncExternalStoreExtra;
14
+let React;
15
+let ReactNoop;
16
+let Scheduler;
17
+let act;
18
+let useState;
19
+let useEffect;
20
+let useLayoutEffect;
21
+
22
+// This tests shared behavior between the built-in and shim implementations of
23
+// of useSyncExternalStore.
24
+describe('Shared useSyncExternalStore behavior (shim and built-in)', () => {
25
+ beforeEach(() => {
26
+ jest.resetModules();
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
+ jest.mock('react', () => {
33
+ // eslint-disable-next-line no-unused-vars
34
+ const {startTransition, ...otherExports} = jest.requireActual('react');
35
+ return otherExports;
36
+ });
37
+
38
+ React = require('react');
39
+ ReactNoop = require('react-noop-renderer');
40
+ Scheduler = require('scheduler');
41
+ useState = React.useState;
42
+ useEffect = React.useEffect;
43
+ useLayoutEffect = React.useLayoutEffect;
44
+
45
+ const internalAct = require('jest-react').act;
46
+
47
+ // The internal act implementation doesn't batch updates by default, since
48
+ // it's mostly used to test concurrent mode. But since these tests run
49
+ // in both concurrent and legacy mode, I'm adding batching here.
50
+ act = cb => internalAct(() => ReactNoop.batchedUpdates(cb));
51
+
52
+ useSyncExternalStore = require('use-sync-external-store')
53
+ .useSyncExternalStore;
54
+ useSyncExternalStoreExtra = require('use-sync-external-store/extra')
55
+ .useSyncExternalStoreExtra;
56
+ });
57
+
58
+ function Text({text}) {
59
+ Scheduler.unstable_yieldValue(text);
60
+ return text;
61
+ }
62
+
63
+ function createRoot(element) {
64
+ // This wrapper function exists so we can test both legacy roots and
65
+ // concurrent roots.
66
+ //
67
+ // TODO: Once the built-in API exists, conditionally test the concurrent
68
+ // root API, too.
69
+ const root = ReactNoop.createLegacyRoot();
70
+ act(() => {
71
+ root.render(element);
72
+ });
73
+ return root;
74
+ }
75
+
76
+ function createExternalStore(initialState) {
77
+ const listeners = new Set();
78
+ let currentState = initialState;
79
+ return {
80
+ set(text) {
81
+ currentState = text;
82
+ ReactNoop.batchedUpdates(() => {
83
+ listeners.forEach(listener => listener());
84
+ });
85
+ },
86
+ subscribe(listener) {
87
+ listeners.add(listener);
88
+ return () => listeners.delete(listener);
89
+ },
90
+ getState() {
91
+ return currentState;
92
+ },
93
+ getSubscriberCount() {
94
+ return listeners.size;
95
+ },
96
+ };
97
+ }
98
+
99
+ test('basic usage', () => {
100
+ const store = createExternalStore('Initial');
101
+
102
+ function App() {
103
+ const text = useSyncExternalStore(store.subscribe, store.getState);
104
+ return <Text text={text} />;
105
+ }
106
+
107
+ const root = createRoot(<App />);
108
+
109
+ expect(Scheduler).toHaveYielded(['Initial']);
110
+ expect(root).toMatchRenderedOutput('Initial');
111
+
112
+ act(() => {
113
+ store.set('Updated');
114
+ });
115
+ expect(Scheduler).toHaveYielded(['Updated']);
116
+ expect(root).toMatchRenderedOutput('Updated');
117
+ });
118
+
119
+ test('skips re-rendering if nothing changes', () => {
120
+ const store = createExternalStore('Initial');
121
+
122
+ function App() {
123
+ const text = useSyncExternalStore(store.subscribe, store.getState);
124
+ return <Text text={text} />;
125
+ }
126
+
127
+ const root = createRoot(<App />);
128
+
129
+ expect(Scheduler).toHaveYielded(['Initial']);
130
+ expect(root).toMatchRenderedOutput('Initial');
131
+
132
+ // Update to the same value
133
+ act(() => {
134
+ store.set('Initial');
135
+ });
136
+ // Should not re-render
137
+ expect(Scheduler).toHaveYielded([]);
138
+ expect(root).toMatchRenderedOutput('Initial');
139
+ });
140
+
141
+ test('switch to a different store', () => {
142
+ const storeA = createExternalStore(0);
143
+ const storeB = createExternalStore(0);
144
+
145
+ let setStore;
146
+ function App() {
147
+ const [store, _setStore] = useState(storeA);
148
+ setStore = _setStore;
149
+ const value = useSyncExternalStore(store.subscribe, store.getState);
150
+ return <Text text={value} />;
151
+ }
152
+
153
+ const root = createRoot(<App />);
154
+
155
+ expect(Scheduler).toHaveYielded([0]);
156
+ expect(root).toMatchRenderedOutput('0');
157
+
158
+ act(() => {
159
+ storeA.set(1);
160
+ });
161
+ expect(Scheduler).toHaveYielded([1]);
162
+ expect(root).toMatchRenderedOutput('1');
163
+
164
+ // Switch stores
165
+ act(() => {
166
+ // This update will be disregarded
167
+ storeA.set(2);
168
+ setStore(storeB);
169
+ });
170
+ // Now reading from B instead of A
171
+ expect(Scheduler).toHaveYielded([0]);
172
+ expect(root).toMatchRenderedOutput('0');
173
+
174
+ // Update A
175
+ act(() => {
176
+ storeA.set(3);
177
+ });
178
+ // Nothing happened, because we're no longer subscribed to A
179
+ expect(Scheduler).toHaveYielded([]);
180
+ expect(root).toMatchRenderedOutput('0');
181
+
182
+ // Update B
183
+ act(() => {
184
+ storeB.set(1);
185
+ });
186
+ expect(Scheduler).toHaveYielded([1]);
187
+ expect(root).toMatchRenderedOutput('1');
188
+ });
189
+
190
+ test('selecting a specific value inside getSnapshot', () => {
191
+ const store = createExternalStore({a: 0, b: 0});
192
+
193
+ function A() {
194
+ const a = useSyncExternalStore(store.subscribe, () => store.getState().a);
195
+ return <Text text={'A' + a} />;
196
+ }
197
+ function B() {
198
+ const b = useSyncExternalStore(store.subscribe, () => store.getState().b);
199
+ return <Text text={'B' + b} />;
200
+ }
201
+
202
+ function App() {
203
+ return (
204
+ <>
205
+ <A />
206
+ <B />
207
+ </>
208
+ );
209
+ }
210
+
211
+ const root = createRoot(<App />);
212
+
213
+ expect(Scheduler).toHaveYielded(['A0', 'B0']);
214
+ expect(root).toMatchRenderedOutput('A0B0');
215
+
216
+ // Update b but not a
217
+ act(() => {
218
+ store.set({a: 0, b: 1});
219
+ });
220
+ // Only b re-renders
221
+ expect(Scheduler).toHaveYielded(['B1']);
222
+ expect(root).toMatchRenderedOutput('A0B1');
223
+
224
+ // Update a but not b
225
+ act(() => {
226
+ store.set({a: 1, b: 1});
227
+ });
228
+ // Only a re-renders
229
+ expect(Scheduler).toHaveYielded(['A1']);
230
+ expect(root).toMatchRenderedOutput('A1B1');
231
+ });
232
+
233
+ test(
234
+ "compares to current state before bailing out, even when there's a " +
235
+ 'mutation in between the sync and passive effects',
236
+ () => {
237
+ const store = createExternalStore(0);
238
+
239
+ function App() {
240
+ const value = useSyncExternalStore(store.subscribe, store.getState);
241
+ useEffect(() => {
242
+ Scheduler.unstable_yieldValue('Passive effect: ' + value);
243
+ }, [value]);
244
+ return <Text text={value} />;
245
+ }
246
+
247
+ const root = createRoot(<App />);
248
+ expect(Scheduler).toHaveYielded([0, 'Passive effect: 0']);
249
+
250
+ // Schedule an update. We'll intentionally not use `act` so that we can
251
+ // insert a mutation before React subscribes to the store in a
252
+ // passive effect.
253
+ store.set(1);
254
+ expect(Scheduler).toHaveYielded([
255
+ 1,
256
+ // Passive effect hasn't fired yet
257
+ ]);
258
+ expect(root).toMatchRenderedOutput('1');
259
+
260
+ // Flip the store state back to the previous value.
261
+ store.set(0);
262
+ expect(Scheduler).toHaveYielded([
263
+ 'Passive effect: 1',
264
+ // Re-render. If the current state were tracked by updating a ref in a
265
+ // passive effect, then this would break because the previous render's
266
+ // passive effect hasn't fired yet, so we'd incorrectly think that
267
+ // the state hasn't changed.
268
+ 0,
269
+ ]);
270
+ // Should flip back to 0
271
+ expect(root).toMatchRenderedOutput('0');
272
+ },
273
+ );
274
+
275
+ test('mutating the store in between render and commit when getSnapshot has changed', () => {
276
+ const store = createExternalStore({a: 1, b: 1});
277
+
278
+ const getSnapshotA = () => store.getState().a;
279
+ const getSnapshotB = () => store.getState().b;
280
+
281
+ function Child1({step}) {
282
+ const value = useSyncExternalStore(store.subscribe, store.getState);
283
+ useLayoutEffect(() => {
284
+ if (step === 1) {
285
+ // Update B in a layout effect. This happens in the same commit
286
+ // that changed the getSnapshot in Child2. Child2's effects haven't
287
+ // fired yet, so it doesn't have access to the latest getSnapshot. So
288
+ // it can't use the getSnapshot to bail out.
289
+ Scheduler.unstable_yieldValue('Update B in commit phase');
290
+ store.set({a: value.a, b: 2});
291
+ }
292
+ }, [step]);
293
+ return null;
294
+ }
295
+
296
+ function Child2({step}) {
297
+ const label = step === 0 ? 'A' : 'B';
298
+ const getSnapshot = step === 0 ? getSnapshotA : getSnapshotB;
299
+ const value = useSyncExternalStore(store.subscribe, getSnapshot);
300
+ return <Text text={label + value} />;
301
+ }
302
+
303
+ let setStep;
304
+ function App() {
305
+ const [step, _setStep] = useState(0);
306
+ setStep = _setStep;
307
+ return (
308
+ <>
309
+ <Child1 step={step} />
310
+ <Child2 step={step} />
311
+ </>
312
+ );
313
+ }
314
+
315
+ const root = createRoot(<App />);
316
+ expect(Scheduler).toHaveYielded(['A1']);
317
+ expect(root).toMatchRenderedOutput('A1');
318
+
319
+ act(() => {
320
+ // Change getSnapshot and update the store in the same batch
321
+ setStep(1);
322
+ });
323
+ expect(Scheduler).toHaveYielded([
324
+ 'B1',
325
+ 'Update B in commit phase',
326
+ // If Child2 had used the old getSnapshot to bail out, then it would have
327
+ // incorrectly bailed out here instead of re-rendering.
328
+ 'B2',
329
+ ]);
330
+ expect(root).toMatchRenderedOutput('B2');
331
+ });
332
+
333
+ test('mutating the store in between render and commit when getSnapshot has _not_ changed', () => {
334
+ // Same as previous test, but `getSnapshot` does not change
335
+ const store = createExternalStore({a: 1, b: 1});
336
+
337
+ const getSnapshotA = () => store.getState().a;
338
+
339
+ function Child1({step}) {
340
+ const value = useSyncExternalStore(store.subscribe, store.getState);
341
+ useLayoutEffect(() => {
342
+ if (step === 1) {
343
+ // Update B in a layout effect. This happens in the same commit
344
+ // that changed the getSnapshot in Child2. Child2's effects haven't
345
+ // fired yet, so it doesn't have access to the latest getSnapshot. So
346
+ // it can't use the getSnapshot to bail out.
347
+ Scheduler.unstable_yieldValue('Update B in commit phase');
348
+ store.set({a: value.a, b: 2});
349
+ }
350
+ }, [step]);
351
+ return null;
352
+ }
353
+
354
+ function Child2({step}) {
355
+ const value = useSyncExternalStore(store.subscribe, getSnapshotA);
356
+ return <Text text={'A' + value} />;
357
+ }
358
+
359
+ let setStep;
360
+ function App() {
361
+ const [step, _setStep] = useState(0);
362
+ setStep = _setStep;
363
+ return (
364
+ <>
365
+ <Child1 step={step} />
366
+ <Child2 step={step} />
367
+ </>
368
+ );
369
+ }
370
+
371
+ const root = createRoot(<App />);
372
+ expect(Scheduler).toHaveYielded(['A1']);
373
+ expect(root).toMatchRenderedOutput('A1');
374
+
375
+ // This will cause a layout effect, and in the layout effect we'll update
376
+ // the store
377
+ act(() => {
378
+ setStep(1);
379
+ });
380
+ expect(Scheduler).toHaveYielded([
381
+ 'A1',
382
+ // This updates B, but since Child2 doesn't subscribe to B, it doesn't
383
+ // need to re-render.
384
+ 'Update B in commit phase',
385
+ // No re-render
386
+ ]);
387
+ expect(root).toMatchRenderedOutput('A1');
388
+ });
389
+
390
+ test("does not bail out if the previous update hasn't finished yet", () => {
391
+ const store = createExternalStore(0);
392
+
393
+ function Child1() {
394
+ const value = useSyncExternalStore(store.subscribe, store.getState);
395
+ useLayoutEffect(() => {
396
+ if (value === 1) {
397
+ Scheduler.unstable_yieldValue('Reset back to 0');
398
+ store.set(0);
399
+ }
400
+ }, [value]);
401
+ return <Text text={value} />;
402
+ }
403
+
404
+ function Child2() {
405
+ const value = useSyncExternalStore(store.subscribe, store.getState);
406
+ return <Text text={value} />;
407
+ }
408
+
409
+ const root = createRoot(
410
+ <>
411
+ <Child1 />
412
+ <Child2 />
413
+ </>,
414
+ );
415
+ expect(Scheduler).toHaveYielded([0, 0]);
416
+ expect(root).toMatchRenderedOutput('00');
417
+
418
+ act(() => {
419
+ store.set(1);
420
+ });
421
+ expect(Scheduler).toHaveYielded([1, 1, 'Reset back to 0', 0, 0]);
422
+ expect(root).toMatchRenderedOutput('00');
423
+ });
424
+
425
+ test('uses the latest getSnapshot, even if it changed in the same batch as a store update', () => {
426
+ const store = createExternalStore({a: 0, b: 0});
427
+
428
+ const getSnapshotA = () => store.getState().a;
429
+ const getSnapshotB = () => store.getState().b;
430
+
431
+ let setGetSnapshot;
432
+ function App() {
433
+ const [getSnapshot, _setGetSnapshot] = useState(() => getSnapshotA);
434
+ setGetSnapshot = _setGetSnapshot;
435
+ const text = useSyncExternalStore(store.subscribe, getSnapshot);
436
+ return <Text text={text} />;
437
+ }
438
+
439
+ const root = createRoot(<App />);
440
+ expect(Scheduler).toHaveYielded([0]);
441
+
442
+ // Update the store and getSnapshot at the same time
443
+ act(() => {
444
+ setGetSnapshot(() => getSnapshotB);
445
+ store.set({a: 1, b: 2});
446
+ });
447
+ // It should read from B instead of A
448
+ expect(Scheduler).toHaveYielded([2]);
449
+ expect(root).toMatchRenderedOutput('2');
450
+ });
451
+
452
+ test('handles errors thrown by getSnapshot or isEqual', () => {
453
+ class ErrorBoundary extends React.Component {
454
+ state = {error: null};
455
+ static getDerivedStateFromError(error) {
456
+ return {error};
457
+ }
458
+ render() {
459
+ if (this.state.error) {
460
+ return <Text text={this.state.error.message} />;
461
+ }
462
+ return this.props.children;
463
+ }
464
+ }
465
+
466
+ const store = createExternalStore({
467
+ value: 0,
468
+ throwInGetSnapshot: false,
469
+ throwInIsEqual: false,
470
+ });
471
+
472
+ function App() {
473
+ const {value} = useSyncExternalStore(
474
+ store.subscribe,
475
+ () => {
476
+ const state = store.getState();
477
+ if (state.throwInGetSnapshot) {
478
+ throw new Error('Error in getSnapshot');
479
+ }
480
+ return state;
481
+ },
482
+ {
483
+ isEqual: (a, b) => {
484
+ if (a.throwInIsEqual || b.throwInIsEqual) {
485
+ throw new Error('Error in isEqual');
486
+ }
487
+ return a.value === b.value;
488
+ },
489
+ },
490
+ );
491
+ return <Text text={value} />;
492
+ }
493
+
494
+ const errorBoundary = React.createRef(null);
495
+ const root = createRoot(
496
+ <ErrorBoundary ref={errorBoundary}>
497
+ <App />
498
+ </ErrorBoundary>,
499
+ );
500
+ expect(Scheduler).toHaveYielded([0]);
501
+ expect(root).toMatchRenderedOutput('0');
502
+
503
+ // Update that throws in a getSnapshot. We can catch it with an error boundary.
504
+ act(() => {
505
+ store.set({value: 1, throwInGetSnapshot: true, throwInIsEqual: false});
506
+ });
507
+ expect(Scheduler).toHaveYielded(['Error in getSnapshot']);
508
+ expect(root).toMatchRenderedOutput('Error in getSnapshot');
509
+
510
+ // Clear the error.
511
+ act(() => {
512
+ store.set({value: 1, throwInGetSnapshot: false, throwInIsEqual: false});
513
+ errorBoundary.current.setState({error: null});
514
+ });
515
+ expect(Scheduler).toHaveYielded([1]);
516
+ expect(root).toMatchRenderedOutput('1');
517
+
518
+ // Update that throws in isEqual. Since isEqual only prevents a bail out,
519
+ // we don't need to surface an error. But we do have to re-render.
520
+ act(() => {
521
+ store.set({value: 1, throwInGetSnapshot: false, throwInIsEqual: true});
522
+ });
523
+ expect(Scheduler).toHaveYielded([1]);
524
+ expect(root).toMatchRenderedOutput('1');
525
+ });
526
+
527
+ describe('extra features implemented in user-space', () => {
528
+ test('memoized selectors are only called once per update', () => {
529
+ const store = createExternalStore({a: 0, b: 0});
530
+
531
+ function selector(state) {
532
+ Scheduler.unstable_yieldValue('Selector');
533
+ return state.a;
534
+ }
535
+
536
+ function App() {
537
+ Scheduler.unstable_yieldValue('App');
538
+ const a = useSyncExternalStoreExtra(
539
+ store.subscribe,
540
+ store.getState,
541
+ selector,
542
+ );
543
+ return <Text text={'A' + a} />;
544
+ }
545
+
546
+ const root = createRoot(<App />);
547
+
548
+ expect(Scheduler).toHaveYielded(['App', 'Selector', 'A0']);
549
+ expect(root).toMatchRenderedOutput('A0');
550
+
551
+ // Update the store
552
+ act(() => {
553
+ store.set({a: 1, b: 0});
554
+ });
555
+ expect(Scheduler).toHaveYielded([
556
+ // The selector runs before React starts rendering
557
+ 'Selector',
558
+ 'App',
559
+ // And because the selector didn't change during render, we can reuse
560
+ // the previous result without running the selector again
561
+ 'A1',
562
+ ]);
563
+ expect(root).toMatchRenderedOutput('A1');
564
+ });
565
+
566
+ test('Using isEqual to bailout', () => {
567
+ const store = createExternalStore({a: 0, b: 0});
568
+
569
+ function A() {
570
+ const {a} = useSyncExternalStoreExtra(
571
+ store.subscribe,
572
+ store.getState,
573
+ state => ({a: state.a}),
574
+ (state1, state2) => state1.a === state2.a,
575
+ );
576
+ return <Text text={'A' + a} />;
577
+ }
578
+ function B() {
579
+ const {b} = useSyncExternalStoreExtra(
580
+ store.subscribe,
581
+ store.getState,
582
+ state => {
583
+ return {b: state.b};
584
+ },
585
+ (state1, state2) => state1.b === state2.b,
586
+ );
587
+ return <Text text={'B' + b} />;
588
+ }
589
+
590
+ function App() {
591
+ return (
592
+ <>
593
+ <A />
594
+ <B />
595
+ </>
596
+ );
597
+ }
598
+
599
+ const root = createRoot(<App />);
600
+
601
+ expect(Scheduler).toHaveYielded(['A0', 'B0']);
602
+ expect(root).toMatchRenderedOutput('A0B0');
603
+
604
+ // Update b but not a
605
+ act(() => {
606
+ store.set({a: 0, b: 1});
607
+ });
608
+ // Only b re-renders
609
+ expect(Scheduler).toHaveYielded(['B1']);
610
+ expect(root).toMatchRenderedOutput('A0B1');
611
+
612
+ // Update a but not b
613
+ act(() => {
614
+ store.set({a: 1, b: 1});
615
+ });
616
+ // Only a re-renders
617
+ expect(Scheduler).toHaveYielded(['A1']);
618
+ expect(root).toMatchRenderedOutput('A1B1');
619
+ });
620
+ });
621
+});