3
let Scheduler;
4
let useState;
5
let useContext;
6
+let Suspense;
7
+let SuspenseList;
8
+let getCacheForType;
9
+let caches;
10
+let seededCache;
11
12
describe('ReactLazyContextPropagation', () => {
13
beforeEach(() => {
18
Scheduler = require('scheduler');
19
useState = React.useState;
20
useContext = React.useContext;
21
+ Suspense = React.Suspense;
22
+ SuspenseList = React.unstable_SuspenseList;
23
+
24
+ getCacheForType = React.unstable_getCacheForType;
25
+
26
+ caches = [];
27
+ seededCache = null;
28
});
29
30
+ // NOTE: These tests are not specific to the lazy propagation (as opposed to
31
+ // eager propagation). The behavior should be the same in both
32
+ // implementations. These are tests that are more relevant to the lazy
33
+ // propagation implementation, though.
34
+
35
+ function createTextCache() {
36
+ if (seededCache !== null) {
37
+ // Trick to seed a cache before it exists.
38
+ // TODO: Need a built-in API to seed data before the initial render (i.e.
39
+ // not a refresh because nothing has mounted yet).
40
+ const cache = seededCache;
41
+ seededCache = null;
42
+ return cache;
43
+ }
44
+
45
+ const data = new Map();
46
+ const version = caches.length + 1;
47
+ const cache = {
48
+ version,
49
+ data,
50
+ resolve(text) {
51
+ const record = data.get(text);
52
+ if (record === undefined) {
53
+ const newRecord = {
54
+ status: 'resolved',
55
+ value: text,
56
+ };
57
+ data.set(text, newRecord);
58
+ } else if (record.status === 'pending') {
59
+ const thenable = record.value;
60
+ record.status = 'resolved';
61
+ record.value = text;
62
+ thenable.pings.forEach(t => t());
63
+ }
64
+ },
65
+ reject(text, error) {
66
+ const record = data.get(text);
67
+ if (record === undefined) {
68
+ const newRecord = {
69
+ status: 'rejected',
70
+ value: error,
71
+ };
72
+ data.set(text, newRecord);
73
+ } else if (record.status === 'pending') {
74
+ const thenable = record.value;
75
+ record.status = 'rejected';
76
+ record.value = error;
77
+ thenable.pings.forEach(t => t());
78
+ }
79
+ },
80
+ };
81
+ caches.push(cache);
82
+ return cache;
83
+ }
84
+
85
+ function readText(text) {
86
+ const textCache = getCacheForType(createTextCache);
87
+ const record = textCache.data.get(text);
88
+ if (record !== undefined) {
89
+ switch (record.status) {
90
+ case 'pending':
91
+ Scheduler.unstable_yieldValue(`Suspend! [${text}]`);
92
+ throw record.value;
93
+ case 'rejected':
94
+ Scheduler.unstable_yieldValue(`Error! [${text}]`);
95
+ throw record.value;
96
+ case 'resolved':
97
+ return textCache.version;
98
+ }
99
+ } else {
100
+ Scheduler.unstable_yieldValue(`Suspend! [${text}]`);
101
+
102
+ const thenable = {
103
+ pings: [],
104
+ then(resolve) {
105
+ if (newRecord.status === 'pending') {
106
+ thenable.pings.push(resolve);
107
+ } else {
108
+ Promise.resolve().then(() => resolve(newRecord.value));
109
+ }
110
+ },
111
+ };
112
+
113
+ const newRecord = {
114
+ status: 'pending',
115
+ value: thenable,
116
+ };
117
+ textCache.data.set(text, newRecord);
118
+
119
+ throw thenable;
120
+ }
121
+ }
122
+
123
function Text({text}) {
124
Scheduler.unstable_yieldValue(text);
125
return text;
126
}
127
23
- // NOTE: These tests are not specific to the lazy propagation (as opposed to
24
- // eager propagation). The behavior should be the same in both
25
- // implementations. These are tests that are more relevant to the lazy
26
- // propagation implementation, though.
128
+ // function AsyncText({text, showVersion}) {
129
+ // const version = readText(text);
130
+ // const fullText = showVersion ? `${text} [v${version}]` : text;
131
+ // Scheduler.unstable_yieldValue(fullText);
132
+ // return text;
133
+ // }
134
+
135
+ function seedNextTextCache(text) {
136
+ if (seededCache === null) {
137
+ seededCache = createTextCache();
138
+ }
139
+ seededCache.resolve(text);
140
+ }
141
+
142
+ function resolveMostRecentTextCache(text) {
143
+ if (caches.length === 0) {
144
+ throw Error('Cache does not exist.');
145
+ } else {
146
+ // Resolve the most recently created cache. An older cache can by
147
+ // resolved with `caches[index].resolve(text)`.
148
+ caches[caches.length - 1].resolve(text);
149
+ }
150
+ }
151
+
152
+ const resolveText = resolveMostRecentTextCache;
153
+
154
+ // function rejectMostRecentTextCache(text, error) {
155
+ // if (caches.length === 0) {
156
+ // throw Error('Cache does not exist.');
157
+ // } else {
158
+ // // Resolve the most recently created cache. An older cache can by
159
+ // // resolved with `caches[index].reject(text, error)`.
160
+ // caches[caches.length - 1].reject(text, error);
161
+ // }
162
+ // }
163
164
test(
165
'context change should prevent bailout of memoized component (useMemo -> ' +
339
expect(Scheduler).toHaveYielded(['Consumer']);
340
expect(root).toMatchRenderedOutput('0');
341
});
342
+
343
+ // @gate enableCache
344
+ test('context is propagated across retries', async () => {
345
+ const root = ReactNoop.createRoot();
346
+
347
+ const Context = React.createContext('A');
348
+
349
+ let setContext;
350
+ function App() {
351
+ const [value, setValue] = useState('A');
352
+ setContext = setValue;
353
+ return (
354
+ <Context.Provider value={value}>
355
+ <Suspense fallback={<Text text="Loading..." />}>
356
+ <Async />
357
+ </Suspense>
358
+ <Text text={value} />
359
+ </Context.Provider>
360
+ );
361
+ }
362
+
363
+ function Async() {
364
+ const value = useContext(Context);
365
+ readText(value);
366
+
367
+ // When `readText` suspends, we haven't yet visited Indirection and all
368
+ // of its children. They won't get rendered until a later retry.
369
+ return <Indirection />;
370
+ }
371
+
372
+ const Indirection = React.memo(() => {
373
+ // This child must always be consistent with the sibling Text component.
374
+ return <DeepChild />;
375
+ });
376
+
377
+ function DeepChild() {
378
+ const value = useContext(Context);
379
+ return <Text text={value} />;
380
+ }
381
+
382
+ await seedNextTextCache('A');
383
+ await ReactNoop.act(async () => {
384
+ root.render(<App />);
385
+ });
386
+ expect(Scheduler).toHaveYielded(['A', 'A']);
387
+ expect(root).toMatchRenderedOutput('AA');
388
+
389
+ await ReactNoop.act(async () => {
390
+ // Intentionally not wrapping in startTransition, so that the fallback
391
+ // the fallback displays despite this being a refresh.
392
+ setContext('B');
393
+ });
394
+ expect(Scheduler).toHaveYielded(['Suspend! [B]', 'Loading...', 'B']);
395
+ expect(root).toMatchRenderedOutput('Loading...B');
396
+
397
+ await ReactNoop.act(async () => {
398
+ await resolveText('B');
399
+ });
400
+ expect(Scheduler).toHaveYielded(['B']);
401
+ expect(root).toMatchRenderedOutput('BB');
402
+ });
403
+
404
+ // @gate enableCache
405
+ test('multiple contexts are propagated across retries', async () => {
406
+ // Same as previous test, but with multiple context providers
407
+ const root = ReactNoop.createRoot();
408
+
409
+ const Context1 = React.createContext('A');
410
+ const Context2 = React.createContext('A');
411
+
412
+ let setContext;
413
+ function App() {
414
+ const [value, setValue] = useState('A');
415
+ setContext = setValue;
416
+ return (
417
+ <Context1.Provider value={value}>
418
+ <Context2.Provider value={value}>
419
+ <Suspense fallback={<Text text="Loading..." />}>
420
+ <Async />
421
+ </Suspense>
422
+ <Text text={value} />
423
+ </Context2.Provider>
424
+ </Context1.Provider>
425
+ );
426
+ }
427
+
428
+ function Async() {
429
+ const value = useContext(Context1);
430
+ readText(value);
431
+
432
+ // When `readText` suspends, we haven't yet visited Indirection and all
433
+ // of its children. They won't get rendered until a later retry.
434
+ return (
435
+ <>
436
+ <Indirection1 />
437
+ <Indirection2 />
438
+ </>
439
+ );
440
+ }
441
+
442
+ const Indirection1 = React.memo(() => {
443
+ // This child must always be consistent with the sibling Text component.
444
+ return <DeepChild1 />;
445
+ });
446
+
447
+ const Indirection2 = React.memo(() => {
448
+ // This child must always be consistent with the sibling Text component.
449
+ return <DeepChild2 />;
450
+ });
451
+
452
+ function DeepChild1() {
453
+ const value = useContext(Context1);
454
+ return <Text text={value} />;
455
+ }
456
+
457
+ function DeepChild2() {
458
+ const value = useContext(Context2);
459
+ return <Text text={value} />;
460
+ }
461
+
462
+ await seedNextTextCache('A');
463
+ await ReactNoop.act(async () => {
464
+ root.render(<App />);
465
+ });
466
+ expect(Scheduler).toHaveYielded(['A', 'A', 'A']);
467
+ expect(root).toMatchRenderedOutput('AAA');
468
+
469
+ await ReactNoop.act(async () => {
470
+ // Intentionally not wrapping in startTransition, so that the fallback
471
+ // the fallback displays despite this being a refresh.
472
+ setContext('B');
473
+ });
474
+ expect(Scheduler).toHaveYielded(['Suspend! [B]', 'Loading...', 'B']);
475
+ expect(root).toMatchRenderedOutput('Loading...B');
476
+
477
+ await ReactNoop.act(async () => {
478
+ await resolveText('B');
479
+ });
480
+ expect(Scheduler).toHaveYielded(['B', 'B']);
481
+ expect(root).toMatchRenderedOutput('BBB');
482
+ });
483
+
484
+ // @gate enableCache
485
+ test('context is propagated across retries (legacy)', async () => {
486
+ const root = ReactNoop.createLegacyRoot();
487
+
488
+ const Context = React.createContext('A');
489
+
490
+ let setContext;
491
+ function App() {
492
+ const [value, setValue] = useState('A');
493
+ setContext = setValue;
494
+ return (
495
+ <Context.Provider value={value}>
496
+ <Suspense fallback={<Text text="Loading..." />}>
497
+ <Async />
498
+ </Suspense>
499
+ <Text text={value} />
500
+ </Context.Provider>
501
+ );
502
+ }
503
+
504
+ function Async() {
505
+ const value = useContext(Context);
506
+ readText(value);
507
+
508
+ // When `readText` suspends, we haven't yet visited Indirection and all
509
+ // of its children. They won't get rendered until a later retry.
510
+ return <Indirection />;
511
+ }
512
+
513
+ const Indirection = React.memo(() => {
514
+ // This child must always be consistent with the sibling Text component.
515
+ return <DeepChild />;
516
+ });
517
+
518
+ function DeepChild() {
519
+ const value = useContext(Context);
520
+ return <Text text={value} />;
521
+ }
522
+
523
+ await seedNextTextCache('A');
524
+ await ReactNoop.act(async () => {
525
+ root.render(<App />);
526
+ });
527
+ expect(Scheduler).toHaveYielded(['A', 'A']);
528
+ expect(root).toMatchRenderedOutput('AA');
529
+
530
+ await ReactNoop.act(async () => {
531
+ // Intentionally not wrapping in startTransition, so that the fallback
532
+ // the fallback displays despite this being a refresh.
533
+ setContext('B');
534
+ });
535
+ expect(Scheduler).toHaveYielded(['Suspend! [B]', 'Loading...', 'B']);
536
+ expect(root).toMatchRenderedOutput('Loading...B');
537
+
538
+ await ReactNoop.act(async () => {
539
+ await resolveText('B');
540
+ });
541
+ expect(Scheduler).toHaveYielded(['B']);
542
+ expect(root).toMatchRenderedOutput('BB');
543
+ });
544
+
545
+ // @gate experimental
546
+ test('context is propagated through offscreen trees', async () => {
547
+ const LegacyHidden = React.unstable_LegacyHidden;
548
+
549
+ const root = ReactNoop.createRoot();
550
+
551
+ const Context = React.createContext('A');
552
+
553
+ let setContext;
554
+ function App() {
555
+ const [value, setValue] = useState('A');
556
+ setContext = setValue;
557
+ return (
558
+ <Context.Provider value={value}>
559
+ <LegacyHidden mode="hidden">
560
+ <Indirection />
561
+ </LegacyHidden>
562
+ <Text text={value} />
563
+ </Context.Provider>
564
+ );
565
+ }
566
+
567
+ const Indirection = React.memo(() => {
568
+ // This child must always be consistent with the sibling Text component.
569
+ return <DeepChild />;
570
+ });
571
+
572
+ function DeepChild() {
573
+ const value = useContext(Context);
574
+ return <Text text={value} />;
575
+ }
576
+
577
+ await seedNextTextCache('A');
578
+ await ReactNoop.act(async () => {
579
+ root.render(<App />);
580
+ });
581
+ expect(Scheduler).toHaveYielded(['A', 'A']);
582
+ expect(root).toMatchRenderedOutput('AA');
583
+
584
+ await ReactNoop.act(async () => {
585
+ setContext('B');
586
+ });
587
+ expect(Scheduler).toHaveYielded(['B', 'B']);
588
+ expect(root).toMatchRenderedOutput('BB');
589
+ });
590
+
591
+ // @gate experimental
592
+ test('multiple contexts are propagated across through offscreen trees', async () => {
593
+ // Same as previous test, but with multiple context providers
594
+ const LegacyHidden = React.unstable_LegacyHidden;
595
+
596
+ const root = ReactNoop.createRoot();
597
+
598
+ const Context1 = React.createContext('A');
599
+ const Context2 = React.createContext('A');
600
+
601
+ let setContext;
602
+ function App() {
603
+ const [value, setValue] = useState('A');
604
+ setContext = setValue;
605
+ return (
606
+ <Context1.Provider value={value}>
607
+ <Context2.Provider value={value}>
608
+ <LegacyHidden mode="hidden">
609
+ <Indirection1 />
610
+ <Indirection2 />
611
+ </LegacyHidden>
612
+ <Text text={value} />
613
+ </Context2.Provider>
614
+ </Context1.Provider>
615
+ );
616
+ }
617
+
618
+ const Indirection1 = React.memo(() => {
619
+ // This child must always be consistent with the sibling Text component.
620
+ return <DeepChild1 />;
621
+ });
622
+
623
+ const Indirection2 = React.memo(() => {
624
+ // This child must always be consistent with the sibling Text component.
625
+ return <DeepChild2 />;
626
+ });
627
+
628
+ function DeepChild1() {
629
+ const value = useContext(Context1);
630
+ return <Text text={value} />;
631
+ }
632
+
633
+ function DeepChild2() {
634
+ const value = useContext(Context2);
635
+ return <Text text={value} />;
636
+ }
637
+
638
+ await seedNextTextCache('A');
639
+ await ReactNoop.act(async () => {
640
+ root.render(<App />);
641
+ });
642
+ expect(Scheduler).toHaveYielded(['A', 'A', 'A']);
643
+ expect(root).toMatchRenderedOutput('AAA');
644
+
645
+ await ReactNoop.act(async () => {
646
+ setContext('B');
647
+ });
648
+ expect(Scheduler).toHaveYielded(['B', 'B', 'B']);
649
+ expect(root).toMatchRenderedOutput('BBB');
650
+ });
651
+
652
+ // @gate enableCache
653
+ // @gate experimental
654
+ test('contexts are propagated through SuspenseList', async () => {
655
+ // This kinda tests an implementation detail. SuspenseList has an early
656
+ // bailout that doesn't use `bailoutOnAlreadyFinishedWork`. It probably
657
+ // should just use that function, though.
658
+ const Context = React.createContext('A');
659
+
660
+ let setContext;
661
+ function App() {
662
+ const [value, setValue] = useState('A');
663
+ setContext = setValue;
664
+ const children = React.useMemo(
665
+ () => (
666
+ <SuspenseList revealOrder="forwards">
667
+ <Child />
668
+ <Child />
669
+ </SuspenseList>
670
+ ),
671
+ [],
672
+ );
673
+ return <Context.Provider value={value}>{children}</Context.Provider>;
674
+ }
675
+
676
+ function Child() {
677
+ const value = useContext(Context);
678
+ return <Text text={value} />;
679
+ }
680
+
681
+ const root = ReactNoop.createRoot();
682
+ await ReactNoop.act(async () => {
683
+ root.render(<App />);
684
+ });
685
+ expect(Scheduler).toHaveYielded(['A', 'A']);
686
+ expect(root).toMatchRenderedOutput('AA');
687
+
688
+ await ReactNoop.act(async () => {
689
+ setContext('B');
690
+ });
691
+ expect(Scheduler).toHaveYielded(['B', 'B']);
692
+ expect(root).toMatchRenderedOutput('BB');
693
+ });
694
+
695
+ test('nested bailouts', async () => {
696
+ // Lazy context propagation will stop propagating when it hits the first
697
+ // match. If we bail out again inside that tree, we must resume propagating.
698
+
699
+ const Context = React.createContext('A');
700
+
701
+ let setContext;
702
+ function App() {
703
+ const [value, setValue] = useState('A');
704
+ setContext = setValue;
705
+ return (
706
+ <Context.Provider value={value}>
707
+ <ChildIndirection />
708
+ </Context.Provider>
709
+ );
710
+ }
711
+
712
+ const ChildIndirection = React.memo(() => {
713
+ return <Child />;
714
+ });
715
+
716
+ function Child() {
717
+ const value = useContext(Context);
718
+ return (
719
+ <>
720
+ <Text text={value} />
721
+ <DeepChildIndirection />
722
+ </>
723
+ );
724
+ }
725
+
726
+ const DeepChildIndirection = React.memo(() => {
727
+ return <DeepChild />;
728
+ });
729
+
730
+ function DeepChild() {
731
+ const value = useContext(Context);
732
+ return <Text text={value} />;
733
+ }
734
+
735
+ const root = ReactNoop.createRoot();
736
+ await ReactNoop.act(async () => {
737
+ root.render(<App />);
738
+ });
739
+ expect(Scheduler).toHaveYielded(['A', 'A']);
740
+ expect(root).toMatchRenderedOutput('AA');
741
+
742
+ await ReactNoop.act(async () => {
743
+ setContext('B');
744
+ });
745
+ expect(Scheduler).toHaveYielded(['B', 'B']);
746
+ expect(root).toMatchRenderedOutput('BB');
747
+ });
748
+
749
+ // @gate experimental
750
+ // @gate enableCache
751
+ test('nested bailouts across retries', async () => {
752
+ // Lazy context propagation will stop propagating when it hits the first
753
+ // match. If we bail out again inside that tree, we must resume propagating.
754
+
755
+ const Context = React.createContext('A');
756
+
757
+ let setContext;
758
+ function App() {
759
+ const [value, setValue] = useState('A');
760
+ setContext = setValue;
761
+ return (
762
+ <Context.Provider value={value}>
763
+ <Suspense fallback={<Text text="Loading..." />}>
764
+ <Async value={value} />
765
+ </Suspense>
766
+ </Context.Provider>
767
+ );
768
+ }
769
+
770
+ function Async({value}) {
771
+ // When this suspends, we won't be able to visit its children during the
772
+ // current render. So we must take extra care to propagate the context
773
+ // change in such a way that they're aren't lost when we retry in a
774
+ // later render.
775
+ readText(value);
776
+ return <Child value={value} />;
777
+ }
778
+
779
+ function Child() {
780
+ const value = useContext(Context);
781
+ return (
782
+ <>
783
+ <Text text={value} />
784
+ <DeepChildIndirection />
785
+ </>
786
+ );
787
+ }
788
+
789
+ const DeepChildIndirection = React.memo(() => {
790
+ return <DeepChild />;
791
+ });
792
+
793
+ function DeepChild() {
794
+ const value = useContext(Context);
795
+ return <Text text={value} />;
796
+ }
797
+
798
+ const root = ReactNoop.createRoot();
799
+ await seedNextTextCache('A');
800
+ await ReactNoop.act(async () => {
801
+ root.render(<App />);
802
+ });
803
+ expect(Scheduler).toHaveYielded(['A', 'A']);
804
+ expect(root).toMatchRenderedOutput('AA');
805
+
806
+ await ReactNoop.act(async () => {
807
+ setContext('B');
808
+ });
809
+ expect(Scheduler).toHaveYielded(['Suspend! [B]', 'Loading...']);
810
+ expect(root).toMatchRenderedOutput('Loading...');
811
+
812
+ await ReactNoop.act(async () => {
813
+ await resolveText('B');
814
+ });
815
+ expect(Scheduler).toHaveYielded(['B', 'B']);
816
+ expect(root).toMatchRenderedOutput('BB');
817
+ });
818
+
819
+ // @gate experimental
820
+ test('nested bailouts through offscreen trees', async () => {
821
+ // Lazy context propagation will stop propagating when it hits the first
822
+ // match. If we bail out again inside that tree, we must resume propagating.
823
+
824
+ const LegacyHidden = React.unstable_LegacyHidden;
825
+
826
+ const Context = React.createContext('A');
827
+
828
+ let setContext;
829
+ function App() {
830
+ const [value, setValue] = useState('A');
831
+ setContext = setValue;
832
+ return (
833
+ <Context.Provider value={value}>
834
+ <LegacyHidden mode="hidden">
835
+ <Child />
836
+ </LegacyHidden>
837
+ </Context.Provider>
838
+ );
839
+ }
840
+
841
+ function Child() {
842
+ const value = useContext(Context);
843
+ return (
844
+ <>
845
+ <Text text={value} />
846
+ <DeepChildIndirection />
847
+ </>
848
+ );
849
+ }
850
+
851
+ const DeepChildIndirection = React.memo(() => {
852
+ return <DeepChild />;
853
+ });
854
+
855
+ function DeepChild() {
856
+ const value = useContext(Context);
857
+ return <Text text={value} />;
858
+ }
859
+
860
+ const root = ReactNoop.createRoot();
861
+ await ReactNoop.act(async () => {
862
+ root.render(<App />);
863
+ });
864
+ expect(Scheduler).toHaveYielded(['A', 'A']);
865
+ expect(root).toMatchRenderedOutput('AA');
866
+
867
+ await ReactNoop.act(async () => {
868
+ setContext('B');
869
+ });
870
+ expect(Scheduler).toHaveYielded(['B', 'B']);
871
+ expect(root).toMatchRenderedOutput('BB');
872
+ });
873
+
874
+ test('finds context consumers in multiple sibling branches', async () => {
875
+ // This test confirms that when we find a matching context consumer during
876
+ // propagation, we continue propagating to its sibling branches.
877
+
878
+ const Context = React.createContext('A');
879
+
880
+ let setContext;
881
+ function App() {
882
+ const [value, setValue] = useState('A');
883
+ setContext = setValue;
884
+ return (
885
+ <Context.Provider value={value}>
886
+ <Blah />
887
+ </Context.Provider>
888
+ );
889
+ }
890
+
891
+ const Blah = React.memo(() => {
892
+ return (
893
+ <>
894
+ <Indirection />
895
+ <Indirection />
896
+ </>
897
+ );
898
+ });
899
+
900
+ const Indirection = React.memo(() => {
901
+ return <Child />;
902
+ });
903
+
904
+ function Child() {
905
+ const value = useContext(Context);
906
+ return <Text text={value} />;
907
+ }
908
+
909
+ const root = ReactNoop.createRoot();
910
+ await ReactNoop.act(async () => {
911
+ root.render(<App />);
912
+ });
913
+ expect(Scheduler).toHaveYielded(['A', 'A']);
914
+ expect(root).toMatchRenderedOutput('AA');
915
+
916
+ await ReactNoop.act(async () => {
917
+ setContext('B');
918
+ });
919
+ expect(Scheduler).toHaveYielded(['B', 'B']);
920
+ expect(root).toMatchRenderedOutput('BB');
921
+ });
922
});