main
js 1,116 lines 30.1 KB
Raw
1 let React;
2 let ReactNoop;
3 let Scheduler;
4 let act;
5 let use;
6 let useState;
7 let useContext;
8 let Suspense;
9 let SuspenseList;
10 let getCacheForType;
11 let caches;
12 let seededCache;
13 let assertLog;
14
15 describe('ReactLazyContextPropagation', () => {
16 beforeEach(() => {
17 jest.resetModules();
18
19 React = require('react');
20 ReactNoop = require('react-noop-renderer');
21 Scheduler = require('scheduler');
22 act = require('internal-test-utils').act;
23 use = React.use;
24 useState = React.useState;
25 useContext = React.useContext;
26 Suspense = React.Suspense;
27 if (gate(flags => flags.enableSuspenseList)) {
28 SuspenseList = React.unstable_SuspenseList;
29 }
30
31 const InternalTestUtils = require('internal-test-utils');
32 assertLog = InternalTestUtils.assertLog;
33
34 getCacheForType = React.unstable_getCacheForType;
35
36 caches = [];
37 seededCache = null;
38 });
39
40 function createTextCache() {
41 if (seededCache !== null) {
42 // Trick to seed a cache before it exists.
43 // TODO: Need a built-in API to seed data before the initial render (i.e.
44 // not a refresh because nothing has mounted yet).
45 const cache = seededCache;
46 seededCache = null;
47 return cache;
48 }
49
50 const data = new Map();
51 const version = caches.length + 1;
52 const cache = {
53 version,
54 data,
55 resolve(text) {
56 const record = data.get(text);
57 if (record === undefined) {
58 const newRecord = {
59 status: 'resolved',
60 value: text,
61 };
62 data.set(text, newRecord);
63 } else if (record.status === 'pending') {
64 const thenable = record.value;
65 record.status = 'resolved';
66 record.value = text;
67 thenable.pings.forEach(t => t());
68 }
69 },
70 reject(text, error) {
71 const record = data.get(text);
72 if (record === undefined) {
73 const newRecord = {
74 status: 'rejected',
75 value: error,
76 };
77 data.set(text, newRecord);
78 } else if (record.status === 'pending') {
79 const thenable = record.value;
80 record.status = 'rejected';
81 record.value = error;
82 thenable.pings.forEach(t => t());
83 }
84 },
85 };
86 caches.push(cache);
87 return cache;
88 }
89
90 function readText(text) {
91 const textCache = getCacheForType(createTextCache);
92 const record = textCache.data.get(text);
93 if (record !== undefined) {
94 switch (record.status) {
95 case 'pending':
96 Scheduler.log(`Suspend! [${text}]`);
97 throw record.value;
98 case 'rejected':
99 Scheduler.log(`Error! [${text}]`);
100 throw record.value;
101 case 'resolved':
102 return textCache.version;
103 }
104 } else {
105 Scheduler.log(`Suspend! [${text}]`);
106
107 const thenable = {
108 pings: [],
109 then(resolve) {
110 if (newRecord.status === 'pending') {
111 thenable.pings.push(resolve);
112 } else {
113 Promise.resolve().then(() => resolve(newRecord.value));
114 }
115 },
116 };
117
118 const newRecord = {
119 status: 'pending',
120 value: thenable,
121 };
122 textCache.data.set(text, newRecord);
123
124 throw thenable;
125 }
126 }
127
128 function Text({text}) {
129 Scheduler.log(text);
130 return text;
131 }
132
133 // function AsyncText({text, showVersion}) {
134 // const version = readText(text);
135 // const fullText = showVersion ? `${text} [v${version}]` : text;
136 // Scheduler.log(fullText);
137 // return text;
138 // }
139
140 function seedNextTextCache(text) {
141 if (seededCache === null) {
142 seededCache = createTextCache();
143 }
144 seededCache.resolve(text);
145 }
146
147 function resolveMostRecentTextCache(text) {
148 if (caches.length === 0) {
149 throw Error('Cache does not exist.');
150 } else {
151 // Resolve the most recently created cache. An older cache can by
152 // resolved with `caches[index].resolve(text)`.
153 caches[caches.length - 1].resolve(text);
154 }
155 }
156
157 const resolveText = resolveMostRecentTextCache;
158
159 // function rejectMostRecentTextCache(text, error) {
160 // if (caches.length === 0) {
161 // throw Error('Cache does not exist.');
162 // } else {
163 // // Resolve the most recently created cache. An older cache can by
164 // // resolved with `caches[index].reject(text, error)`.
165 // caches[caches.length - 1].reject(text, error);
166 // }
167 // }
168
169 it(
170 'context change should prevent bailout of memoized component (useMemo -> ' +
171 'no intermediate fiber)',
172 async () => {
173 const root = ReactNoop.createRoot();
174
175 const Context = React.createContext(0);
176
177 let setValue;
178 function App() {
179 const [value, _setValue] = useState(0);
180 setValue = _setValue;
181
182 // NOTE: It's an important part of this test that we're memoizing the
183 // props of the Consumer component, as opposed to wrapping in an
184 // additional memoized fiber, because the implementation propagates
185 // context changes whenever a fiber bails out.
186 const consumer = React.useMemo(() => <Consumer />, []);
187
188 return <Context.Provider value={value}>{consumer}</Context.Provider>;
189 }
190
191 function Consumer() {
192 const value = useContext(Context);
193 // Even though Consumer is memoized, Consumer should re-render
194 // DeepChild whenever the context value changes. Otherwise DeepChild
195 // won't receive the new value.
196 return <DeepChild value={value} />;
197 }
198
199 function DeepChild({value}) {
200 return <Text text={value} />;
201 }
202
203 await act(() => {
204 root.render(<App />);
205 });
206 assertLog([0]);
207 expect(root).toMatchRenderedOutput('0');
208
209 await act(() => {
210 setValue(1);
211 });
212 assertLog([1]);
213 expect(root).toMatchRenderedOutput('1');
214 },
215 );
216
217 it('context change should prevent bailout of memoized component (memo HOC)', async () => {
218 const root = ReactNoop.createRoot();
219
220 const Context = React.createContext(0);
221
222 let setValue;
223 function App() {
224 const [value, _setValue] = useState(0);
225 setValue = _setValue;
226 return (
227 <Context.Provider value={value}>
228 <Consumer />
229 </Context.Provider>
230 );
231 }
232
233 const Consumer = React.memo(() => {
234 const value = useContext(Context);
235 // Even though Consumer is memoized, Consumer should re-render
236 // DeepChild whenever the context value changes. Otherwise DeepChild
237 // won't receive the new value.
238 return <DeepChild value={value} />;
239 });
240
241 function DeepChild({value}) {
242 return <Text text={value} />;
243 }
244
245 await act(() => {
246 root.render(<App />);
247 });
248 assertLog([0]);
249 expect(root).toMatchRenderedOutput('0');
250
251 await act(() => {
252 setValue(1);
253 });
254 assertLog([1]);
255 expect(root).toMatchRenderedOutput('1');
256 });
257
258 it('context change should prevent bailout of memoized component (PureComponent)', async () => {
259 const root = ReactNoop.createRoot();
260
261 const Context = React.createContext(0);
262
263 let setValue;
264 function App() {
265 const [value, _setValue] = useState(0);
266 setValue = _setValue;
267 return (
268 <Context.Provider value={value}>
269 <Consumer />
270 </Context.Provider>
271 );
272 }
273
274 class Consumer extends React.PureComponent {
275 static contextType = Context;
276 render() {
277 // Even though Consumer is memoized, Consumer should re-render
278 // DeepChild whenever the context value changes. Otherwise DeepChild
279 // won't receive the new value.
280 return <DeepChild value={this.context} />;
281 }
282 }
283
284 function DeepChild({value}) {
285 return <Text text={value} />;
286 }
287
288 await act(() => {
289 root.render(<App />);
290 });
291 assertLog([0]);
292 expect(root).toMatchRenderedOutput('0');
293
294 await act(() => {
295 setValue(1);
296 });
297 assertLog([1]);
298 expect(root).toMatchRenderedOutput('1');
299 });
300
301 it("context consumer bails out if context hasn't changed", async () => {
302 const root = ReactNoop.createRoot();
303
304 const Context = React.createContext(0);
305
306 function App() {
307 return (
308 <Context.Provider value={0}>
309 <Consumer />
310 </Context.Provider>
311 );
312 }
313
314 let setOtherValue;
315 const Consumer = React.memo(() => {
316 const value = useContext(Context);
317
318 const [, _setOtherValue] = useState(0);
319 setOtherValue = _setOtherValue;
320
321 Scheduler.log('Consumer');
322
323 return <Text text={value} />;
324 });
325
326 await act(() => {
327 root.render(<App />);
328 });
329 assertLog(['Consumer', 0]);
330 expect(root).toMatchRenderedOutput('0');
331
332 await act(() => {
333 // Intentionally calling setState to some other arbitrary value before
334 // setting it back to the current one. That way an update is scheduled,
335 // but we'll bail out during render when nothing has changed.
336 setOtherValue(1);
337 setOtherValue(0);
338 });
339 // NOTE: If this didn't yield anything, that indicates that we never visited
340 // the consumer during the render phase, which probably means the eager
341 // bailout mechanism kicked in. Because we're testing the _lazy_ bailout
342 // mechanism, update this test to foil the _eager_ bailout, somehow. Perhaps
343 // by switching to useReducer.
344 assertLog(['Consumer']);
345 expect(root).toMatchRenderedOutput('0');
346 });
347
348 // @gate enableLegacyCache
349 it('context is propagated across retries', async () => {
350 const root = ReactNoop.createRoot();
351
352 const Context = React.createContext('A');
353
354 let setContext;
355 function App() {
356 const [value, setValue] = useState('A');
357 setContext = setValue;
358 return (
359 <Context.Provider value={value}>
360 <Suspense fallback={<Text text="Loading..." />}>
361 <Async />
362 </Suspense>
363 <Text text={value} />
364 </Context.Provider>
365 );
366 }
367
368 function Async() {
369 const value = useContext(Context);
370 readText(value);
371
372 // When `readText` suspends, we haven't yet visited Indirection and all
373 // of its children. They won't get rendered until a later retry.
374 return <Indirection />;
375 }
376
377 const Indirection = React.memo(() => {
378 // This child must always be consistent with the sibling Text component.
379 return <DeepChild />;
380 });
381
382 function DeepChild() {
383 const value = useContext(Context);
384 return <Text text={value} />;
385 }
386
387 await seedNextTextCache('A');
388 await act(() => {
389 root.render(<App />);
390 });
391 assertLog(['A', 'A']);
392 expect(root).toMatchRenderedOutput('AA');
393
394 await act(() => {
395 // Intentionally not wrapping in startTransition, so that the fallback
396 // the fallback displays despite this being a refresh.
397 setContext('B');
398 });
399 assertLog([
400 'Suspend! [B]',
401 'Loading...',
402 'B',
403 // pre-warming
404 'Suspend! [B]',
405 ]);
406 expect(root).toMatchRenderedOutput('Loading...B');
407
408 await act(async () => {
409 await resolveText('B');
410 });
411 assertLog(['B']);
412 expect(root).toMatchRenderedOutput('BB');
413 });
414
415 // @gate enableLegacyCache
416 it('multiple contexts are propagated across retries', async () => {
417 // Same as previous test, but with multiple context providers
418 const root = ReactNoop.createRoot();
419
420 const Context1 = React.createContext('A');
421 const Context2 = React.createContext('A');
422
423 let setContext;
424 function App() {
425 const [value, setValue] = useState('A');
426 setContext = setValue;
427 return (
428 <Context1.Provider value={value}>
429 <Context2.Provider value={value}>
430 <Suspense fallback={<Text text="Loading..." />}>
431 <Async />
432 </Suspense>
433 <Text text={value} />
434 </Context2.Provider>
435 </Context1.Provider>
436 );
437 }
438
439 function Async() {
440 const value = useContext(Context1);
441 readText(value);
442
443 // When `readText` suspends, we haven't yet visited Indirection and all
444 // of its children. They won't get rendered until a later retry.
445 return (
446 <>
447 <Indirection1 />
448 <Indirection2 />
449 </>
450 );
451 }
452
453 const Indirection1 = React.memo(() => {
454 // This child must always be consistent with the sibling Text component.
455 return <DeepChild1 />;
456 });
457
458 const Indirection2 = React.memo(() => {
459 // This child must always be consistent with the sibling Text component.
460 return <DeepChild2 />;
461 });
462
463 function DeepChild1() {
464 const value = useContext(Context1);
465 return <Text text={value} />;
466 }
467
468 function DeepChild2() {
469 const value = useContext(Context2);
470 return <Text text={value} />;
471 }
472
473 await seedNextTextCache('A');
474 await act(() => {
475 root.render(<App />);
476 });
477 assertLog(['A', 'A', 'A']);
478 expect(root).toMatchRenderedOutput('AAA');
479
480 await act(() => {
481 // Intentionally not wrapping in startTransition, so that the fallback
482 // the fallback displays despite this being a refresh.
483 setContext('B');
484 });
485 assertLog([
486 'Suspend! [B]',
487 'Loading...',
488 'B',
489 // pre-warming
490 'Suspend! [B]',
491 ]);
492 expect(root).toMatchRenderedOutput('Loading...B');
493
494 await act(async () => {
495 await resolveText('B');
496 });
497 assertLog(['B', 'B']);
498 expect(root).toMatchRenderedOutput('BBB');
499 });
500
501 // @gate enableLegacyCache && !disableLegacyMode
502 it('context is propagated across retries (legacy)', async () => {
503 const root = ReactNoop.createLegacyRoot();
504
505 const Context = React.createContext('A');
506
507 let setContext;
508 function App() {
509 const [value, setValue] = useState('A');
510 setContext = setValue;
511 return (
512 <Context.Provider value={value}>
513 <Suspense fallback={<Text text="Loading..." />}>
514 <Async />
515 </Suspense>
516 <Text text={value} />
517 </Context.Provider>
518 );
519 }
520
521 function Async() {
522 const value = useContext(Context);
523 readText(value);
524
525 // When `readText` suspends, we haven't yet visited Indirection and all
526 // of its children. They won't get rendered until a later retry.
527 return <Indirection />;
528 }
529
530 const Indirection = React.memo(() => {
531 // This child must always be consistent with the sibling Text component.
532 return <DeepChild />;
533 });
534
535 function DeepChild() {
536 const value = useContext(Context);
537 return <Text text={value} />;
538 }
539
540 await seedNextTextCache('A');
541 await act(() => {
542 root.render(<App />);
543 });
544 assertLog(['A', 'A']);
545 expect(root).toMatchRenderedOutput('AA');
546
547 await act(() => {
548 // Intentionally not wrapping in startTransition, so that the fallback
549 // the fallback displays despite this being a refresh.
550 setContext('B');
551 });
552 assertLog(['Suspend! [B]', 'Loading...', 'B']);
553 expect(root).toMatchRenderedOutput('Loading...B');
554
555 await act(async () => {
556 await resolveText('B');
557 });
558 assertLog(['B']);
559 expect(root).toMatchRenderedOutput('BB');
560 });
561
562 // @gate enableLegacyCache && enableLegacyHidden
563 it('context is propagated through offscreen trees', async () => {
564 const LegacyHidden = React.unstable_LegacyHidden;
565
566 const root = ReactNoop.createRoot();
567
568 const Context = React.createContext('A');
569
570 let setContext;
571 function App() {
572 const [value, setValue] = useState('A');
573 setContext = setValue;
574 return (
575 <Context.Provider value={value}>
576 <LegacyHidden mode="hidden">
577 <Indirection />
578 </LegacyHidden>
579 <Text text={value} />
580 </Context.Provider>
581 );
582 }
583
584 const Indirection = React.memo(() => {
585 // This child must always be consistent with the sibling Text component.
586 return <DeepChild />;
587 });
588
589 function DeepChild() {
590 const value = useContext(Context);
591 return <Text text={value} />;
592 }
593
594 await seedNextTextCache('A');
595 await act(() => {
596 root.render(<App />);
597 });
598 assertLog(['A', 'A']);
599 expect(root).toMatchRenderedOutput('AA');
600
601 await act(() => {
602 setContext('B');
603 });
604 assertLog(['B', 'B']);
605 expect(root).toMatchRenderedOutput('BB');
606 });
607
608 // @gate enableLegacyCache && enableLegacyHidden
609 it('multiple contexts are propagated across through offscreen trees', async () => {
610 // Same as previous test, but with multiple context providers
611 const LegacyHidden = React.unstable_LegacyHidden;
612
613 const root = ReactNoop.createRoot();
614
615 const Context1 = React.createContext('A');
616 const Context2 = React.createContext('A');
617
618 let setContext;
619 function App() {
620 const [value, setValue] = useState('A');
621 setContext = setValue;
622 return (
623 <Context1.Provider value={value}>
624 <Context2.Provider value={value}>
625 <LegacyHidden mode="hidden">
626 <Indirection1 />
627 <Indirection2 />
628 </LegacyHidden>
629 <Text text={value} />
630 </Context2.Provider>
631 </Context1.Provider>
632 );
633 }
634
635 const Indirection1 = React.memo(() => {
636 // This child must always be consistent with the sibling Text component.
637 return <DeepChild1 />;
638 });
639
640 const Indirection2 = React.memo(() => {
641 // This child must always be consistent with the sibling Text component.
642 return <DeepChild2 />;
643 });
644
645 function DeepChild1() {
646 const value = useContext(Context1);
647 return <Text text={value} />;
648 }
649
650 function DeepChild2() {
651 const value = useContext(Context2);
652 return <Text text={value} />;
653 }
654
655 await seedNextTextCache('A');
656 await act(() => {
657 root.render(<App />);
658 });
659 assertLog(['A', 'A', 'A']);
660 expect(root).toMatchRenderedOutput('AAA');
661
662 await act(() => {
663 setContext('B');
664 });
665 assertLog(['B', 'B', 'B']);
666 expect(root).toMatchRenderedOutput('BBB');
667 });
668
669 // @gate enableSuspenseList
670 it('contexts are propagated through SuspenseList', async () => {
671 // This kinda tests an implementation detail. SuspenseList has an early
672 // bailout that doesn't use `bailoutOnAlreadyFinishedWork`. It probably
673 // should just use that function, though.
674 const Context = React.createContext('A');
675
676 let setContext;
677 function App() {
678 const [value, setValue] = useState('A');
679 setContext = setValue;
680 const children = React.useMemo(
681 () => (
682 <SuspenseList revealOrder="forwards" tail="visible">
683 <Child />
684 <Child />
685 </SuspenseList>
686 ),
687 [],
688 );
689 return <Context.Provider value={value}>{children}</Context.Provider>;
690 }
691
692 function Child() {
693 const value = useContext(Context);
694 return <Text text={value} />;
695 }
696
697 const root = ReactNoop.createRoot();
698 await act(() => {
699 root.render(<App />);
700 });
701 assertLog(['A', 'A']);
702 expect(root).toMatchRenderedOutput('AA');
703
704 await act(() => {
705 setContext('B');
706 });
707 assertLog(['B', 'B']);
708 expect(root).toMatchRenderedOutput('BB');
709 });
710
711 it('nested bailouts', async () => {
712 // Lazy context propagation will stop propagating when it hits the first
713 // match. If we bail out again inside that tree, we must resume propagating.
714
715 const Context = React.createContext('A');
716
717 let setContext;
718 function App() {
719 const [value, setValue] = useState('A');
720 setContext = setValue;
721 return (
722 <Context.Provider value={value}>
723 <ChildIndirection />
724 </Context.Provider>
725 );
726 }
727
728 const ChildIndirection = React.memo(() => {
729 return <Child />;
730 });
731
732 function Child() {
733 const value = useContext(Context);
734 return (
735 <>
736 <Text text={value} />
737 <DeepChildIndirection />
738 </>
739 );
740 }
741
742 const DeepChildIndirection = React.memo(() => {
743 return <DeepChild />;
744 });
745
746 function DeepChild() {
747 const value = useContext(Context);
748 return <Text text={value} />;
749 }
750
751 const root = ReactNoop.createRoot();
752 await act(() => {
753 root.render(<App />);
754 });
755 assertLog(['A', 'A']);
756 expect(root).toMatchRenderedOutput('AA');
757
758 await act(() => {
759 setContext('B');
760 });
761 assertLog(['B', 'B']);
762 expect(root).toMatchRenderedOutput('BB');
763 });
764
765 // @gate enableLegacyCache
766 it('nested bailouts across retries', async () => {
767 // Lazy context propagation will stop propagating when it hits the first
768 // match. If we bail out again inside that tree, we must resume propagating.
769
770 const Context = React.createContext('A');
771
772 let setContext;
773 function App() {
774 const [value, setValue] = useState('A');
775 setContext = setValue;
776 return (
777 <Context.Provider value={value}>
778 <Suspense fallback={<Text text="Loading..." />}>
779 <Async value={value} />
780 </Suspense>
781 </Context.Provider>
782 );
783 }
784
785 function Async({value}) {
786 // When this suspends, we won't be able to visit its children during the
787 // current render. So we must take extra care to propagate the context
788 // change in such a way that they're aren't lost when we retry in a
789 // later render.
790 readText(value);
791 return <Child value={value} />;
792 }
793
794 function Child() {
795 const value = useContext(Context);
796 return (
797 <>
798 <Text text={value} />
799 <DeepChildIndirection />
800 </>
801 );
802 }
803
804 const DeepChildIndirection = React.memo(() => {
805 return <DeepChild />;
806 });
807
808 function DeepChild() {
809 const value = useContext(Context);
810 return <Text text={value} />;
811 }
812
813 const root = ReactNoop.createRoot();
814 await seedNextTextCache('A');
815 await act(() => {
816 root.render(<App />);
817 });
818 assertLog(['A', 'A']);
819 expect(root).toMatchRenderedOutput('AA');
820
821 await act(() => {
822 setContext('B');
823 });
824 assertLog([
825 'Suspend! [B]',
826 'Loading...',
827 // pre-warming
828 'Suspend! [B]',
829 ]);
830 expect(root).toMatchRenderedOutput('Loading...');
831
832 await act(async () => {
833 await resolveText('B');
834 });
835 assertLog(['B', 'B']);
836 expect(root).toMatchRenderedOutput('BB');
837 });
838
839 // @gate enableLegacyCache && enableLegacyHidden
840 it('nested bailouts through offscreen trees', async () => {
841 // Lazy context propagation will stop propagating when it hits the first
842 // match. If we bail out again inside that tree, we must resume propagating.
843
844 const LegacyHidden = React.unstable_LegacyHidden;
845
846 const Context = React.createContext('A');
847
848 let setContext;
849 function App() {
850 const [value, setValue] = useState('A');
851 setContext = setValue;
852 return (
853 <Context.Provider value={value}>
854 <LegacyHidden mode="hidden">
855 <Child />
856 </LegacyHidden>
857 </Context.Provider>
858 );
859 }
860
861 function Child() {
862 const value = useContext(Context);
863 return (
864 <>
865 <Text text={value} />
866 <DeepChildIndirection />
867 </>
868 );
869 }
870
871 const DeepChildIndirection = React.memo(() => {
872 return <DeepChild />;
873 });
874
875 function DeepChild() {
876 const value = useContext(Context);
877 return <Text text={value} />;
878 }
879
880 const root = ReactNoop.createRoot();
881 await act(() => {
882 root.render(<App />);
883 });
884 assertLog(['A', 'A']);
885 expect(root).toMatchRenderedOutput('AA');
886
887 await act(() => {
888 setContext('B');
889 });
890 assertLog(['B', 'B']);
891 expect(root).toMatchRenderedOutput('BB');
892 });
893
894 it('finds context consumers in multiple sibling branches', async () => {
895 // This test confirms that when we find a matching context consumer during
896 // propagation, we continue propagating to its sibling branches.
897
898 const Context = React.createContext('A');
899
900 let setContext;
901 function App() {
902 const [value, setValue] = useState('A');
903 setContext = setValue;
904 return (
905 <Context.Provider value={value}>
906 <Blah />
907 </Context.Provider>
908 );
909 }
910
911 const Blah = React.memo(() => {
912 return (
913 <>
914 <Indirection />
915 <Indirection />
916 </>
917 );
918 });
919
920 const Indirection = React.memo(() => {
921 return <Child />;
922 });
923
924 function Child() {
925 const value = useContext(Context);
926 return <Text text={value} />;
927 }
928
929 const root = ReactNoop.createRoot();
930 await act(() => {
931 root.render(<App />);
932 });
933 assertLog(['A', 'A']);
934 expect(root).toMatchRenderedOutput('AA');
935
936 await act(() => {
937 setContext('B');
938 });
939 assertLog(['B', 'B']);
940 expect(root).toMatchRenderedOutput('BB');
941 });
942
943 it('regression: context change triggers retry of suspended Suspense boundary on initial mount', async () => {
944 // Regression test for a bug where a context change above a suspended
945 // Suspense boundary would fail to trigger a retry. When a Suspense
946 // boundary suspends during initial mount, the primary children's fibers
947 // are discarded because there is no current tree to preserve them. If
948 // the suspended promise never resolves, the only way to retry is
949 // something external — like a context change. Context propagation must
950 // mark suspended Suspense boundaries for retry even though the consumer
951 // fibers no longer exist in the tree.
952 //
953 // The Provider component owns the state update. The children are
954 // passed in from above, so they are not re-created when the Provider
955 // re-renders — this means the Suspense boundary bails out, exercising
956 // the lazy context propagation path where the bug manifests.
957 const Context = React.createContext(null);
958 const neverResolvingPromise = new Promise(() => {});
959 const resolvedThenable = {status: 'fulfilled', value: 'Result', then() {}};
960
961 function Consumer() {
962 return <Text text={use(use(Context))} />;
963 }
964
965 let setPromise;
966 function Provider({children}) {
967 const [promise, _setPromise] = useState(neverResolvingPromise);
968 setPromise = _setPromise;
969 return <Context.Provider value={promise}>{children}</Context.Provider>;
970 }
971
972 const root = ReactNoop.createRoot();
973 await act(() => {
974 root.render(
975 <Provider>
976 <Suspense fallback={<Text text="Loading" />}>
977 <Consumer />
978 </Suspense>
979 </Provider>,
980 );
981 });
982 assertLog(['Loading']);
983 expect(root).toMatchRenderedOutput('Loading');
984
985 await act(() => {
986 setPromise(resolvedThenable);
987 });
988 assertLog(['Result']);
989 expect(root).toMatchRenderedOutput('Result');
990 });
991
992 it('regression: context change triggers retry of suspended Suspense boundary on initial mount (nested)', async () => {
993 // Same as above, but with an additional indirection component between
994 // the provider and the Suspense boundary. This exercises the
995 // propagateContextChanges walker path rather than the
996 // propagateParentContextChanges path.
997 const Context = React.createContext(null);
998 const neverResolvingPromise = new Promise(() => {});
999 const resolvedThenable = {status: 'fulfilled', value: 'Result', then() {}};
1000
1001 function Consumer() {
1002 return <Text text={use(use(Context))} />;
1003 }
1004
1005 function Indirection({children}) {
1006 Scheduler.log('Indirection');
1007 return children;
1008 }
1009
1010 let setPromise;
1011 function Provider({children}) {
1012 const [promise, _setPromise] = useState(neverResolvingPromise);
1013 setPromise = _setPromise;
1014 return <Context.Provider value={promise}>{children}</Context.Provider>;
1015 }
1016
1017 const root = ReactNoop.createRoot();
1018 await act(() => {
1019 root.render(
1020 <Provider>
1021 <Indirection>
1022 <Suspense fallback={<Text text="Loading" />}>
1023 <Consumer />
1024 </Suspense>
1025 </Indirection>
1026 </Provider>,
1027 );
1028 });
1029 assertLog(['Indirection', 'Loading']);
1030 expect(root).toMatchRenderedOutput('Loading');
1031
1032 // Indirection should not re-render — only the Suspense boundary
1033 // should be retried.
1034 await act(() => {
1035 setPromise(resolvedThenable);
1036 });
1037 assertLog(['Result']);
1038 expect(root).toMatchRenderedOutput('Result');
1039 });
1040
1041 // @gate enableLegacyCache
1042 it('context change propagates to Suspense fallback (memo boundary)', async () => {
1043 // When a context change occurs above a Suspense boundary that is currently
1044 // showing its fallback, the fallback's context consumers should re-render
1045 // with the updated value — even if there's a memo boundary between the
1046 // provider and the Suspense boundary that prevents the fallback element
1047 // references from changing.
1048 const root = ReactNoop.createRoot();
1049 const Context = React.createContext('A');
1050
1051 let setContext;
1052 function App() {
1053 const [value, _setValue] = useState('A');
1054 setContext = _setValue;
1055 return (
1056 <Context.Provider value={value}>
1057 <MemoizedWrapper />
1058 <Text text={value} />
1059 </Context.Provider>
1060 );
1061 }
1062
1063 const MemoizedWrapper = React.memo(function MemoizedWrapper() {
1064 return (
1065 <Suspense fallback={<FallbackConsumer />}>
1066 <AsyncChild />
1067 </Suspense>
1068 );
1069 });
1070
1071 function FallbackConsumer() {
1072 const value = useContext(Context);
1073 return <Text text={'Fallback: ' + value} />;
1074 }
1075
1076 function AsyncChild() {
1077 readText('async');
1078 return <Text text="Content" />;
1079 }
1080
1081 // Initial render — primary content suspends, fallback is shown
1082 await act(() => {
1083 root.render(<App />);
1084 });
1085 assertLog([
1086 'Suspend! [async]',
1087 'Fallback: A',
1088 'A',
1089 // pre-warming
1090 'Suspend! [async]',
1091 ]);
1092 expect(root).toMatchRenderedOutput('Fallback: AA');
1093
1094 // Update context while still suspended. The fallback consumer should
1095 // re-render with the new value.
1096 await act(() => {
1097 setContext('B');
1098 });
1099 assertLog([
1100 // The Suspense boundary retries the primary children first
1101 'Suspend! [async]',
1102 'Fallback: B',
1103 'B',
1104 // pre-warming
1105 'Suspend! [async]',
1106 ]);
1107 expect(root).toMatchRenderedOutput('Fallback: BB');
1108
1109 // Unsuspend. The primary content should render with the latest context.
1110 await act(async () => {
1111 await resolveText('async');
1112 });
1113 assertLog(['Content']);
1114 expect(root).toMatchRenderedOutput('ContentB');
1115 });
1116 });