main
js 2,320 lines 73 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 React;
13 let ReactNoop;
14 let Scheduler;
15 let act;
16 let use;
17 let useDebugValue;
18 let useState;
19 let useTransition;
20 let useMemo;
21 let useEffect;
22 let Suspense;
23 let startTransition;
24 let pendingTextRequests;
25 let waitFor;
26 let waitForPaint;
27 let assertLog;
28 let waitForAll;
29 let waitForMicrotasks;
30 let assertConsoleErrorDev;
31
32 describe('ReactUse', () => {
33 beforeEach(() => {
34 jest.resetModules();
35
36 React = require('react');
37 ReactNoop = require('react-noop-renderer');
38 Scheduler = require('scheduler');
39 act = require('internal-test-utils').act;
40 use = React.use;
41 useDebugValue = React.useDebugValue;
42 useState = React.useState;
43 useTransition = React.useTransition;
44 useMemo = React.useMemo;
45 useEffect = React.useEffect;
46 Suspense = React.Suspense;
47 startTransition = React.startTransition;
48
49 const InternalTestUtils = require('internal-test-utils');
50 waitForAll = InternalTestUtils.waitForAll;
51 assertLog = InternalTestUtils.assertLog;
52 waitForPaint = InternalTestUtils.waitForPaint;
53 waitFor = InternalTestUtils.waitFor;
54 waitForMicrotasks = InternalTestUtils.waitForMicrotasks;
55 assertConsoleErrorDev = InternalTestUtils.assertConsoleErrorDev;
56
57 pendingTextRequests = new Map();
58 });
59
60 function resolveTextRequests(text) {
61 const requests = pendingTextRequests.get(text);
62 if (requests !== undefined) {
63 pendingTextRequests.delete(text);
64 requests.forEach(resolve => resolve(text));
65 }
66 }
67
68 function getAsyncText(text) {
69 // getAsyncText is completely uncached — it performs a new async operation
70 // every time it's called. During a transition, React should be able to
71 // unwrap it anyway.
72 Scheduler.log(`Async text requested [${text}]`);
73 return new Promise(resolve => {
74 const requests = pendingTextRequests.get(text);
75 if (requests !== undefined) {
76 requests.push(resolve);
77 pendingTextRequests.set(text, requests);
78 } else {
79 pendingTextRequests.set(text, [resolve]);
80 }
81 });
82 }
83
84 function normalizeCodeLocInfo(str) {
85 return (
86 str &&
87 str.replace(
88 /^ +(?:at|in) ([\S]+)([^\n]*)(\n?)/gm,
89 function (m, name, location, eol) {
90 if (location.indexOf(__filename) === -1) {
91 // ignore frames from library code
92 return '';
93 }
94 return (
95 ' in ' +
96 name +
97 (/:\d+:\d+/.test(m)
98 ? ' ' +
99 location.replace(__dirname, '~').replace(/:\d+:\d+/, ':*:*')
100 : '') +
101 eol
102 );
103 },
104 )
105 );
106 }
107
108 function Text({text}) {
109 Scheduler.log(text);
110 return text;
111 }
112
113 // This behavior was intentionally disabled to derisk the rollout of `use`.
114 // It changes the behavior of old, pre-`use` Suspense implementations. We may
115 // add this back; however, the plan is to migrate all existing Suspense code
116 // to `use`, so the extra code probably isn't worth it.
117 // @gate TODO
118 it('if suspended fiber is pinged in a microtask, retry immediately without unwinding the stack', async () => {
119 let fulfilled = false;
120 function Async() {
121 if (fulfilled) {
122 return <Text text="Async" />;
123 }
124 Scheduler.log('Suspend!');
125 throw Promise.resolve().then(() => {
126 Scheduler.log('Resolve in microtask');
127 fulfilled = true;
128 });
129 }
130
131 function App() {
132 return (
133 <Suspense fallback={<Text text="Loading..." />}>
134 <Async />
135 </Suspense>
136 );
137 }
138
139 const root = ReactNoop.createRoot();
140 await act(() => {
141 startTransition(() => {
142 root.render(<App />);
143 });
144 });
145
146 assertLog([
147 // React will yield when the async component suspends.
148 'Suspend!',
149 'Resolve in microtask',
150
151 // Finished rendering without unwinding the stack or preparing a fallback.
152 'Async',
153 ]);
154 expect(root).toMatchRenderedOutput('Async');
155 });
156
157 it('if suspended fiber is pinged in a microtask, it does not block a transition from completing', async () => {
158 let fulfilled = false;
159 function Async() {
160 if (fulfilled) {
161 return <Text text="Async" />;
162 }
163 Scheduler.log('Suspend!');
164 throw Promise.resolve().then(() => {
165 Scheduler.log('Resolve in microtask');
166 fulfilled = true;
167 });
168 }
169
170 function App() {
171 return <Async />;
172 }
173
174 const root = ReactNoop.createRoot();
175 await act(() => {
176 startTransition(() => {
177 root.render(<App />);
178 });
179 });
180 assertLog(['Suspend!', 'Resolve in microtask', 'Async']);
181 expect(root).toMatchRenderedOutput('Async');
182 });
183
184 it('does not infinite loop if already fulfilled thenable is thrown', async () => {
185 // An already fulfilled promise should never be thrown. Since it already
186 // fulfilled, we shouldn't bother trying to render again — doing so would
187 // likely lead to an infinite loop. This scenario should only happen if a
188 // userspace Suspense library makes an implementation mistake.
189
190 // Create an already fulfilled thenable
191 const thenable = {
192 then(ping) {},
193 status: 'fulfilled',
194 value: null,
195 };
196
197 let i = 0;
198 function Async() {
199 if (i++ > 50) {
200 throw new Error('Infinite loop detected');
201 }
202 Scheduler.log('Suspend!');
203 // This thenable should never be thrown because it already fulfilled.
204 // But if it is thrown, React should handle it gracefully.
205 throw thenable;
206 }
207
208 function App() {
209 return (
210 <Suspense fallback={<Text text="Loading..." />}>
211 <Async />
212 </Suspense>
213 );
214 }
215
216 const root = ReactNoop.createRoot();
217 await act(() => {
218 root.render(<App />);
219 });
220 assertLog([
221 'Suspend!',
222 'Loading...',
223 // pre-warming
224 'Suspend!',
225 ]);
226 expect(root).toMatchRenderedOutput('Loading...');
227 });
228
229 it('basic use(promise)', async () => {
230 const promiseA = Promise.resolve('A');
231 const promiseB = Promise.resolve('B');
232 const promiseC = Promise.resolve('C');
233
234 function Async() {
235 const text = use(promiseA) + use(promiseB) + use(promiseC);
236 return <Text text={text} />;
237 }
238
239 function App() {
240 return (
241 <Suspense fallback={<Text text="Loading..." />}>
242 <Async />
243 </Suspense>
244 );
245 }
246
247 const root = ReactNoop.createRoot();
248 await act(() => {
249 startTransition(() => {
250 root.render(<App />);
251 });
252 });
253 assertLog(['ABC']);
254 expect(root).toMatchRenderedOutput('ABC');
255 });
256
257 it("using a promise that's not cached between attempts", async () => {
258 function Async() {
259 const text =
260 use(Promise.resolve('A')) +
261 use(Promise.resolve('B')) +
262 use(Promise.resolve('C'));
263 return <Text text={text} />;
264 }
265
266 function App() {
267 return (
268 <Suspense fallback={<Text text="Loading..." />}>
269 <Async />
270 </Suspense>
271 );
272 }
273
274 const root = ReactNoop.createRoot();
275 await act(() => {
276 startTransition(() => {
277 root.render(<App />);
278 });
279 });
280 assertConsoleErrorDev([
281 'A component was suspended by an uncached promise. Creating ' +
282 'promises inside a Client Component or hook is not yet ' +
283 'supported, except via a Suspense-compatible library or framework.\n' +
284 ' in App (at **)',
285 ]);
286 assertLog(['ABC']);
287 expect(root).toMatchRenderedOutput('ABC');
288 });
289
290 it('using a rejected promise will throw', async () => {
291 class ErrorBoundary extends React.Component {
292 state = {error: null};
293 static getDerivedStateFromError(error) {
294 return {error};
295 }
296 render() {
297 if (this.state.error) {
298 return <Text text={this.state.error.message} />;
299 }
300 return this.props.children;
301 }
302 }
303
304 const promiseA = Promise.resolve('A');
305 const promiseB = Promise.reject(new Error('Oops!'));
306 const promiseC = Promise.resolve('C');
307
308 // Jest/Node will raise an unhandled rejected error unless we await this. It
309 // works fine in the browser, though.
310 await expect(promiseB).rejects.toThrow('Oops!');
311
312 function Async() {
313 const text = use(promiseA) + use(promiseB) + use(promiseC);
314 return <Text text={text} />;
315 }
316
317 function App() {
318 return (
319 <ErrorBoundary>
320 <Async />
321 </ErrorBoundary>
322 );
323 }
324
325 const root = ReactNoop.createRoot();
326 await act(() => {
327 startTransition(() => {
328 root.render(<App />);
329 });
330 });
331 assertLog(['Oops!', 'Oops!']);
332 });
333
334 it('use(promise) in multiple components', async () => {
335 // This tests that the state for tracking promises is reset per component.
336 const promiseA = Promise.resolve('A');
337 const promiseB = Promise.resolve('B');
338 const promiseC = Promise.resolve('C');
339 const promiseD = Promise.resolve('D');
340
341 function Child({prefix}) {
342 return <Text text={prefix + use(promiseC) + use(promiseD)} />;
343 }
344
345 function Parent() {
346 return <Child prefix={use(promiseA) + use(promiseB)} />;
347 }
348
349 function App() {
350 return (
351 <Suspense fallback={<Text text="Loading..." />}>
352 <Parent />
353 </Suspense>
354 );
355 }
356
357 const root = ReactNoop.createRoot();
358 await act(() => {
359 startTransition(() => {
360 root.render(<App />);
361 });
362 });
363 assertLog(['ABCD']);
364 expect(root).toMatchRenderedOutput('ABCD');
365 });
366
367 it('use(promise) in multiple sibling components', async () => {
368 // This tests that the state for tracking promises is reset per component.
369
370 const promiseA = {then: () => {}, status: 'pending', value: null};
371 const promiseB = {then: () => {}, status: 'pending', value: null};
372 const promiseC = {then: () => {}, status: 'fulfilled', value: 'C'};
373 const promiseD = {then: () => {}, status: 'fulfilled', value: 'D'};
374
375 function Sibling1({prefix}) {
376 return <Text text={use(promiseA) + use(promiseB)} />;
377 }
378
379 function Sibling2() {
380 return <Text text={use(promiseC) + use(promiseD)} />;
381 }
382
383 function App() {
384 return (
385 <Suspense fallback={<Text text="Loading..." />}>
386 <Sibling1 />
387 <Sibling2 />
388 </Suspense>
389 );
390 }
391
392 const root = ReactNoop.createRoot();
393 await act(() => {
394 startTransition(() => {
395 root.render(<App />);
396 });
397 });
398 assertLog(['Loading...']);
399 expect(root).toMatchRenderedOutput('Loading...');
400 });
401
402 it('erroring in the same component as an uncached promise does not result in an infinite loop', async () => {
403 class ErrorBoundary extends React.Component {
404 state = {error: null};
405 static getDerivedStateFromError(error) {
406 return {error};
407 }
408 render() {
409 if (this.state.error) {
410 return <Text text={'Caught an error: ' + this.state.error.message} />;
411 }
412 return this.props.children;
413 }
414 }
415
416 let i = 0;
417 function Async({
418 // Intentionally destrucutring a prop here so that our production error
419 // stack trick is triggered at the beginning of the function
420 prop,
421 }) {
422 if (i++ > 50) {
423 throw new Error('Infinite loop detected');
424 }
425 try {
426 use(Promise.resolve('Async'));
427 } catch (e) {
428 Scheduler.log('Suspend! [Async]');
429 throw e;
430 }
431 throw new Error('Oops!');
432 }
433
434 function App() {
435 return (
436 <Suspense fallback={<Text text="Loading..." />}>
437 <ErrorBoundary>
438 <Async />
439 </ErrorBoundary>
440 </Suspense>
441 );
442 }
443
444 const root = ReactNoop.createRoot();
445 await act(() => {
446 startTransition(() => {
447 root.render(<App />);
448 });
449 });
450 assertConsoleErrorDev([
451 'A component was suspended by an uncached promise. Creating ' +
452 'promises inside a Client Component or hook is not yet ' +
453 'supported, except via a Suspense-compatible library or framework.\n' +
454 ' in App (at **)',
455 'A component was suspended by an uncached promise. Creating ' +
456 'promises inside a Client Component or hook is not yet ' +
457 'supported, except via a Suspense-compatible library or framework.\n' +
458 ' in App (at **)',
459 ]);
460 assertLog([
461 // First attempt. The uncached promise suspends.
462 'Suspend! [Async]',
463 // Because the promise already fulfilled, we're able to unwrap the value
464 // immediately in a microtask.
465 //
466 // Then we proceed to the rest of the component, which throws an error.
467 'Caught an error: Oops!',
468
469 // During the sync error recovery pass, the component suspends, because
470 // we were unable to unwrap the value of the promise.
471 'Suspend! [Async]',
472 'Loading...',
473
474 // Because the error recovery attempt suspended, React can't tell if the
475 // error was actually fixed, or it was masked by the suspended data.
476 // In this case, it wasn't actually fixed, so if we were to commit the
477 // suspended fallback, it would enter an endless error recovery loop.
478 //
479 // Instead, we disable error recovery for these lanes and start
480 // over again.
481
482 // This time, the error is thrown and we commit the result.
483 'Suspend! [Async]',
484 'Caught an error: Oops!',
485 ]);
486 expect(root).toMatchRenderedOutput('Caught an error: Oops!');
487 });
488
489 it('basic use(context)', async () => {
490 const ContextA = React.createContext('');
491 const ContextB = React.createContext('B');
492
493 function Sync() {
494 const text = use(ContextA) + use(ContextB);
495 return text;
496 }
497
498 function App() {
499 return (
500 <ContextA.Provider value="A">
501 <Sync />
502 </ContextA.Provider>
503 );
504 }
505
506 const root = ReactNoop.createRoot();
507 root.render(<App />);
508 await waitForAll([]);
509 expect(root).toMatchRenderedOutput('AB');
510 });
511
512 it('interrupting while yielded should reset contexts', async () => {
513 let resolve;
514 const promise = new Promise(r => {
515 resolve = r;
516 });
517
518 const Context = React.createContext();
519
520 const lazy = React.lazy(() => {
521 return promise;
522 });
523
524 function ContextText() {
525 return <Text text={use(Context)} />;
526 }
527
528 function App({text}) {
529 return (
530 <div>
531 <Context.Provider value={text}>
532 {lazy}
533 <ContextText />
534 </Context.Provider>
535 </div>
536 );
537 }
538
539 const root = ReactNoop.createRoot();
540 startTransition(() => {
541 root.render(<App text="world" />);
542 });
543 await waitForPaint([]);
544 expect(root).toMatchRenderedOutput(null);
545
546 await resolve({default: <Text key="hi" text="Hello " />});
547
548 // Higher priority update that interrupts the first render
549 ReactNoop.flushSync(() => {
550 root.render(<App text="world!" />);
551 });
552
553 assertLog(['Hello ', 'world!']);
554
555 expect(root).toMatchRenderedOutput(<div>Hello world!</div>);
556 });
557
558 it('warns if use(promise) is wrapped with try/catch block', async () => {
559 function Async() {
560 try {
561 return <Text text={use(Promise.resolve('Async'))} />;
562 } catch (e) {
563 return <Text text="Fallback" />;
564 }
565 }
566
567 spyOnDev(console, 'error').mockImplementation(() => {});
568 function App() {
569 return (
570 <Suspense fallback={<Text text="Loading..." />}>
571 <Async />
572 </Suspense>
573 );
574 }
575
576 const root = ReactNoop.createRoot();
577 await act(() => {
578 startTransition(() => {
579 root.render(<App />);
580 });
581 });
582
583 if (__DEV__) {
584 expect(console.error).toHaveBeenCalledTimes(1);
585 expect(console.error.mock.calls[0][0]).toContain(
586 '`use` was called from inside a try/catch block. This is not ' +
587 'allowed and can lead to unexpected behavior. To handle errors ' +
588 'triggered by `use`, wrap your component in a error boundary.',
589 );
590 console.error.mockRestore();
591 }
592 });
593
594 // @gate enableSuspendingDuringWorkLoop
595 it('during a transition, can unwrap async operations even if nothing is cached', async () => {
596 function App() {
597 return <Text text={use(getAsyncText('Async'))} />;
598 }
599
600 const root = ReactNoop.createRoot();
601 await act(() => {
602 root.render(
603 <Suspense fallback={<Text text="Loading..." />}>
604 <Text text="(empty)" />
605 </Suspense>,
606 );
607 });
608 assertLog(['(empty)']);
609 expect(root).toMatchRenderedOutput('(empty)');
610
611 await act(() => {
612 startTransition(() => {
613 root.render(
614 <Suspense fallback={<Text text="Loading..." />}>
615 <App />
616 </Suspense>,
617 );
618 });
619 });
620 assertLog(['Async text requested [Async]']);
621 expect(root).toMatchRenderedOutput('(empty)');
622
623 await act(() => {
624 resolveTextRequests('Async');
625 });
626 assertLog(['Async text requested [Async]', 'Async']);
627 assertConsoleErrorDev([
628 'A component was suspended by an uncached promise. ' +
629 'Creating promises inside a Client Component or hook is not yet supported, ' +
630 'except via a Suspense-compatible library or framework.\n' +
631 ' in App (at **)',
632 ]);
633 expect(root).toMatchRenderedOutput('Async');
634 });
635
636 // @gate enableSuspendingDuringWorkLoop
637 it("does not prevent a Suspense fallback from showing if it's a new boundary, even during a transition", async () => {
638 function App() {
639 return <Text text={use(getAsyncText('Async'))} />;
640 }
641
642 const root = ReactNoop.createRoot();
643 await act(() => {
644 startTransition(() => {
645 root.render(
646 <Suspense fallback={<Text text="Loading..." />}>
647 <App />
648 </Suspense>,
649 );
650 });
651 });
652 // Even though the initial render was a transition, it shows a fallback.
653 assertLog(['Async text requested [Async]', 'Loading...']);
654 expect(root).toMatchRenderedOutput('Loading...');
655
656 // Resolve the original data
657 await act(() => {
658 resolveTextRequests('Async');
659 });
660 // During the retry, a fresh request is initiated. Now we must wait for this
661 // one to finish.
662 // TODO: This is awkward. Intuitively, you might expect for `act` to wait
663 // until the new request has finished loading. But if it's mock IO, as in
664 // this test, how would the developer be able to imperatively flush it if it
665 // wasn't initiated until the current `act` call? Can't think of a better
666 // strategy at the moment.
667 assertLog(['Async text requested [Async]']);
668 expect(root).toMatchRenderedOutput('Loading...');
669
670 // Flush the second request.
671 await act(() => {
672 resolveTextRequests('Async');
673 });
674 // This time it finishes because it was during a retry.
675 assertLog(['Async text requested [Async]', 'Async']);
676 assertConsoleErrorDev([
677 'A component was suspended by an uncached promise. ' +
678 'Creating promises inside a Client Component or hook is not yet supported, ' +
679 'except via a Suspense-compatible library or framework.\n' +
680 ' in App (at **)',
681 ]);
682 expect(root).toMatchRenderedOutput('Async');
683 });
684
685 // @gate enableSuspendingDuringWorkLoop
686 it('when waiting for data to resolve, a fresh update will trigger a restart', async () => {
687 function App() {
688 return <Text text={use(getAsyncText('Will never resolve'))} />;
689 }
690
691 const root = ReactNoop.createRoot();
692 await act(() => {
693 root.render(<Suspense fallback={<Text text="Loading..." />} />);
694 });
695
696 await act(() => {
697 startTransition(() => {
698 root.render(
699 <Suspense fallback={<Text text="Loading..." />}>
700 <App />
701 </Suspense>,
702 );
703 });
704 });
705 assertLog(['Async text requested [Will never resolve]']);
706
707 await act(() => {
708 root.render(
709 <Suspense fallback={<Text text="Loading..." />}>
710 <Text text="Something different" />
711 </Suspense>,
712 );
713 });
714 assertLog(['Something different']);
715 });
716
717 // @gate enableSuspendingDuringWorkLoop
718 it('when waiting for data to resolve, an update on a different root does not cause work to be dropped', async () => {
719 const promise = getAsyncText('Hi');
720
721 function App() {
722 return <Text text={use(promise)} />;
723 }
724
725 const root1 = ReactNoop.createRoot();
726 assertLog(['Async text requested [Hi]']);
727
728 await act(() => {
729 root1.render(<Suspense fallback={<Text text="Loading..." />} />);
730 });
731
732 // Start a transition on one root. It will suspend.
733 await act(() => {
734 startTransition(() => {
735 root1.render(
736 <Suspense fallback={<Text text="Loading..." />}>
737 <App />
738 </Suspense>,
739 );
740 });
741 });
742 assertLog([]);
743
744 // While we're waiting for the first root's data to resolve, a second
745 // root renders.
746 const root2 = ReactNoop.createRoot();
747 await act(() => {
748 root2.render('Do re mi');
749 });
750 expect(root2).toMatchRenderedOutput('Do re mi');
751
752 // Once the first root's data is ready, we should finish its transition.
753 await act(async () => {
754 await resolveTextRequests('Hi');
755 });
756 assertLog(['Hi']);
757 expect(root1).toMatchRenderedOutput('Hi');
758 });
759
760 // @gate enableSuspendingDuringWorkLoop
761 it('while suspended, hooks cannot be called (i.e. current dispatcher is unset correctly)', async () => {
762 function App() {
763 return <Text text={use(getAsyncText('Will never resolve'))} />;
764 }
765
766 const root = ReactNoop.createRoot();
767 await act(() => {
768 root.render(<Suspense fallback={<Text text="Loading..." />} />);
769 });
770
771 await act(() => {
772 startTransition(() => {
773 root.render(
774 <Suspense fallback={<Text text="Loading..." />}>
775 <App />
776 </Suspense>,
777 );
778 });
779 });
780 assertLog(['Async text requested [Will never resolve]']);
781
782 // Calling a hook should error because we're oustide of a component.
783 expect(useState).toThrow(
784 'Invalid hook call. Hooks can only be called inside of the body of a ' +
785 'function component.',
786 );
787 });
788
789 it('unwraps thenable that fulfills synchronously without suspending', async () => {
790 function App() {
791 const thenable = {
792 then(resolve) {
793 // This thenable immediately resolves, synchronously, without waiting
794 // a microtask.
795 resolve('Hi');
796 },
797 };
798 try {
799 return <Text text={use(thenable)} />;
800 } catch {
801 throw new Error(
802 '`use` should not suspend because the thenable resolved synchronously.',
803 );
804 }
805 }
806 // Because the thenable resolves synchronously, we should be able to finish
807 // rendering synchronously, with no fallback.
808 const root = ReactNoop.createRoot();
809 ReactNoop.flushSync(() => {
810 root.render(<App />);
811 });
812 assertLog(['Hi']);
813 expect(root).toMatchRenderedOutput('Hi');
814 });
815
816 it('does not suspend indefinitely if an interleaved update was skipped', async () => {
817 function Child({childShouldSuspend}) {
818 return (
819 <Text
820 text={
821 childShouldSuspend
822 ? use(getAsyncText('Will never resolve'))
823 : 'Child'
824 }
825 />
826 );
827 }
828
829 let setChildShouldSuspend;
830 let setShowChild;
831 function Parent() {
832 const [showChild, _setShowChild] = useState(true);
833 setShowChild = _setShowChild;
834
835 const [childShouldSuspend, _setChildShouldSuspend] = useState(false);
836 setChildShouldSuspend = _setChildShouldSuspend;
837
838 Scheduler.log(
839 `childShouldSuspend: ${childShouldSuspend}, showChild: ${showChild}`,
840 );
841 return showChild ? (
842 <Child childShouldSuspend={childShouldSuspend} />
843 ) : (
844 <Text text="(empty)" />
845 );
846 }
847
848 const root = ReactNoop.createRoot();
849 await act(() => {
850 root.render(<Parent />);
851 });
852 assertLog(['childShouldSuspend: false, showChild: true', 'Child']);
853 expect(root).toMatchRenderedOutput('Child');
854
855 await act(async () => {
856 // Perform an update that causes the app to suspend
857 startTransition(() => {
858 setChildShouldSuspend(true);
859 });
860 await waitFor(['childShouldSuspend: true, showChild: true']);
861 // While the update is in progress, schedule another update.
862 startTransition(() => {
863 setShowChild(false);
864 });
865 });
866 assertLog([
867 // Because the interleaved update is not higher priority than what we were
868 // already working on, it won't interrupt. The first update will continue,
869 // and will suspend.
870 'Async text requested [Will never resolve]',
871
872 // Instead of waiting for the promise to resolve, React notices there's
873 // another pending update that it hasn't tried yet. It will switch to
874 // rendering that instead.
875 //
876 // This time, the update hides the component that previous was suspending,
877 // so it finishes successfully.
878 'childShouldSuspend: false, showChild: false',
879 '(empty)',
880
881 // Finally, React attempts to render the first update again. It also
882 // finishes successfully, because it was rebased on top of the update that
883 // hid the suspended component.
884 // NOTE: These this render happened to not be entangled with the previous
885 // one. If they had been, this update would have been included in the
886 // previous render, and there wouldn't be an extra one here. This could
887 // change if we change our entanglement heurstics. Semantically, it
888 // shouldn't matter, though in general we try to work on transitions in
889 // parallel whenever possible. So even though in this particular case, the
890 // extra render is unnecessary, it's a nice property that it wasn't
891 // entangled with the other transition.
892 'childShouldSuspend: true, showChild: false',
893 '(empty)',
894 ]);
895 expect(root).toMatchRenderedOutput('(empty)');
896 });
897
898 // @gate enableSuspendingDuringWorkLoop
899 it('when replaying a suspended component, reuses the hooks computed during the previous attempt (Memo)', async () => {
900 function ExcitingText({text}) {
901 // This computes the uppercased version of some text. Pretend it's an
902 // expensive operation that we want to reuse.
903 const uppercaseText = useMemo(() => {
904 Scheduler.log('Compute uppercase: ' + text);
905 return text.toUpperCase();
906 }, [text]);
907
908 // This adds an exclamation point to the text. Pretend it's an async
909 // operation that is sent to a service for processing.
910 const exclamatoryText = use(getAsyncText(uppercaseText + '!'));
911
912 // This surrounds the text with sparkle emojis. The purpose in this test
913 // is to show that you can suspend in the middle of a sequence of hooks
914 // without breaking anything.
915 const sparklingText = useMemo(() => {
916 Scheduler.log('Add sparkles: ' + exclamatoryText);
917 return `✨ ${exclamatoryText} ✨`;
918 }, [exclamatoryText]);
919
920 return <Text text={sparklingText} />;
921 }
922
923 const root = ReactNoop.createRoot();
924 await act(() => {
925 startTransition(() => {
926 root.render(<ExcitingText text="Hello" />);
927 });
928 });
929
930 // Suspends while we wait for the async service to respond.
931 assertLog(['Compute uppercase: Hello', 'Async text requested [HELLO!]']);
932 expect(root).toMatchRenderedOutput(null);
933
934 // The data is received.
935 await act(() => {
936 resolveTextRequests('HELLO!');
937 });
938 assertConsoleErrorDev([
939 'A component was suspended by an uncached promise. ' +
940 'Creating promises inside a Client Component or hook is not yet supported, ' +
941 'except via a Suspense-compatible library or framework.\n' +
942 ' in ExcitingText (at **)',
943 ]);
944
945 assertLog([
946 // We shouldn't run the uppercase computation again, because we can reuse
947 // the computation from the previous attempt.
948 // 'Compute uppercase: Hello',
949
950 'Async text requested [HELLO!]',
951 'Add sparkles: HELLO!',
952 '✨ HELLO! ✨',
953 ]);
954 });
955
956 // @gate enableSuspendingDuringWorkLoop
957 it('when replaying a suspended component, reuses the hooks computed during the previous attempt (State)', async () => {
958 let _setFruit;
959 let _setVegetable;
960 function Kitchen() {
961 const [fruit, setFruit] = useState('apple');
962 _setFruit = setFruit;
963 const usedFruit = use(getAsyncText(fruit));
964 const [vegetable, setVegetable] = useState('carrot');
965 _setVegetable = setVegetable;
966 return <Text text={usedFruit + ' ' + vegetable} />;
967 }
968
969 // Initial render.
970 const root = ReactNoop.createRoot();
971 await act(() => {
972 startTransition(() => {
973 root.render(<Kitchen />);
974 });
975 });
976 assertLog(['Async text requested [apple]']);
977 expect(root).toMatchRenderedOutput(null);
978 await act(() => {
979 resolveTextRequests('apple');
980 });
981 assertLog(['Async text requested [apple]', 'apple carrot']);
982 assertConsoleErrorDev([
983 'A component was suspended by an uncached promise. ' +
984 'Creating promises inside a Client Component or hook is not yet supported, ' +
985 'except via a Suspense-compatible library or framework.\n' +
986 ' in Kitchen (at **)',
987 ]);
988
989 expect(root).toMatchRenderedOutput('apple carrot');
990
991 // Update the state variable after the use().
992 await act(() => {
993 startTransition(() => {
994 _setVegetable('dill');
995 });
996 });
997 assertLog(['Async text requested [apple]']);
998 expect(root).toMatchRenderedOutput('apple carrot');
999 await act(() => {
1000 resolveTextRequests('apple');
1001 });
1002 assertLog(['Async text requested [apple]', 'apple dill']);
1003 assertConsoleErrorDev([
1004 'A component was suspended by an uncached promise. ' +
1005 'Creating promises inside a Client Component or hook is not yet supported, ' +
1006 'except via a Suspense-compatible library or framework.\n' +
1007 ' in Kitchen (at **)',
1008 ]);
1009
1010 expect(root).toMatchRenderedOutput('apple dill');
1011
1012 // Update the state variable before the use(). The second state is maintained.
1013 await act(() => {
1014 startTransition(() => {
1015 _setFruit('banana');
1016 });
1017 });
1018 assertLog(['Async text requested [banana]']);
1019 expect(root).toMatchRenderedOutput('apple dill');
1020 await act(() => {
1021 resolveTextRequests('banana');
1022 });
1023 assertLog(['Async text requested [banana]', 'banana dill']);
1024 assertConsoleErrorDev([
1025 'A component was suspended by an uncached promise. ' +
1026 'Creating promises inside a Client Component or hook is not yet supported, ' +
1027 'except via a Suspense-compatible library or framework.\n' +
1028 ' in Kitchen (at **)',
1029 ]);
1030 expect(root).toMatchRenderedOutput('banana dill');
1031 });
1032
1033 // @gate enableSuspendingDuringWorkLoop
1034 it('when replaying a suspended component, reuses the hooks computed during the previous attempt (DebugValue+State)', async () => {
1035 // Make sure we don't get a Hook mismatch warning on updates if there were non-stateful Hooks before the use().
1036 let _setLawyer;
1037 function Lexicon() {
1038 useDebugValue(123);
1039 const avocado = use(getAsyncText('aguacate'));
1040 const [lawyer, setLawyer] = useState('abogado');
1041 _setLawyer = setLawyer;
1042 return <Text text={avocado + ' ' + lawyer} />;
1043 }
1044
1045 // Initial render.
1046 const root = ReactNoop.createRoot();
1047 await act(() => {
1048 startTransition(() => {
1049 root.render(<Lexicon />);
1050 });
1051 });
1052 assertLog(['Async text requested [aguacate]']);
1053 expect(root).toMatchRenderedOutput(null);
1054 await act(() => {
1055 resolveTextRequests('aguacate');
1056 });
1057 assertLog(['Async text requested [aguacate]', 'aguacate abogado']);
1058 assertConsoleErrorDev([
1059 'A component was suspended by an uncached promise. ' +
1060 'Creating promises inside a Client Component or hook is not yet supported, ' +
1061 'except via a Suspense-compatible library or framework.\n' +
1062 ' in Lexicon (at **)',
1063 ]);
1064 expect(root).toMatchRenderedOutput('aguacate abogado');
1065
1066 // Now update the state.
1067 await act(() => {
1068 startTransition(() => {
1069 _setLawyer('avocat');
1070 });
1071 });
1072 assertLog(['Async text requested [aguacate]']);
1073 expect(root).toMatchRenderedOutput('aguacate abogado');
1074 await act(() => {
1075 resolveTextRequests('aguacate');
1076 });
1077 assertLog(['Async text requested [aguacate]', 'aguacate avocat']);
1078 assertConsoleErrorDev([
1079 'A component was suspended by an uncached promise. ' +
1080 'Creating promises inside a Client Component or hook is not yet supported, ' +
1081 'except via a Suspense-compatible library or framework.\n' +
1082 ' in Lexicon (at **)',
1083 ]);
1084 expect(root).toMatchRenderedOutput('aguacate avocat');
1085 });
1086
1087 // @gate enableSuspendingDuringWorkLoop
1088 it(
1089 'wrap an async function with useMemo to skip running the function ' +
1090 'twice when loading new data',
1091 async () => {
1092 function App({text}) {
1093 const promiseForText = useMemo(async () => getAsyncText(text), [text]);
1094 const asyncText = use(promiseForText);
1095 return <Text text={asyncText} />;
1096 }
1097
1098 const root = ReactNoop.createRoot();
1099 await act(() => {
1100 startTransition(() => {
1101 root.render(<App text="Hello" />);
1102 });
1103 });
1104 assertLog(['Async text requested [Hello]']);
1105 expect(root).toMatchRenderedOutput(null);
1106
1107 await act(() => {
1108 resolveTextRequests('Hello');
1109 });
1110 assertLog([
1111 // We shouldn't request async text again, because the async function
1112 // was memoized
1113 // 'Async text requested [Hello]'
1114
1115 'Hello',
1116 ]);
1117 },
1118 );
1119
1120 it('load multiple nested Suspense boundaries', async () => {
1121 const promiseA = getAsyncText('A');
1122 const promiseB = getAsyncText('B');
1123 const promiseC = getAsyncText('C');
1124 assertLog([
1125 'Async text requested [A]',
1126 'Async text requested [B]',
1127 'Async text requested [C]',
1128 ]);
1129
1130 function AsyncText({promise}) {
1131 return <Text text={use(promise)} />;
1132 }
1133
1134 const root = ReactNoop.createRoot();
1135 await act(() => {
1136 root.render(
1137 <Suspense fallback={<Text text="(Loading A...)" />}>
1138 <AsyncText promise={promiseA} />
1139 <Suspense fallback={<Text text="(Loading B...)" />}>
1140 <AsyncText promise={promiseB} />
1141 <Suspense fallback={<Text text="(Loading C...)" />}>
1142 <AsyncText promise={promiseC} />
1143 </Suspense>
1144 </Suspense>
1145 </Suspense>,
1146 );
1147 });
1148 assertLog([
1149 '(Loading A...)',
1150 // pre-warming
1151 '(Loading C...)',
1152 '(Loading B...)',
1153 ]);
1154 expect(root).toMatchRenderedOutput('(Loading A...)');
1155
1156 await act(() => {
1157 resolveTextRequests('A');
1158 });
1159 assertLog(['A', '(Loading B...)']);
1160 expect(root).toMatchRenderedOutput('A(Loading B...)');
1161
1162 await act(() => {
1163 resolveTextRequests('B');
1164 });
1165 assertLog(['B', '(Loading C...)']);
1166 expect(root).toMatchRenderedOutput('AB(Loading C...)');
1167
1168 await act(() => {
1169 resolveTextRequests('C');
1170 });
1171 assertLog(['C']);
1172 expect(root).toMatchRenderedOutput('ABC');
1173 });
1174
1175 // @gate enableSuspendingDuringWorkLoop
1176 it('load multiple nested Suspense boundaries (uncached requests)', async () => {
1177 // This the same as the previous test, except the requests are not cached.
1178 // The tree should still eventually resolve, despite the
1179 // duplicate requests.
1180 function AsyncText({text}) {
1181 // This initiates a new request on each render.
1182 return <Text text={use(getAsyncText(text))} />;
1183 }
1184
1185 const root = ReactNoop.createRoot();
1186 await act(() => {
1187 root.render(
1188 <Suspense fallback={<Text text="(Loading A...)" />}>
1189 <AsyncText text="A" />
1190 <Suspense fallback={<Text text="(Loading B...)" />}>
1191 <AsyncText text="B" />
1192 <Suspense fallback={<Text text="(Loading C...)" />}>
1193 <AsyncText text="C" />
1194 </Suspense>
1195 </Suspense>
1196 </Suspense>,
1197 );
1198 });
1199 assertLog(['Async text requested [A]', '(Loading A...)']);
1200 expect(root).toMatchRenderedOutput('(Loading A...)');
1201
1202 await act(() => {
1203 resolveTextRequests('A');
1204 });
1205 assertLog(['Async text requested [A]']);
1206 expect(root).toMatchRenderedOutput('(Loading A...)');
1207
1208 await act(() => {
1209 resolveTextRequests('A');
1210 });
1211 assertLog([
1212 // React suspends until A finishes loading.
1213 'Async text requested [A]',
1214 'A',
1215
1216 // Now React can continue rendering the rest of the tree.
1217
1218 // React does not suspend on the inner requests, because that would
1219 // block A from appearing. Instead it shows a fallback.
1220 'Async text requested [B]',
1221 '(Loading B...)',
1222 ]);
1223 assertConsoleErrorDev([
1224 'A component was suspended by an uncached promise. ' +
1225 'Creating promises inside a Client Component or hook is not yet supported, ' +
1226 'except via a Suspense-compatible library or framework.\n' +
1227 ' in AsyncText (at **)',
1228 ]);
1229 expect(root).toMatchRenderedOutput('A(Loading B...)');
1230
1231 await act(() => {
1232 resolveTextRequests('B');
1233 });
1234 assertLog(['Async text requested [B]']);
1235 expect(root).toMatchRenderedOutput('A(Loading B...)');
1236
1237 await act(() => {
1238 resolveTextRequests('B');
1239 });
1240 assertLog([
1241 // React suspends until B finishes loading.
1242 'Async text requested [B]',
1243 'B',
1244
1245 // React does not suspend on C, because that would block B from appearing.
1246 'Async text requested [C]',
1247 '(Loading C...)',
1248 ]);
1249 assertConsoleErrorDev([
1250 'A component was suspended by an uncached promise. ' +
1251 'Creating promises inside a Client Component or hook is not yet supported, ' +
1252 'except via a Suspense-compatible library or framework.\n' +
1253 ' in AsyncText (at **)',
1254 ]);
1255 expect(root).toMatchRenderedOutput('AB(Loading C...)');
1256
1257 await act(() => {
1258 resolveTextRequests('C');
1259 });
1260 assertLog(['Async text requested [C]']);
1261 expect(root).toMatchRenderedOutput('AB(Loading C...)');
1262
1263 await act(() => {
1264 resolveTextRequests('C');
1265 });
1266 assertLog(['Async text requested [C]', 'C']);
1267 assertConsoleErrorDev([
1268 'A component was suspended by an uncached promise. ' +
1269 'Creating promises inside a Client Component or hook is not yet supported, ' +
1270 'except via a Suspense-compatible library or framework.\n' +
1271 ' in AsyncText (at **)',
1272 ]);
1273 expect(root).toMatchRenderedOutput('ABC');
1274 });
1275
1276 it('use() combined with render phase updates', async () => {
1277 function Async() {
1278 const a = use(Promise.resolve('A'));
1279 const [count, setCount] = useState(0);
1280 if (count === 0) {
1281 setCount(1);
1282 }
1283 const usedCount = use(Promise.resolve(count));
1284 return <Text text={a + usedCount} />;
1285 }
1286
1287 function App() {
1288 return (
1289 <Suspense fallback={<Text text="Loading..." />}>
1290 <Async />
1291 </Suspense>
1292 );
1293 }
1294
1295 const root = ReactNoop.createRoot();
1296 await act(() => {
1297 startTransition(() => {
1298 root.render(<App />);
1299 });
1300 });
1301 assertLog(['A1']);
1302 assertConsoleErrorDev([
1303 'A component was suspended by an uncached promise. ' +
1304 'Creating promises inside a Client Component or hook is not yet supported, ' +
1305 'except via a Suspense-compatible library or framework.\n' +
1306 ' in App (at **)',
1307 'A component was suspended by an uncached promise. ' +
1308 'Creating promises inside a Client Component or hook is not yet supported, ' +
1309 'except via a Suspense-compatible library or framework.\n' +
1310 ' in App (at **)',
1311 ]);
1312 expect(root).toMatchRenderedOutput('A1');
1313 });
1314
1315 it('basic promise as child', async () => {
1316 const promise = Promise.resolve(<Text text="Hi" />);
1317 const root = ReactNoop.createRoot();
1318 await act(() => {
1319 startTransition(() => {
1320 root.render(promise);
1321 });
1322 });
1323 assertLog(['Hi']);
1324 expect(root).toMatchRenderedOutput('Hi');
1325 });
1326
1327 // @gate enableSuspendingDuringWorkLoop
1328 it('basic async component', async () => {
1329 async function App() {
1330 await getAsyncText('Hi');
1331 return <Text text="Hi" />;
1332 }
1333
1334 const root = ReactNoop.createRoot();
1335 await act(() => {
1336 startTransition(() => {
1337 root.render(<App />);
1338 });
1339 });
1340 assertLog(['Async text requested [Hi]']);
1341 assertConsoleErrorDev([
1342 '<App> is an async Client Component. ' +
1343 'Only Server Components can be async at the moment. ' +
1344 "This error is often caused by accidentally adding `'use client'` " +
1345 'to a module that was originally written for the server.\n' +
1346 ' in App (at **)',
1347 ]);
1348 await act(() => resolveTextRequests('Hi'));
1349 assertLog([
1350 // TODO: We shouldn't have to replay the function body again. Skip
1351 // straight to reconciliation.
1352 'Async text requested [Hi]',
1353 'Hi',
1354 ]);
1355 assertConsoleErrorDev([
1356 'A component was suspended by an uncached promise. ' +
1357 'Creating promises inside a Client Component or hook is not yet supported, ' +
1358 'except via a Suspense-compatible library or framework.\n' +
1359 ' in App (at **)',
1360 ]);
1361 expect(root).toMatchRenderedOutput('Hi');
1362 });
1363
1364 // @gate enableSuspendingDuringWorkLoop
1365 it('async child of a non-function component (e.g. a class)', async () => {
1366 class App extends React.Component {
1367 async render() {
1368 const text = await getAsyncText('Hi');
1369 return <Text text={text} />;
1370 }
1371 }
1372
1373 const root = ReactNoop.createRoot();
1374 await act(async () => {
1375 startTransition(() => {
1376 root.render(<App />);
1377 });
1378 });
1379 assertLog(['Async text requested [Hi]']);
1380
1381 await act(async () => resolveTextRequests('Hi'));
1382 assertLog([
1383 // TODO: We shouldn't have to replay the render function again. We could
1384 // skip straight to reconciliation. However, it's not as urgent to fix
1385 // this for fiber types that aren't function components, so we can special
1386 // case those in the meantime.
1387 'Async text requested [Hi]',
1388 'Hi',
1389 ]);
1390 assertConsoleErrorDev([
1391 'A component was suspended by an uncached promise. ' +
1392 'Creating promises inside a Client Component or hook is not yet supported, ' +
1393 'except via a Suspense-compatible library or framework.\n' +
1394 ' in App (at **)',
1395 ]);
1396 expect(root).toMatchRenderedOutput('Hi');
1397 });
1398
1399 it('async children are recursively unwrapped', async () => {
1400 // This is a Usable of a Usable. `use` would only unwrap a single level, but
1401 // when passed as a child, the reconciler recurisvely unwraps until it
1402 // resolves to a non-Usable value.
1403 const thenable = {
1404 then() {},
1405 status: 'fulfilled',
1406 value: {
1407 then() {},
1408 status: 'fulfilled',
1409 value: <Text text="Hi" />,
1410 },
1411 };
1412 const root = ReactNoop.createRoot();
1413 await act(() => {
1414 root.render(thenable);
1415 });
1416 assertLog(['Hi']);
1417 expect(root).toMatchRenderedOutput('Hi');
1418 });
1419
1420 it('async children are transparently unwrapped before being reconciled (top level)', async () => {
1421 function Child({text}) {
1422 useEffect(() => {
1423 Scheduler.log(`Mount: ${text}`);
1424 }, [text]);
1425 return <Text text={text} />;
1426 }
1427
1428 async function App({text}) {
1429 // The child returned by this component is always a promise (async
1430 // functions always return promises). React should unwrap it and reconcile
1431 // the result, not the promise itself.
1432 return <Child text={text} />;
1433 }
1434
1435 const root = ReactNoop.createRoot();
1436 await act(() => {
1437 startTransition(() => {
1438 root.render(<App text="A" />);
1439 });
1440 });
1441 assertLog(['A', 'Mount: A']);
1442 assertConsoleErrorDev([
1443 '<App> is an async Client Component. ' +
1444 'Only Server Components can be async at the moment. ' +
1445 "This error is often caused by accidentally adding `'use client'` " +
1446 'to a module that was originally written for the server.\n' +
1447 ' in App (at **)',
1448 'A component was suspended by an uncached promise. ' +
1449 'Creating promises inside a Client Component or hook is not yet supported, ' +
1450 'except via a Suspense-compatible library or framework.\n' +
1451 ' in App (at **)',
1452 ]);
1453 expect(root).toMatchRenderedOutput('A');
1454
1455 // Update the child's props. It should not remount.
1456 await act(() => {
1457 startTransition(() => {
1458 root.render(<App text="B" />);
1459 });
1460 });
1461 assertLog(['B', 'Mount: B']);
1462 assertConsoleErrorDev([
1463 'A component was suspended by an uncached promise. ' +
1464 'Creating promises inside a Client Component or hook is not yet supported, ' +
1465 'except via a Suspense-compatible library or framework.\n' +
1466 ' in App (at **)',
1467 ]);
1468 expect(root).toMatchRenderedOutput('B');
1469 });
1470
1471 it('async children are transparently unwrapped before being reconciled (siblings)', async () => {
1472 function Child({text}) {
1473 useEffect(() => {
1474 Scheduler.log(`Mount: ${text}`);
1475 }, [text]);
1476 return <Text text={text} />;
1477 }
1478
1479 const root = ReactNoop.createRoot();
1480 await act(async () => {
1481 startTransition(() => {
1482 root.render(
1483 <>
1484 {Promise.resolve(<Child text="A" />)}
1485 {Promise.resolve(<Child text="B" />)}
1486 {Promise.resolve(<Child text="C" />)}
1487 </>,
1488 );
1489 });
1490 });
1491 assertLog(['A', 'B', 'C', 'Mount: A', 'Mount: B', 'Mount: C']);
1492 expect(root).toMatchRenderedOutput('ABC');
1493
1494 await act(() => {
1495 startTransition(() => {
1496 root.render(
1497 <>
1498 {Promise.resolve(<Child text="A" />)}
1499 {Promise.resolve(<Child text="B" />)}
1500 {Promise.resolve(<Child text="C" />)}
1501 </>,
1502 );
1503 });
1504 });
1505 // Nothing should have remounted
1506 assertLog(['A', 'B', 'C']);
1507 expect(root).toMatchRenderedOutput('ABC');
1508 });
1509
1510 it('async children are transparently unwrapped before being reconciled (siblings, reordered)', async () => {
1511 function Child({text}) {
1512 useEffect(() => {
1513 Scheduler.log(`Mount: ${text}`);
1514 }, [text]);
1515 return <Text text={text} />;
1516 }
1517
1518 const root = ReactNoop.createRoot();
1519 await act(() => {
1520 startTransition(() => {
1521 root.render(
1522 <>
1523 {Promise.resolve(<Child key="A" text="A" />)}
1524 {Promise.resolve(<Child key="B" text="B" />)}
1525 {Promise.resolve(<Child key="C" text="C" />)}
1526 </>,
1527 );
1528 });
1529 });
1530 assertLog(['A', 'B', 'C', 'Mount: A', 'Mount: B', 'Mount: C']);
1531 expect(root).toMatchRenderedOutput('ABC');
1532
1533 await act(() => {
1534 startTransition(() => {
1535 root.render(
1536 <>
1537 {Promise.resolve(<Child key="B" text="B" />)}
1538 {Promise.resolve(<Child key="A" text="A" />)}
1539 {Promise.resolve(<Child key="C" text="C" />)}
1540 </>,
1541 );
1542 });
1543 });
1544 // Nothing should have remounted
1545 assertLog(['B', 'A', 'C']);
1546 expect(root).toMatchRenderedOutput('BAC');
1547 });
1548
1549 it('basic Context as node', async () => {
1550 const Context = React.createContext(null);
1551
1552 function Indirection({children}) {
1553 Scheduler.log('Indirection');
1554 return children;
1555 }
1556
1557 function ParentOfContextNode() {
1558 Scheduler.log('ParentOfContextNode');
1559 return Context;
1560 }
1561
1562 function Child({text}) {
1563 useEffect(() => {
1564 Scheduler.log('Mount');
1565 return () => {
1566 Scheduler.log('Unmount');
1567 };
1568 }, []);
1569 return <Text text={text} />;
1570 }
1571
1572 function App({contextValue, children}) {
1573 const memoizedChildren = useMemo(
1574 () => (
1575 <Indirection>
1576 <ParentOfContextNode />
1577 </Indirection>
1578 ),
1579 [children],
1580 );
1581 return (
1582 <Context.Provider value={contextValue}>
1583 {memoizedChildren}
1584 </Context.Provider>
1585 );
1586 }
1587
1588 // Initial render
1589 const root = ReactNoop.createRoot();
1590 await act(() => {
1591 root.render(<App contextValue={<Child text="A" />} />);
1592 });
1593 assertLog(['Indirection', 'ParentOfContextNode', 'A', 'Mount']);
1594 expect(root).toMatchRenderedOutput('A');
1595
1596 // Update the child to a new value
1597 await act(async () => {
1598 root.render(<App contextValue={<Child text="B" />} />);
1599 });
1600 assertLog([
1601 // Notice that the <Indirection /> did not rerender, because the
1602 // update was sent via Context.
1603
1604 // TODO: We shouldn't have to re-render the parent of the context node.
1605 // This happens because we need to reconcile the parent's children again.
1606 // However, we should be able to skip directly to reconcilation without
1607 // evaluating the component. One way to do this might be to mark the
1608 // context dependency with a flag that says it was added
1609 // during reconcilation.
1610 'ParentOfContextNode',
1611
1612 // Notice that this was an update, not a remount.
1613 'B',
1614 ]);
1615 expect(root).toMatchRenderedOutput('B');
1616
1617 // Delete the old child and replace it with a new one, by changing the key
1618 await act(async () => {
1619 root.render(<App contextValue={<Child key="C" text="C" />} />);
1620 });
1621 assertLog([
1622 'ParentOfContextNode',
1623
1624 // A new instance is mounted
1625 'C',
1626 'Unmount',
1627 'Mount',
1628 ]);
1629 });
1630
1631 it('context as node, at the root', async () => {
1632 const Context = React.createContext(<Text text="Hi" />);
1633 const root = ReactNoop.createRoot();
1634 await act(async () => {
1635 startTransition(() => {
1636 root.render(Context);
1637 });
1638 });
1639 assertLog(['Hi']);
1640 expect(root).toMatchRenderedOutput('Hi');
1641 });
1642
1643 it('promises that resolves to a context, rendered as a node', async () => {
1644 const Context = React.createContext(<Text text="Hi" />);
1645 const promise = Promise.resolve(Context);
1646 const root = ReactNoop.createRoot();
1647 await act(async () => {
1648 startTransition(() => {
1649 root.render(promise);
1650 });
1651 });
1652 assertLog(['Hi']);
1653 expect(root).toMatchRenderedOutput('Hi');
1654 });
1655
1656 it('unwrap uncached promises inside forwardRef', async () => {
1657 const asyncInstance = {};
1658 const Async = React.forwardRef((props, ref) => {
1659 React.useImperativeHandle(ref, () => asyncInstance);
1660 const text = use(Promise.resolve('Async'));
1661 return <Text text={text} />;
1662 });
1663
1664 const ref = React.createRef();
1665 function App() {
1666 return (
1667 <Suspense fallback={<Text text="Loading..." />}>
1668 <Async ref={ref} />
1669 </Suspense>
1670 );
1671 }
1672
1673 const root = ReactNoop.createRoot();
1674 await act(() => {
1675 startTransition(() => {
1676 root.render(<App />);
1677 });
1678 });
1679 assertLog(['Async']);
1680 assertConsoleErrorDev([
1681 'A component was suspended by an uncached promise. ' +
1682 'Creating promises inside a Client Component or hook is not yet supported, ' +
1683 'except via a Suspense-compatible library or framework.\n' +
1684 ' in App (at **)',
1685 ]);
1686 expect(root).toMatchRenderedOutput('Async');
1687 expect(ref.current).toBe(asyncInstance);
1688 });
1689
1690 it('unwrap uncached promises inside memo', async () => {
1691 const Async = React.memo(
1692 props => {
1693 const text = use(Promise.resolve(props.text));
1694 return <Text text={text} />;
1695 },
1696 (a, b) => a.text === b.text,
1697 );
1698
1699 function App({text}) {
1700 return (
1701 <Suspense fallback={<Text text="Loading..." />}>
1702 <Async text={text} />
1703 </Suspense>
1704 );
1705 }
1706
1707 const root = ReactNoop.createRoot();
1708 await act(() => {
1709 startTransition(() => {
1710 root.render(<App text="Async" />);
1711 });
1712 });
1713 assertLog(['Async']);
1714 assertConsoleErrorDev([
1715 'A component was suspended by an uncached promise. ' +
1716 'Creating promises inside a Client Component or hook is not yet supported, ' +
1717 'except via a Suspense-compatible library or framework.\n' +
1718 ' in App (at **)',
1719 ]);
1720 expect(root).toMatchRenderedOutput('Async');
1721
1722 // Update to the same value
1723 await act(() => {
1724 startTransition(() => {
1725 root.render(<App text="Async" />);
1726 });
1727 });
1728 // Should not have re-rendered, because it's memoized
1729 assertLog([]);
1730 expect(root).toMatchRenderedOutput('Async');
1731
1732 // Update to a different value
1733 await act(() => {
1734 startTransition(() => {
1735 root.render(<App text="Async!" />);
1736 });
1737 });
1738 assertLog(['Async!']);
1739 assertConsoleErrorDev([
1740 'A component was suspended by an uncached promise. ' +
1741 'Creating promises inside a Client Component or hook is not yet supported, ' +
1742 'except via a Suspense-compatible library or framework.\n' +
1743 ' in App (at **)',
1744 ]);
1745 expect(root).toMatchRenderedOutput('Async!');
1746 });
1747
1748 // @gate !disableLegacyContext && !disableLegacyContextForFunctionComponents
1749 it('unwrap uncached promises in component that accesses legacy context', async () => {
1750 class ContextProvider extends React.Component {
1751 static childContextTypes = {
1752 legacyContext() {},
1753 };
1754 getChildContext() {
1755 return {legacyContext: 'Async'};
1756 }
1757 render() {
1758 return this.props.children;
1759 }
1760 }
1761
1762 function Async({label}, context) {
1763 const text = use(Promise.resolve(context.legacyContext + ` (${label})`));
1764 return <Text text={text} />;
1765 }
1766 Async.contextTypes = {
1767 legacyContext: () => {},
1768 };
1769
1770 const AsyncMemo = React.memo(Async, (a, b) => a.label === b.label);
1771
1772 function App() {
1773 return (
1774 <ContextProvider>
1775 <Suspense fallback={<Text text="Loading..." />}>
1776 <div>
1777 <Async label="function component" />
1778 </div>
1779 <div>
1780 <AsyncMemo label="memo component" />
1781 </div>
1782 </Suspense>
1783 </ContextProvider>
1784 );
1785 }
1786
1787 const root = ReactNoop.createRoot();
1788 await act(() => {
1789 startTransition(() => {
1790 root.render(<App />);
1791 });
1792 });
1793 assertLog(['Async (function component)', 'Async (memo component)']);
1794 assertConsoleErrorDev([
1795 'ContextProvider uses the legacy childContextTypes API which will soon be removed. ' +
1796 'Use React.createContext() instead. (https://react.dev/link/legacy-context)\n' +
1797 ' in App (at **)',
1798 'Async uses the legacy contextTypes API which will be removed soon. ' +
1799 'Use React.createContext() with React.useContext() instead. (https://react.dev/link/legacy-context)\n' +
1800 ' in App (at **)',
1801 'A component was suspended by an uncached promise. ' +
1802 'Creating promises inside a Client Component or hook is not yet supported, ' +
1803 'except via a Suspense-compatible library or framework.\n' +
1804 ' in App (at **)',
1805 'A component was suspended by an uncached promise. ' +
1806 'Creating promises inside a Client Component or hook is not yet supported, ' +
1807 'except via a Suspense-compatible library or framework.\n' +
1808 ' in App (at **)',
1809 ]);
1810 expect(root).toMatchRenderedOutput(
1811 <>
1812 <div>Async (function component)</div>
1813 <div>Async (memo component)</div>
1814 </>,
1815 );
1816 });
1817
1818 it('regression test: updates while component is suspended should not be mistaken for render phase updates', async () => {
1819 const promiseA = getAsyncText('A');
1820 const promiseB = getAsyncText('B');
1821 const promiseC = getAsyncText('C');
1822 assertLog([
1823 'Async text requested [A]',
1824 'Async text requested [B]',
1825 'Async text requested [C]',
1826 ]);
1827
1828 let setState;
1829 function App() {
1830 const [state, _setState] = useState(promiseA);
1831 setState = _setState;
1832 return <Text text={use(state)} />;
1833 }
1834
1835 // Initial render
1836 const root = ReactNoop.createRoot();
1837 await act(() => root.render(<App />));
1838 expect(root).toMatchRenderedOutput(null);
1839 await act(() => resolveTextRequests('A'));
1840 assertLog(['A']);
1841 expect(root).toMatchRenderedOutput('A');
1842
1843 // Update to B. This will suspend.
1844 await act(() => startTransition(() => setState(promiseB)));
1845 expect(root).toMatchRenderedOutput('A');
1846
1847 // While B is suspended, update to C. This should immediately interrupt
1848 // the render for B. In the regression, this update was mistakenly treated
1849 // as a render phase update.
1850 ReactNoop.flushSync(() => setState(promiseC));
1851
1852 // Finish rendering.
1853 await act(() => resolveTextRequests('C'));
1854 assertLog(['C']);
1855 expect(root).toMatchRenderedOutput('C');
1856 });
1857
1858 it('an async component outside of a Suspense boundary crashes with an error (resolves in microtask)', async () => {
1859 class ErrorBoundary extends React.Component {
1860 state = {error: null};
1861 static getDerivedStateFromError(error) {
1862 return {error};
1863 }
1864 render() {
1865 if (this.state.error) {
1866 return <Text text={this.state.error.message} />;
1867 }
1868 return this.props.children;
1869 }
1870 }
1871
1872 async function AsyncClientComponent() {
1873 return <Text text="Hi" />;
1874 }
1875
1876 const root = ReactNoop.createRoot();
1877 await act(async () => {
1878 root.render(
1879 <ErrorBoundary>
1880 <AsyncClientComponent />
1881 </ErrorBoundary>,
1882 );
1883 });
1884 assertConsoleErrorDev([
1885 '<AsyncClientComponent> is an async Client Component. ' +
1886 'Only Server Components can be async at the moment. ' +
1887 "This error is often caused by accidentally adding `'use client'` " +
1888 'to a module that was originally written for the server.\n' +
1889 ' in AsyncClientComponent (at **)',
1890 ]);
1891 assertLog([
1892 'An unknown Component is an async Client Component. ' +
1893 'Only Server Components can be async at the moment. ' +
1894 'This error is often caused by accidentally adding ' +
1895 "`'use client'` to a module that was originally written for " +
1896 'the server.',
1897 'An unknown Component is an async Client Component. ' +
1898 'Only Server Components can be async at the moment. ' +
1899 'This error is often caused by accidentally adding ' +
1900 "`'use client'` to a module that was originally written for " +
1901 'the server.',
1902 ]);
1903 expect(root).toMatchRenderedOutput(
1904 'An unknown Component is an async Client Component. ' +
1905 'Only Server Components can be async at the moment. ' +
1906 'This error is often caused by accidentally adding ' +
1907 "`'use client'` to a module that was originally written for " +
1908 'the server.',
1909 );
1910 });
1911
1912 it('an async component outside of a Suspense boundary crashes with an error (resolves in macrotask)', async () => {
1913 class ErrorBoundary extends React.Component {
1914 state = {error: null};
1915 static getDerivedStateFromError(error) {
1916 return {error};
1917 }
1918 render() {
1919 if (this.state.error) {
1920 return <Text text={this.state.error.message} />;
1921 }
1922 return this.props.children;
1923 }
1924 }
1925
1926 async function AsyncClientComponent() {
1927 await waitForMicrotasks();
1928 return <Text text="Hi" />;
1929 }
1930
1931 const root = ReactNoop.createRoot();
1932 await act(async () => {
1933 root.render(
1934 <ErrorBoundary>
1935 <AsyncClientComponent />
1936 </ErrorBoundary>,
1937 );
1938 });
1939 assertConsoleErrorDev([
1940 '<AsyncClientComponent> is an async Client Component. ' +
1941 'Only Server Components can be async at the moment. ' +
1942 "This error is often caused by accidentally adding `'use client'` " +
1943 'to a module that was originally written for the server.\n' +
1944 ' in AsyncClientComponent (at **)',
1945 ]);
1946 assertLog([
1947 'An unknown Component is an async Client Component. ' +
1948 'Only Server Components can be async at the moment. ' +
1949 'This error is often caused by accidentally adding ' +
1950 "`'use client'` to a module that was originally written for " +
1951 'the server.',
1952 'An unknown Component is an async Client Component. ' +
1953 'Only Server Components can be async at the moment. ' +
1954 'This error is often caused by accidentally adding ' +
1955 "`'use client'` to a module that was originally written for " +
1956 'the server.',
1957 ]);
1958 expect(root).toMatchRenderedOutput(
1959 'An unknown Component is an async Client Component. ' +
1960 'Only Server Components can be async at the moment. ' +
1961 'This error is often caused by accidentally adding ' +
1962 "`'use client'` to a module that was originally written for " +
1963 'the server.',
1964 );
1965 });
1966
1967 it(
1968 'warn if async client component calls a hook (e.g. useState) ' +
1969 'during a non-sync update',
1970 async () => {
1971 async function AsyncClientComponent() {
1972 useState();
1973 return <Text text="Hi" />;
1974 }
1975
1976 const root = ReactNoop.createRoot();
1977 await act(() => {
1978 startTransition(() => {
1979 root.render(<AsyncClientComponent />);
1980 });
1981 });
1982 assertConsoleErrorDev([
1983 // Note: This used to log a different warning about not using hooks
1984 // inside async components, like we do on the server. Since then, we
1985 // decided to warn for _any_ async client component regardless of
1986 // whether the update is sync. But if we ever add back support for async
1987 // client components, we should add back the hook warning.
1988 '<AsyncClientComponent> is an async Client Component. ' +
1989 'Only Server Components can be async at the moment. ' +
1990 "This error is often caused by accidentally adding `'use client'` " +
1991 'to a module that was originally written for the server.\n' +
1992 ' in AsyncClientComponent (at **)',
1993 'A component was suspended by an uncached promise. ' +
1994 'Creating promises inside a Client Component or hook is not yet supported, ' +
1995 'except via a Suspense-compatible library or framework.\n' +
1996 ' in AsyncClientComponent (at **)',
1997 ]);
1998 },
1999 );
2000
2001 it('warn if async client component calls a hook (e.g. use)', async () => {
2002 const promise = Promise.resolve();
2003
2004 async function AsyncClientComponent() {
2005 use(promise);
2006 return <Text text="Hi" />;
2007 }
2008
2009 const root = ReactNoop.createRoot();
2010 await act(() => {
2011 startTransition(() => {
2012 root.render(<AsyncClientComponent />);
2013 });
2014 });
2015 assertConsoleErrorDev([
2016 // Note: This used to log a different warning about not using hooks
2017 // inside async components, like we do on the server. Since then, we
2018 // decided to warn for _any_ async client component regardless of
2019 // whether the update is sync. But if we ever add back support for async
2020 // client components, we should add back the hook warning.
2021 '<AsyncClientComponent> is an async Client Component. ' +
2022 'Only Server Components can be async at the moment. ' +
2023 "This error is often caused by accidentally adding `'use client'` " +
2024 'to a module that was originally written for the server.\n' +
2025 ' in AsyncClientComponent (at **)',
2026 'A component was suspended by an uncached promise. ' +
2027 'Creating promises inside a Client Component or hook is not yet supported, ' +
2028 'except via a Suspense-compatible library or framework.\n' +
2029 ' in AsyncClientComponent (at **)',
2030 'A component was suspended by an uncached promise. ' +
2031 'Creating promises inside a Client Component or hook is not yet supported, ' +
2032 'except via a Suspense-compatible library or framework.\n' +
2033 ' in AsyncClientComponent (at **)',
2034 ]);
2035 });
2036
2037 // @gate enableAsyncIterableChildren
2038 it('async generator component', async () => {
2039 let hi, world;
2040 async function* App() {
2041 // Only cached promises can be awaited in async generators because
2042 // when we rerender, it'll issue another request which blocks the next.
2043 await (hi || (hi = getAsyncText('Hi')));
2044 yield <Text key="1" text="Hi" />;
2045 yield ' ';
2046 await (world || (world = getAsyncText('World')));
2047 yield <Text key="2" text="World" />;
2048 }
2049
2050 const root = ReactNoop.createRoot();
2051 await act(() => {
2052 startTransition(() => {
2053 root.render(<App />);
2054 });
2055 });
2056 assertConsoleErrorDev([
2057 '<App> is an async Client Component. ' +
2058 'Only Server Components can be async at the moment. ' +
2059 "This error is often caused by accidentally adding `'use client'` " +
2060 'to a module that was originally written for the server.\n' +
2061 ' in App (at **)',
2062 ]);
2063 assertLog(['Async text requested [Hi]']);
2064
2065 await act(() => resolveTextRequests('Hi'));
2066 assertConsoleErrorDev([
2067 // We get this warning because the generator's promise themselves are not cached.
2068 'A component was suspended by an uncached promise. ' +
2069 'Creating promises inside a Client Component or hook is not yet supported, ' +
2070 'except via a Suspense-compatible library or framework.\n' +
2071 ' in App (at **)',
2072 ]);
2073
2074 assertLog(['Async text requested [World]']);
2075
2076 await act(() => resolveTextRequests('World'));
2077 assertConsoleErrorDev([
2078 'A component was suspended by an uncached promise. ' +
2079 'Creating promises inside a Client Component or hook is not yet supported, ' +
2080 'except via a Suspense-compatible library or framework.\n' +
2081 ' in App (at **)',
2082 ]);
2083
2084 assertLog(['Hi', 'World']);
2085 expect(root).toMatchRenderedOutput('Hi World');
2086 });
2087
2088 // @gate enableAsyncIterableChildren
2089 it('async iterable children', async () => {
2090 let hi, world;
2091 const iterable = {
2092 async *[Symbol.asyncIterator]() {
2093 // Only cached promises can be awaited in async iterables because
2094 // when we retry, it'll ask for another iterator which issues another
2095 // request which blocks the next.
2096 await (hi || (hi = getAsyncText('Hi')));
2097 yield <Text key="1" text="Hi" />;
2098 yield ' ';
2099 await (world || (world = getAsyncText('World')));
2100 yield <Text key="2" text="World" />;
2101 },
2102 };
2103
2104 function App({children}) {
2105 return <div>{children}</div>;
2106 }
2107
2108 const root = ReactNoop.createRoot();
2109 await act(() => {
2110 startTransition(() => {
2111 root.render(<App>{iterable}</App>);
2112 });
2113 });
2114 assertLog(['Async text requested [Hi]']);
2115
2116 await act(() => resolveTextRequests('Hi'));
2117 assertConsoleErrorDev([
2118 // We get this warning because the generator's promise themselves are not cached.
2119 'A component was suspended by an uncached promise. ' +
2120 'Creating promises inside a Client Component or hook is not yet supported, ' +
2121 'except via a Suspense-compatible library or framework.\n' +
2122 ' in div (at **)\n' +
2123 ' in App (at **)',
2124 ]);
2125
2126 assertLog(['Async text requested [World]']);
2127
2128 await act(() => resolveTextRequests('World'));
2129 assertConsoleErrorDev([
2130 'A component was suspended by an uncached promise. ' +
2131 'Creating promises inside a Client Component or hook is not yet supported, ' +
2132 'except via a Suspense-compatible library or framework.\n' +
2133 ' in div (at **)\n' +
2134 ' in App (at **)',
2135 ]);
2136
2137 assertLog(['Hi', 'World']);
2138 expect(root).toMatchRenderedOutput(<div>Hi World</div>);
2139 });
2140
2141 it(
2142 'regression: does not get stuck in pending state after `use` suspends ' +
2143 '(when `use` comes before all hooks)',
2144 async () => {
2145 // This is a regression test. The root cause was an issue where we failed to
2146 // switch from the "re-render" dispatcher back to the "update" dispatcher
2147 // after a `use` suspends and triggers a replay.
2148 let update;
2149 function App({promise}) {
2150 const value = use(promise);
2151
2152 const [isPending, startLocalTransition] = useTransition();
2153 update = () => {
2154 startLocalTransition(() => {
2155 root.render(<App promise={getAsyncText('Updated')} />);
2156 });
2157 };
2158
2159 return <Text text={value + (isPending ? ' (pending...)' : '')} />;
2160 }
2161
2162 const root = ReactNoop.createRoot();
2163 await act(() => {
2164 root.render(<App promise={Promise.resolve('Initial')} />);
2165 });
2166 assertLog(['Initial']);
2167 expect(root).toMatchRenderedOutput('Initial');
2168
2169 await act(() => update());
2170 assertLog(['Async text requested [Updated]', 'Initial (pending...)']);
2171
2172 await act(() => resolveTextRequests('Updated'));
2173 assertLog(['Updated']);
2174 expect(root).toMatchRenderedOutput('Updated');
2175 },
2176 );
2177
2178 it(
2179 'regression: does not get stuck in pending state after `use` suspends ' +
2180 '(when `use` in in the middle of hook list)',
2181 async () => {
2182 // Same as previous test but `use` comes in between two hooks.
2183 let update;
2184 function App({promise}) {
2185 // This hook is only here to test that `use` resumes correctly after
2186 // suspended even if it comes in between other hooks.
2187 useState(false);
2188
2189 const value = use(promise);
2190
2191 const [isPending, startLocalTransition] = useTransition();
2192 update = () => {
2193 startLocalTransition(() => {
2194 root.render(<App promise={getAsyncText('Updated')} />);
2195 });
2196 };
2197
2198 return <Text text={value + (isPending ? ' (pending...)' : '')} />;
2199 }
2200
2201 const root = ReactNoop.createRoot();
2202 await act(() => {
2203 root.render(<App promise={Promise.resolve('Initial')} />);
2204 });
2205 assertLog(['Initial']);
2206 expect(root).toMatchRenderedOutput('Initial');
2207
2208 await act(() => update());
2209 assertLog(['Async text requested [Updated]', 'Initial (pending...)']);
2210
2211 await act(() => resolveTextRequests('Updated'));
2212 assertLog(['Updated']);
2213 expect(root).toMatchRenderedOutput('Updated');
2214 },
2215 );
2216
2217 it('throws a descriptive error when a rejected promise without a reason property is passed to use()', async () => {
2218 const badThenable = Promise.resolve();
2219 // Simulate bad instrumentation: status is 'rejected' but the reason is not
2220 // in the `reason` property as intended.
2221 badThenable.status = 'rejected';
2222 badThenable.error = new Error('Something went wrong');
2223
2224 function Child() {
2225 return use(badThenable);
2226 }
2227
2228 function App({
2229 // Intentionally destrucutring a prop here so that our production error
2230 // stack trick is triggered at the beginning of the function
2231 prop,
2232 }) {
2233 return <Child />;
2234 }
2235
2236 const uncaughtErrors = [];
2237 const root = ReactNoop.createRoot({
2238 onUncaughtError(error, errorInfo) {
2239 uncaughtErrors.push({
2240 callStack: normalizeCodeLocInfo(error.stack),
2241 ownerStack: React.captureOwnerStack
2242 ? normalizeCodeLocInfo(React.captureOwnerStack())
2243 : null,
2244 componentStack: normalizeCodeLocInfo(errorInfo.componentStack),
2245 });
2246 },
2247 });
2248
2249 await act(() => {
2250 root.render(<App />);
2251 });
2252
2253 expect(uncaughtErrors).toEqual([
2254 {
2255 callStack:
2256 'Error: A rejected Promise was passed to React without a `reason` property. ' +
2257 'React threw a generic error from where the Promise was used to assist in identifying the problematic Promise. ' +
2258 "Make sure that instrumented Promises correctly set the `reason` property when setting `status` to `'rejected'`." +
2259 '\n in Child (~/ReactUse-test.js:*:*)' +
2260 '\n in Object.<anonymous> (~/ReactUse-test.js:*:*)',
2261 componentStack:
2262 '\n in Child (~/ReactUse-test.js:*:*)' +
2263 '\n in App (~/ReactUse-test.js:*:*)',
2264 ownerStack: __DEV__ ? '\n in App (~/ReactUse-test.js:*:*)' : null,
2265 },
2266 ]);
2267 });
2268
2269 it('throws a descriptive error when a rejected promise without a reason property is used as a child', async () => {
2270 const badThenable = Promise.resolve();
2271 // Simulate bad instrumentation: status is 'rejected' but the reason is not
2272 // in the `reason` property as intended.
2273 badThenable.status = 'rejected';
2274 badThenable.error = new Error('Something went wrong');
2275
2276 function Child() {
2277 return <div>{badThenable}</div>;
2278 }
2279
2280 function App({
2281 // Intentionally destrucutring a prop here so that our production error
2282 // stack trick is triggered at the beginning of the function
2283 prop,
2284 }) {
2285 return <Child />;
2286 }
2287
2288 const uncaughtErrors = [];
2289 const root = ReactNoop.createRoot({
2290 onUncaughtError(error, errorInfo) {
2291 uncaughtErrors.push({
2292 callStack: normalizeCodeLocInfo(error.stack),
2293 ownerStack: React.captureOwnerStack
2294 ? normalizeCodeLocInfo(React.captureOwnerStack())
2295 : null,
2296 componentStack: normalizeCodeLocInfo(errorInfo.componentStack),
2297 });
2298 },
2299 });
2300
2301 await act(() => {
2302 root.render(<App />);
2303 });
2304
2305 expect(uncaughtErrors).toEqual([
2306 {
2307 callStack:
2308 'Error: A rejected Promise was passed to React without a `reason` property. ' +
2309 'React threw a generic error from where the Promise was used to assist in identifying the problematic Promise. ' +
2310 "Make sure that instrumented Promises correctly set the `reason` property when setting `status` to `'rejected'`." +
2311 '\n in Object.<anonymous> (~/ReactUse-test.js:*:*)',
2312 componentStack: '\n in App (~/ReactUse-test.js:*:*)',
2313 ownerStack: __DEV__
2314 ? // TODO: Should start in Child since that's the owner.
2315 '\n in App (~/ReactUse-test.js:*:*)'
2316 : null,
2317 },
2318 ]);
2319 });
2320 });