main
js 1,868 lines 51.7 KB
Raw
1 let React;
2 let ReactNoop;
3 let Scheduler;
4 let act;
5 let assertLog;
6 let useTransition;
7 let useState;
8 let useOptimistic;
9 let textCache;
10 let assertConsoleErrorDev;
11
12 describe('ReactAsyncActions', () => {
13 beforeEach(() => {
14 jest.resetModules();
15
16 global.reportError = error => {
17 Scheduler.log('reportError: ' + error.message);
18 };
19
20 React = require('react');
21 ReactNoop = require('react-noop-renderer');
22 Scheduler = require('scheduler');
23 act = require('internal-test-utils').act;
24 assertLog = require('internal-test-utils').assertLog;
25 assertConsoleErrorDev =
26 require('internal-test-utils').assertConsoleErrorDev;
27 useTransition = React.useTransition;
28 useState = React.useState;
29 useOptimistic = React.useOptimistic;
30
31 textCache = new Map();
32 });
33
34 function resolveText(text) {
35 const record = textCache.get(text);
36 if (record === undefined) {
37 const newRecord = {
38 status: 'resolved',
39 value: text,
40 };
41 textCache.set(text, newRecord);
42 } else if (record.status === 'pending') {
43 const thenable = record.value;
44 record.status = 'resolved';
45 record.value = text;
46 thenable.pings.forEach(t => t());
47 }
48 }
49
50 function readText(text) {
51 const record = textCache.get(text);
52 if (record !== undefined) {
53 switch (record.status) {
54 case 'pending':
55 Scheduler.log(`Suspend! [${text}]`);
56 throw record.value;
57 case 'rejected':
58 throw record.value;
59 case 'resolved':
60 return record.value;
61 }
62 } else {
63 Scheduler.log(`Suspend! [${text}]`);
64 const thenable = {
65 pings: [],
66 then(resolve) {
67 if (newRecord.status === 'pending') {
68 thenable.pings.push(resolve);
69 } else {
70 Promise.resolve().then(() => resolve(newRecord.value));
71 }
72 },
73 };
74
75 const newRecord = {
76 status: 'pending',
77 value: thenable,
78 };
79 textCache.set(text, newRecord);
80
81 throw thenable;
82 }
83 }
84
85 function getText(text) {
86 const record = textCache.get(text);
87 if (record === undefined) {
88 const thenable = {
89 pings: [],
90 then(resolve) {
91 if (newRecord.status === 'pending') {
92 thenable.pings.push(resolve);
93 } else {
94 Promise.resolve().then(() => resolve(newRecord.value));
95 }
96 },
97 };
98 const newRecord = {
99 status: 'pending',
100 value: thenable,
101 };
102 textCache.set(text, newRecord);
103 return thenable;
104 } else {
105 switch (record.status) {
106 case 'pending':
107 return record.value;
108 case 'rejected':
109 return Promise.reject(record.value);
110 case 'resolved':
111 return Promise.resolve(record.value);
112 }
113 }
114 }
115
116 function Text({text}) {
117 Scheduler.log(text);
118 return text;
119 }
120
121 function AsyncText({text}) {
122 readText(text);
123 Scheduler.log(text);
124 return text;
125 }
126
127 it('isPending remains true until async action finishes', async () => {
128 let startTransition;
129 function App() {
130 const [isPending, _start] = useTransition();
131 startTransition = _start;
132 return <Text text={'Pending: ' + isPending} />;
133 }
134
135 const root = ReactNoop.createRoot();
136 await act(() => {
137 root.render(<App />);
138 });
139 assertLog(['Pending: false']);
140 expect(root).toMatchRenderedOutput('Pending: false');
141
142 // At the start of an async action, isPending is set to true.
143 await act(() => {
144 startTransition(async () => {
145 Scheduler.log('Async action started');
146 await getText('Wait');
147 Scheduler.log('Async action ended');
148 });
149 });
150 assertLog(['Async action started', 'Pending: true']);
151 expect(root).toMatchRenderedOutput('Pending: true');
152
153 // Once the action finishes, isPending is set back to false.
154 await act(() => resolveText('Wait'));
155 assertLog(['Async action ended', 'Pending: false']);
156 expect(root).toMatchRenderedOutput('Pending: false');
157 });
158
159 it('multiple updates in an async action scope are entangled together', async () => {
160 let startTransition;
161 function App({text}) {
162 const [isPending, _start] = useTransition();
163 startTransition = _start;
164 return (
165 <>
166 <span>
167 <Text text={'Pending: ' + isPending} />
168 </span>
169 <span>
170 <Text text={text} />
171 </span>
172 </>
173 );
174 }
175
176 const root = ReactNoop.createRoot();
177 await act(() => {
178 root.render(<App text="A" />);
179 });
180 assertLog(['Pending: false', 'A']);
181 expect(root).toMatchRenderedOutput(
182 <>
183 <span>Pending: false</span>
184 <span>A</span>
185 </>,
186 );
187
188 await act(() => {
189 startTransition(async () => {
190 Scheduler.log('Async action started');
191 await getText('Yield before updating');
192 Scheduler.log('Async action ended');
193 startTransition(() => root.render(<App text="B" />));
194 });
195 });
196 assertLog(['Async action started', 'Pending: true', 'A']);
197 expect(root).toMatchRenderedOutput(
198 <>
199 <span>Pending: true</span>
200 <span>A</span>
201 </>,
202 );
203
204 await act(() => resolveText('Yield before updating'));
205 assertLog(['Async action ended', 'Pending: false', 'B']);
206 expect(root).toMatchRenderedOutput(
207 <>
208 <span>Pending: false</span>
209 <span>B</span>
210 </>,
211 );
212 });
213
214 it('multiple async action updates in the same scope are entangled together', async () => {
215 let setStepA;
216 function A() {
217 const [step, setStep] = useState(0);
218 setStepA = setStep;
219 return <AsyncText text={'A' + step} />;
220 }
221
222 let setStepB;
223 function B() {
224 const [step, setStep] = useState(0);
225 setStepB = setStep;
226 return <AsyncText text={'B' + step} />;
227 }
228
229 let setStepC;
230 function C() {
231 const [step, setStep] = useState(0);
232 setStepC = setStep;
233 return <AsyncText text={'C' + step} />;
234 }
235
236 let startTransition;
237 function App() {
238 const [isPending, _start] = useTransition();
239 startTransition = _start;
240 return (
241 <>
242 <span>
243 <Text text={'Pending: ' + isPending} />
244 </span>
245 <span>
246 <A />, <B />, <C />
247 </span>
248 </>
249 );
250 }
251
252 const root = ReactNoop.createRoot();
253 resolveText('A0');
254 resolveText('B0');
255 resolveText('C0');
256 await act(() => {
257 root.render(<App text="A" />);
258 });
259 assertLog(['Pending: false', 'A0', 'B0', 'C0']);
260 expect(root).toMatchRenderedOutput(
261 <>
262 <span>Pending: false</span>
263 <span>A0, B0, C0</span>
264 </>,
265 );
266
267 await act(() => {
268 startTransition(async () => {
269 Scheduler.log('Async action started');
270 setStepA(1);
271 await getText('Wait before updating B');
272 startTransition(() => setStepB(1));
273 await getText('Wait before updating C');
274 startTransition(() => setStepC(1));
275 Scheduler.log('Async action ended');
276 });
277 });
278 assertLog(['Async action started', 'Pending: true', 'A0', 'B0', 'C0']);
279 expect(root).toMatchRenderedOutput(
280 <>
281 <span>Pending: true</span>
282 <span>A0, B0, C0</span>
283 </>,
284 );
285
286 // This will schedule an update on B, but nothing will render yet because
287 // the async action scope hasn't finished.
288 await act(() => resolveText('Wait before updating B'));
289 assertLog([]);
290 expect(root).toMatchRenderedOutput(
291 <>
292 <span>Pending: true</span>
293 <span>A0, B0, C0</span>
294 </>,
295 );
296
297 // This will schedule an update on C, and also the async action scope
298 // will end. This will allow React to attempt to render the updates.
299 await act(() => resolveText('Wait before updating C'));
300 assertLog([
301 'Async action ended',
302 'Pending: false',
303 'Suspend! [A1]',
304 // pre-warming
305 'Suspend! [B1]',
306 'Suspend! [C1]',
307 ]);
308 expect(root).toMatchRenderedOutput(
309 <>
310 <span>Pending: true</span>
311 <span>A0, B0, C0</span>
312 </>,
313 );
314
315 // Progressively load the all the data. Because they are all entangled
316 // together, only when the all of A, B, and C updates are unblocked is the
317 // render allowed to proceed.
318 await act(() => resolveText('A1'));
319 assertLog([
320 'Pending: false',
321 'A1',
322 'Suspend! [B1]',
323 // pre-warming
324 'Suspend! [C1]',
325 ]);
326 expect(root).toMatchRenderedOutput(
327 <>
328 <span>Pending: true</span>
329 <span>A0, B0, C0</span>
330 </>,
331 );
332 await act(() => resolveText('B1'));
333 assertLog(['Pending: false', 'A1', 'B1', 'Suspend! [C1]']);
334 expect(root).toMatchRenderedOutput(
335 <>
336 <span>Pending: true</span>
337 <span>A0, B0, C0</span>
338 </>,
339 );
340
341 // Finally, all the data has loaded and the transition is complete.
342 await act(() => resolveText('C1'));
343 assertLog(['Pending: false', 'A1', 'B1', 'C1']);
344 expect(root).toMatchRenderedOutput(
345 <>
346 <span>Pending: false</span>
347 <span>A1, B1, C1</span>
348 </>,
349 );
350 });
351
352 it('urgent updates are not blocked during an async action', async () => {
353 let setStepA;
354 function A() {
355 const [step, setStep] = useState(0);
356 setStepA = setStep;
357 return <Text text={'A' + step} />;
358 }
359
360 let setStepB;
361 function B() {
362 const [step, setStep] = useState(0);
363 setStepB = setStep;
364 return <Text text={'B' + step} />;
365 }
366
367 let startTransition;
368 function App() {
369 const [isPending, _start] = useTransition();
370 startTransition = _start;
371 return (
372 <>
373 <span>
374 <Text text={'Pending: ' + isPending} />
375 </span>
376 <span>
377 <A />, <B />
378 </span>
379 </>
380 );
381 }
382
383 const root = ReactNoop.createRoot();
384 await act(() => {
385 root.render(<App text="A" />);
386 });
387 assertLog(['Pending: false', 'A0', 'B0']);
388 expect(root).toMatchRenderedOutput(
389 <>
390 <span>Pending: false</span>
391 <span>A0, B0</span>
392 </>,
393 );
394
395 await act(() => {
396 startTransition(async () => {
397 Scheduler.log('Async action started');
398 startTransition(() => setStepA(1));
399 await getText('Wait');
400 Scheduler.log('Async action ended');
401 });
402 });
403 assertLog(['Async action started', 'Pending: true', 'A0', 'B0']);
404 expect(root).toMatchRenderedOutput(
405 <>
406 <span>Pending: true</span>
407 <span>A0, B0</span>
408 </>,
409 );
410
411 // Update B at urgent priority. This should be allowed to finish.
412 await act(() => setStepB(1));
413 assertLog(['B1']);
414 expect(root).toMatchRenderedOutput(
415 <>
416 <span>Pending: true</span>
417 <span>A0, B1</span>
418 </>,
419 );
420
421 // Finish the async action.
422 await act(() => resolveText('Wait'));
423 assertLog(['Async action ended', 'Pending: false', 'A1', 'B1']);
424 expect(root).toMatchRenderedOutput(
425 <>
426 <span>Pending: false</span>
427 <span>A1, B1</span>
428 </>,
429 );
430 });
431
432 it("if a sync action throws, it's rethrown from the `useTransition`", async () => {
433 class ErrorBoundary extends React.Component {
434 state = {error: null};
435 static getDerivedStateFromError(error) {
436 return {error};
437 }
438 render() {
439 if (this.state.error) {
440 return <Text text={this.state.error.message} />;
441 }
442 return this.props.children;
443 }
444 }
445
446 let startTransition;
447 function App() {
448 const [isPending, _start] = useTransition();
449 startTransition = _start;
450 return <Text text={'Pending: ' + isPending} />;
451 }
452
453 const root = ReactNoop.createRoot();
454 await act(() => {
455 root.render(
456 <ErrorBoundary>
457 <App />
458 </ErrorBoundary>,
459 );
460 });
461 assertLog(['Pending: false']);
462 expect(root).toMatchRenderedOutput('Pending: false');
463
464 await act(() => {
465 startTransition(() => {
466 throw new Error('Oops!');
467 });
468 });
469 assertLog(['Pending: true', 'Oops!', 'Oops!']);
470 expect(root).toMatchRenderedOutput('Oops!');
471 });
472
473 it("if an async action throws, it's rethrown from the `useTransition`", async () => {
474 class ErrorBoundary extends React.Component {
475 state = {error: null};
476 static getDerivedStateFromError(error) {
477 return {error};
478 }
479 render() {
480 if (this.state.error) {
481 return <Text text={this.state.error.message} />;
482 }
483 return this.props.children;
484 }
485 }
486
487 let startTransition;
488 function App() {
489 const [isPending, _start] = useTransition();
490 startTransition = _start;
491 return <Text text={'Pending: ' + isPending} />;
492 }
493
494 const root = ReactNoop.createRoot();
495 await act(() => {
496 root.render(
497 <ErrorBoundary>
498 <App />
499 </ErrorBoundary>,
500 );
501 });
502 assertLog(['Pending: false']);
503 expect(root).toMatchRenderedOutput('Pending: false');
504
505 await act(() => {
506 startTransition(async () => {
507 Scheduler.log('Async action started');
508 await getText('Wait');
509 throw new Error('Oops!');
510 });
511 });
512 assertLog(['Async action started', 'Pending: true']);
513 expect(root).toMatchRenderedOutput('Pending: true');
514
515 await act(() => resolveText('Wait'));
516 assertLog(['Oops!', 'Oops!']);
517 expect(root).toMatchRenderedOutput('Oops!');
518 });
519
520 it('if there are multiple entangled actions, and one of them errors, it only affects that action', async () => {
521 class ErrorBoundary extends React.Component {
522 state = {error: null};
523 static getDerivedStateFromError(error) {
524 return {error};
525 }
526 render() {
527 if (this.state.error) {
528 return <Text text={this.state.error.message} />;
529 }
530 return this.props.children;
531 }
532 }
533
534 let startTransitionA;
535 function ActionA() {
536 const [isPendingA, start] = useTransition();
537 startTransitionA = start;
538 return <Text text={'Pending A: ' + isPendingA} />;
539 }
540
541 let startTransitionB;
542 function ActionB() {
543 const [isPending, start] = useTransition();
544 startTransitionB = start;
545 return <Text text={'Pending B: ' + isPending} />;
546 }
547
548 let startTransitionC;
549 function ActionC() {
550 const [isPending, start] = useTransition();
551 startTransitionC = start;
552 return <Text text={'Pending C: ' + isPending} />;
553 }
554
555 const root = ReactNoop.createRoot();
556 await act(() => {
557 root.render(
558 <>
559 <div>
560 <ErrorBoundary>
561 <ActionA />
562 </ErrorBoundary>
563 </div>
564 <div>
565 <ErrorBoundary>
566 <ActionB />
567 </ErrorBoundary>
568 </div>
569 <div>
570 <ErrorBoundary>
571 <ActionC />
572 </ErrorBoundary>
573 </div>
574 </>,
575 );
576 });
577 assertLog(['Pending A: false', 'Pending B: false', 'Pending C: false']);
578 expect(root).toMatchRenderedOutput(
579 <>
580 <div>Pending A: false</div>
581 <div>Pending B: false</div>
582 <div>Pending C: false</div>
583 </>,
584 );
585
586 // Start a bunch of entangled transitions. A and C throw errors, but B
587 // doesn't. A and should surface their respective errors, but B should
588 // finish successfully.
589 await act(() => {
590 startTransitionC(async () => {
591 startTransitionB(async () => {
592 startTransitionA(async () => {
593 await getText('Wait for A');
594 throw new Error('Oops A!');
595 });
596 await getText('Wait for B');
597 });
598 await getText('Wait for C');
599 throw new Error('Oops C!');
600 });
601 });
602 assertLog(['Pending A: true', 'Pending B: true', 'Pending C: true']);
603
604 // Finish action A. We can't commit the result yet because it's entangled
605 // with B and C.
606 await act(() => resolveText('Wait for A'));
607 assertLog([]);
608
609 // Finish action B. Same as above.
610 await act(() => resolveText('Wait for B'));
611 assertLog([]);
612
613 // Now finish action C. This is the last action in the entangled set, so
614 // rendering can proceed.
615 await act(() => resolveText('Wait for C'));
616 assertLog([
617 // A and C result in (separate) errors, but B does not.
618 'Oops A!',
619 'Pending B: false',
620 'Oops C!',
621
622 // Because there was an error, React will try rendering one more time.
623 'Oops A!',
624 'Pending B: false',
625 'Oops C!',
626 ]);
627 expect(root).toMatchRenderedOutput(
628 <>
629 <div>Oops A!</div>
630 <div>Pending B: false</div>
631 <div>Oops C!</div>
632 </>,
633 );
634 });
635
636 it('useOptimistic can be used to implement a pending state', async () => {
637 const startTransition = React.startTransition;
638
639 let setIsPending;
640 function App({text}) {
641 const [isPending, _setIsPending] = useOptimistic(false);
642 setIsPending = _setIsPending;
643 return (
644 <>
645 <Text text={'Pending: ' + isPending} />
646 <AsyncText text={text} />
647 </>
648 );
649 }
650
651 // Initial render
652 const root = ReactNoop.createRoot();
653 resolveText('A');
654 await act(() => root.render(<App text="A" />));
655 assertLog(['Pending: false', 'A']);
656 expect(root).toMatchRenderedOutput('Pending: falseA');
657
658 // Start a transition
659 await act(() =>
660 startTransition(() => {
661 setIsPending(true);
662 root.render(<App text="B" />);
663 }),
664 );
665 assertLog([
666 // Render the pending state immediately
667 'Pending: true',
668 'A',
669
670 // Then attempt to render the transition. The pending state will be
671 // automatically reverted.
672 'Pending: false',
673 'Suspend! [B]',
674 ]);
675
676 // Resolve the transition
677 await act(() => resolveText('B'));
678 assertLog([
679 // Render the pending state immediately
680 'Pending: false',
681 'B',
682 ]);
683 });
684
685 it('useOptimistic rebases pending updates on top of passthrough value', async () => {
686 let serverCart = ['A'];
687
688 async function submitNewItem(item) {
689 await getText('Adding item ' + item);
690 serverCart = [...serverCart, item];
691 React.startTransition(() => {
692 root.render(<App cart={serverCart} />);
693 });
694 }
695
696 let addItemToCart;
697 function App({cart}) {
698 const [isPending, startTransition] = useTransition();
699
700 const savedCartSize = cart.length;
701 const [optimisticCartSize, setOptimisticCartSize] =
702 useOptimistic(savedCartSize);
703
704 addItemToCart = item => {
705 startTransition(async () => {
706 setOptimisticCartSize(n => n + 1);
707 await submitNewItem(item);
708 });
709 };
710
711 return (
712 <>
713 <div>
714 <Text text={'Pending: ' + isPending} />
715 </div>
716 <div>
717 <Text text={'Items in cart: ' + optimisticCartSize} />
718 </div>
719 <ul>
720 {cart.map(item => (
721 <li key={item}>
722 <Text text={'Item ' + item} />
723 </li>
724 ))}
725 </ul>
726 </>
727 );
728 }
729
730 // Initial render
731 const root = ReactNoop.createRoot();
732 await act(() => root.render(<App cart={serverCart} />));
733 assertLog(['Pending: false', 'Items in cart: 1', 'Item A']);
734 expect(root).toMatchRenderedOutput(
735 <>
736 <div>Pending: false</div>
737 <div>Items in cart: 1</div>
738 <ul>
739 <li>Item A</li>
740 </ul>
741 </>,
742 );
743
744 // The cart size is incremented even though B hasn't been added yet.
745 await act(() => addItemToCart('B'));
746 assertLog(['Pending: true', 'Items in cart: 2', 'Item A']);
747 expect(root).toMatchRenderedOutput(
748 <>
749 <div>Pending: true</div>
750 <div>Items in cart: 2</div>
751 <ul>
752 <li>Item A</li>
753 </ul>
754 </>,
755 );
756
757 // While B is still pending, another item gets added to the cart
758 // out-of-band.
759 serverCart = [...serverCart, 'C'];
760 // NOTE: This is a synchronous update only because we don't yet support
761 // parallel transitions; all transitions are entangled together. Once we add
762 // support for parallel transitions, we can update this test.
763 ReactNoop.flushSync(() => root.render(<App cart={serverCart} />));
764 assertLog([
765 'Pending: true',
766 // Note that the optimistic cart size is still correct, because the
767 // pending update was rebased on top new value.
768 'Items in cart: 3',
769 'Item A',
770 'Item C',
771 ]);
772 expect(root).toMatchRenderedOutput(
773 <>
774 <div>Pending: true</div>
775 <div>Items in cart: 3</div>
776 <ul>
777 <li>Item A</li>
778 <li>Item C</li>
779 </ul>
780 </>,
781 );
782
783 // Finish loading B. The optimistic state is reverted.
784 await act(() => resolveText('Adding item B'));
785 assertLog([
786 'Pending: false',
787 'Items in cart: 3',
788 'Item A',
789 'Item C',
790 'Item B',
791 ]);
792 expect(root).toMatchRenderedOutput(
793 <>
794 <div>Pending: false</div>
795 <div>Items in cart: 3</div>
796 <ul>
797 <li>Item A</li>
798 <li>Item C</li>
799 <li>Item B</li>
800 </ul>
801 </>,
802 );
803 });
804
805 it(
806 'regression: when there are no pending transitions, useOptimistic should ' +
807 'always return the passthrough value',
808 async () => {
809 let setCanonicalState;
810 function App() {
811 const [canonicalState, _setCanonicalState] = useState(0);
812 const [optimisticState] = useOptimistic(canonicalState);
813 setCanonicalState = _setCanonicalState;
814
815 return (
816 <>
817 <div>
818 <Text text={'Canonical: ' + canonicalState} />
819 </div>
820 <div>
821 <Text text={'Optimistic: ' + optimisticState} />
822 </div>
823 </>
824 );
825 }
826
827 const root = ReactNoop.createRoot();
828 await act(() => root.render(<App />));
829 assertLog(['Canonical: 0', 'Optimistic: 0']);
830 expect(root).toMatchRenderedOutput(
831 <>
832 <div>Canonical: 0</div>
833 <div>Optimistic: 0</div>
834 </>,
835 );
836
837 // Update the canonical state. The optimistic state should update, too,
838 // even though there was no transition, and no call to setOptimisticState.
839 await act(() => setCanonicalState(1));
840 assertLog(['Canonical: 1', 'Optimistic: 1']);
841 expect(root).toMatchRenderedOutput(
842 <>
843 <div>Canonical: 1</div>
844 <div>Optimistic: 1</div>
845 </>,
846 );
847 },
848 );
849
850 it('regression: useOptimistic during setState-in-render', async () => {
851 // This is a regression test for a very specific case where useOptimistic is
852 // the first hook in the component, it has a pending update, and a later
853 // hook schedules a local (setState-in-render) update. Don't sweat about
854 // deleting this test if the implementation details change.
855
856 let setOptimisticState;
857 let startTransition;
858 function App() {
859 const [optimisticState, _setOptimisticState] = useOptimistic(0);
860 setOptimisticState = _setOptimisticState;
861 const [, _startTransition] = useTransition();
862 startTransition = _startTransition;
863
864 const [derivedState, setDerivedState] = useState(0);
865 if (derivedState !== optimisticState) {
866 setDerivedState(optimisticState);
867 }
868
869 return <Text text={optimisticState} />;
870 }
871
872 const root = ReactNoop.createRoot();
873 await act(() => root.render(<App />));
874 assertLog([0]);
875 expect(root).toMatchRenderedOutput('0');
876
877 await act(() => {
878 startTransition(async () => {
879 setOptimisticState(1);
880 await getText('Wait');
881 });
882 });
883 assertLog([1]);
884 expect(root).toMatchRenderedOutput('1');
885 });
886
887 it('useOptimistic accepts a custom reducer', async () => {
888 let serverCart = ['A'];
889
890 async function submitNewItem(item) {
891 await getText('Adding item ' + item);
892 serverCart = [...serverCart, item];
893 React.startTransition(() => {
894 root.render(<App cart={serverCart} />);
895 });
896 }
897
898 let addItemToCart;
899 function App({cart}) {
900 const [isPending, startTransition] = useTransition();
901
902 const savedCartSize = cart.length;
903 const [optimisticCartSize, addToOptimisticCart] = useOptimistic(
904 savedCartSize,
905 (prevSize, newItem) => {
906 Scheduler.log('Increment optimistic cart size for ' + newItem);
907 return prevSize + 1;
908 },
909 );
910
911 addItemToCart = item => {
912 startTransition(async () => {
913 addToOptimisticCart(item);
914 await submitNewItem(item);
915 });
916 };
917
918 return (
919 <>
920 <div>
921 <Text text={'Pending: ' + isPending} />
922 </div>
923 <div>
924 <Text text={'Items in cart: ' + optimisticCartSize} />
925 </div>
926 <ul>
927 {cart.map(item => (
928 <li key={item}>
929 <Text text={'Item ' + item} />
930 </li>
931 ))}
932 </ul>
933 </>
934 );
935 }
936
937 // Initial render
938 const root = ReactNoop.createRoot();
939 await act(() => root.render(<App cart={serverCart} />));
940 assertLog(['Pending: false', 'Items in cart: 1', 'Item A']);
941 expect(root).toMatchRenderedOutput(
942 <>
943 <div>Pending: false</div>
944 <div>Items in cart: 1</div>
945 <ul>
946 <li>Item A</li>
947 </ul>
948 </>,
949 );
950
951 // The cart size is incremented even though B hasn't been added yet.
952 await act(() => addItemToCart('B'));
953 assertLog([
954 'Increment optimistic cart size for B',
955 'Pending: true',
956 'Items in cart: 2',
957 'Item A',
958 ]);
959 expect(root).toMatchRenderedOutput(
960 <>
961 <div>Pending: true</div>
962 <div>Items in cart: 2</div>
963 <ul>
964 <li>Item A</li>
965 </ul>
966 </>,
967 );
968
969 // While B is still pending, another item gets added to the cart
970 // out-of-band.
971 serverCart = [...serverCart, 'C'];
972 // NOTE: This is a synchronous update only because we don't yet support
973 // parallel transitions; all transitions are entangled together. Once we add
974 // support for parallel transitions, we can update this test.
975 ReactNoop.flushSync(() => root.render(<App cart={serverCart} />));
976 assertLog([
977 'Increment optimistic cart size for B',
978 'Pending: true',
979 // Note that the optimistic cart size is still correct, because the
980 // pending update was rebased on top new value.
981 'Items in cart: 3',
982 'Item A',
983 'Item C',
984 ]);
985 expect(root).toMatchRenderedOutput(
986 <>
987 <div>Pending: true</div>
988 <div>Items in cart: 3</div>
989 <ul>
990 <li>Item A</li>
991 <li>Item C</li>
992 </ul>
993 </>,
994 );
995
996 // Finish loading B. The optimistic state is reverted.
997 await act(() => resolveText('Adding item B'));
998 assertLog([
999 'Pending: false',
1000 'Items in cart: 3',
1001 'Item A',
1002 'Item C',
1003 'Item B',
1004 ]);
1005 expect(root).toMatchRenderedOutput(
1006 <>
1007 <div>Pending: false</div>
1008 <div>Items in cart: 3</div>
1009 <ul>
1010 <li>Item A</li>
1011 <li>Item C</li>
1012 <li>Item B</li>
1013 </ul>
1014 </>,
1015 );
1016 });
1017
1018 it('useOptimistic rebases if the passthrough is updated during a render phase update', async () => {
1019 // This is kind of an esoteric case where it's hard to come up with a
1020 // realistic real-world scenario but it should still work.
1021 let increment;
1022 let setCount;
1023 function App() {
1024 const [isPending, startTransition] = useTransition(2);
1025 const [count, _setCount] = useState(0);
1026 setCount = _setCount;
1027
1028 const [optimisticCount, setOptimisticCount] = useOptimistic(
1029 count,
1030 prev => {
1031 Scheduler.log('Increment optimistic count');
1032 return prev + 1;
1033 },
1034 );
1035
1036 if (count === 1) {
1037 Scheduler.log('Render phase update count from 1 to 2');
1038 setCount(2);
1039 }
1040
1041 increment = () =>
1042 startTransition(async () => {
1043 setOptimisticCount(n => n + 1);
1044 await getText('Wait to increment');
1045 React.startTransition(() => setCount(n => n + 1));
1046 });
1047
1048 return (
1049 <>
1050 <div>
1051 <Text text={'Count: ' + count} />
1052 </div>
1053 {isPending ? (
1054 <div>
1055 <Text text={'Optimistic count: ' + optimisticCount} />
1056 </div>
1057 ) : null}
1058 </>
1059 );
1060 }
1061
1062 const root = ReactNoop.createRoot();
1063 await act(() => root.render(<App />));
1064 assertLog(['Count: 0']);
1065 expect(root).toMatchRenderedOutput(<div>Count: 0</div>);
1066
1067 await act(() => increment());
1068 assertLog([
1069 'Increment optimistic count',
1070 'Count: 0',
1071 'Optimistic count: 1',
1072 ]);
1073 expect(root).toMatchRenderedOutput(
1074 <>
1075 <div>Count: 0</div>
1076 <div>Optimistic count: 1</div>
1077 </>,
1078 );
1079
1080 await act(() => setCount(1));
1081 assertLog([
1082 'Increment optimistic count',
1083 'Render phase update count from 1 to 2',
1084 // The optimistic update is rebased on top of the new passthrough value.
1085 'Increment optimistic count',
1086 'Count: 2',
1087 'Optimistic count: 3',
1088 ]);
1089 expect(root).toMatchRenderedOutput(
1090 <>
1091 <div>Count: 2</div>
1092 <div>Optimistic count: 3</div>
1093 </>,
1094 );
1095
1096 // Finish the action
1097 await act(() => resolveText('Wait to increment'));
1098 assertLog(['Count: 3']);
1099 expect(root).toMatchRenderedOutput(<div>Count: 3</div>);
1100 });
1101
1102 it('useOptimistic rebases if the passthrough is updated during a render phase update (initial mount)', async () => {
1103 // This is kind of an esoteric case where it's hard to come up with a
1104 // realistic real-world scenario but it should still work.
1105 function App() {
1106 const [count, setCount] = useState(0);
1107 const [optimisticCount] = useOptimistic(count);
1108
1109 if (count === 0) {
1110 Scheduler.log('Render phase update count from 1 to 2');
1111 setCount(1);
1112 }
1113
1114 return (
1115 <>
1116 <div>
1117 <Text text={'Count: ' + count} />
1118 </div>
1119 <div>
1120 <Text text={'Optimistic count: ' + optimisticCount} />
1121 </div>
1122 </>
1123 );
1124 }
1125
1126 const root = ReactNoop.createRoot();
1127 await act(() => root.render(<App />));
1128 assertLog([
1129 'Render phase update count from 1 to 2',
1130 'Count: 1',
1131 'Optimistic count: 1',
1132 ]);
1133 expect(root).toMatchRenderedOutput(
1134 <>
1135 <div>Count: 1</div>
1136 <div>Optimistic count: 1</div>
1137 </>,
1138 );
1139 });
1140
1141 it('useOptimistic can update repeatedly in the same async action', async () => {
1142 let startTransition;
1143 let setLoadingProgress;
1144 let setText;
1145 function App() {
1146 const [, _startTransition] = useTransition();
1147 const [text, _setText] = useState('A');
1148 const [loadingProgress, _setLoadingProgress] = useOptimistic(0);
1149 startTransition = _startTransition;
1150 setText = _setText;
1151 setLoadingProgress = _setLoadingProgress;
1152
1153 return (
1154 <>
1155 {loadingProgress !== 0 ? (
1156 <div key="progress">
1157 <Text text={`Loading... (${loadingProgress})`} />
1158 </div>
1159 ) : null}
1160 <div key="real">
1161 <Text text={text} />
1162 </div>
1163 </>
1164 );
1165 }
1166
1167 // Initial render
1168 const root = ReactNoop.createRoot();
1169 await act(() => root.render(<App />));
1170 assertLog(['A']);
1171 expect(root).toMatchRenderedOutput(<div>A</div>);
1172
1173 await act(async () => {
1174 startTransition(async () => {
1175 setLoadingProgress('25%');
1176 await getText('Wait 1');
1177 setLoadingProgress('75%');
1178 await getText('Wait 2');
1179 startTransition(() => setText('B'));
1180 });
1181 });
1182 assertLog(['Loading... (25%)', 'A']);
1183 expect(root).toMatchRenderedOutput(
1184 <>
1185 <div>Loading... (25%)</div>
1186 <div>A</div>
1187 </>,
1188 );
1189
1190 await act(() => resolveText('Wait 1'));
1191 assertLog(['Loading... (75%)', 'A']);
1192 expect(root).toMatchRenderedOutput(
1193 <>
1194 <div>Loading... (75%)</div>
1195 <div>A</div>
1196 </>,
1197 );
1198
1199 await act(() => resolveText('Wait 2'));
1200 assertLog(['B']);
1201 expect(root).toMatchRenderedOutput(<div>B</div>);
1202 });
1203
1204 it('useOptimistic warns if outside of a transition', async () => {
1205 let startTransition;
1206 let setLoadingProgress;
1207 let setText;
1208 function App() {
1209 const [, _startTransition] = useTransition();
1210 const [text, _setText] = useState('A');
1211 const [loadingProgress, _setLoadingProgress] = useOptimistic(0);
1212 startTransition = _startTransition;
1213 setText = _setText;
1214 setLoadingProgress = _setLoadingProgress;
1215
1216 return (
1217 <>
1218 {loadingProgress !== 0 ? (
1219 <div key="progress">
1220 <Text text={`Loading... (${loadingProgress})`} />
1221 </div>
1222 ) : null}
1223 <div key="real">
1224 <Text text={text} />
1225 </div>
1226 </>
1227 );
1228 }
1229
1230 // Initial render
1231 const root = ReactNoop.createRoot();
1232 await act(() => root.render(<App />));
1233 assertLog(['A']);
1234 expect(root).toMatchRenderedOutput(<div>A</div>);
1235
1236 await act(() => {
1237 setLoadingProgress('25%');
1238 startTransition(() => setText('B'));
1239 });
1240 assertConsoleErrorDev([
1241 'An optimistic state update occurred outside a transition or ' +
1242 'action. To fix, move the update to an action, or wrap ' +
1243 'with startTransition.',
1244 ]);
1245 assertLog(['Loading... (25%)', 'A', 'B']);
1246 expect(root).toMatchRenderedOutput(<div>B</div>);
1247 });
1248
1249 it(
1250 'optimistic state is not reverted until async action finishes, even if ' +
1251 'useTransition hook is unmounted',
1252 async () => {
1253 let startTransition;
1254 function Updater() {
1255 const [isPending, _start] = useTransition();
1256 startTransition = _start;
1257 return (
1258 <span>
1259 <Text text={'Pending: ' + isPending} />
1260 </span>
1261 );
1262 }
1263
1264 let setText;
1265 let setOptimisticText;
1266 function Sibling() {
1267 const [canonicalText, _setText] = useState('A');
1268 setText = _setText;
1269
1270 const [text, _setOptimisticText] = useOptimistic(
1271 canonicalText,
1272 (_, optimisticText) => `${optimisticText} (loading...)`,
1273 );
1274 setOptimisticText = _setOptimisticText;
1275
1276 return (
1277 <span>
1278 <Text text={text} />
1279 </span>
1280 );
1281 }
1282
1283 function App({showUpdater}) {
1284 return (
1285 <>
1286 {showUpdater ? <Updater /> : null}
1287 <Sibling />
1288 </>
1289 );
1290 }
1291
1292 const root = ReactNoop.createRoot();
1293 await act(() => {
1294 root.render(<App showUpdater={true} />);
1295 });
1296 assertLog(['Pending: false', 'A']);
1297 expect(root).toMatchRenderedOutput(
1298 <>
1299 <span>Pending: false</span>
1300 <span>A</span>
1301 </>,
1302 );
1303
1304 // Start an async action that has multiple updates with async
1305 // operations in between.
1306 await act(() => {
1307 startTransition(async () => {
1308 Scheduler.log('Async action started');
1309
1310 setOptimisticText('C');
1311
1312 startTransition(() => setText('B'));
1313
1314 await getText('Wait before updating to C');
1315
1316 Scheduler.log('Async action ended');
1317 startTransition(() => setText('C'));
1318 });
1319 });
1320 assertLog([
1321 'Async action started',
1322 'Pending: true',
1323 // Render an optimistic value
1324 'C (loading...)',
1325 ]);
1326 expect(root).toMatchRenderedOutput(
1327 <>
1328 <span>Pending: true</span>
1329 <span>C (loading...)</span>
1330 </>,
1331 );
1332
1333 // Delete the component that contains the useTransition hook. This
1334 // component no longer blocks the transition from completing. But the
1335 // we're still showing an optimistic state, because the async action has
1336 // not yet finished.
1337 await act(() => {
1338 root.render(<App showUpdater={false} />);
1339 });
1340 assertLog(['C (loading...)']);
1341 expect(root).toMatchRenderedOutput(<span>C (loading...)</span>);
1342
1343 // Finish the async action. Now the optimistic state is reverted and we
1344 // switch to the canonical value.
1345 await act(() => resolveText('Wait before updating to C'));
1346 assertLog(['Async action ended', 'C']);
1347 expect(root).toMatchRenderedOutput(<span>C</span>);
1348 },
1349 );
1350
1351 it(
1352 'updates in an async action are entangled even if useTransition hook ' +
1353 'is unmounted before it finishes',
1354 async () => {
1355 let startTransition;
1356 function Updater() {
1357 const [isPending, _start] = useTransition();
1358 startTransition = _start;
1359 return (
1360 <span>
1361 <Text text={'Pending: ' + isPending} />
1362 </span>
1363 );
1364 }
1365
1366 let setText;
1367 function Sibling() {
1368 const [text, _setText] = useState('A');
1369 setText = _setText;
1370 return (
1371 <span>
1372 <Text text={text} />
1373 </span>
1374 );
1375 }
1376
1377 function App({showUpdater}) {
1378 return (
1379 <>
1380 {showUpdater ? <Updater /> : null}
1381 <Sibling />
1382 </>
1383 );
1384 }
1385
1386 const root = ReactNoop.createRoot();
1387 await act(() => {
1388 root.render(<App showUpdater={true} />);
1389 });
1390 assertLog(['Pending: false', 'A']);
1391 expect(root).toMatchRenderedOutput(
1392 <>
1393 <span>Pending: false</span>
1394 <span>A</span>
1395 </>,
1396 );
1397
1398 // Start an async action that has multiple updates with async
1399 // operations in between.
1400 await act(() => {
1401 startTransition(async () => {
1402 Scheduler.log('Async action started');
1403 startTransition(() => setText('B'));
1404
1405 await getText('Wait before updating to C');
1406
1407 Scheduler.log('Async action ended');
1408 startTransition(() => setText('C'));
1409 });
1410 });
1411 assertLog(['Async action started', 'Pending: true']);
1412 expect(root).toMatchRenderedOutput(
1413 <>
1414 <span>Pending: true</span>
1415 <span>A</span>
1416 </>,
1417 );
1418
1419 // Delete the component that contains the useTransition hook. This
1420 // component no longer blocks the transition from completing. But the
1421 // pending update to Sibling should not be allowed to finish, because it's
1422 // part of the async action.
1423 await act(() => {
1424 root.render(<App showUpdater={false} />);
1425 });
1426 assertLog(['A']);
1427 expect(root).toMatchRenderedOutput(<span>A</span>);
1428
1429 // Finish the async action. Notice the intermediate B state was never
1430 // shown, because it was batched with the update that came later in the
1431 // same action.
1432 await act(() => resolveText('Wait before updating to C'));
1433 assertLog(['Async action ended', 'C']);
1434 expect(root).toMatchRenderedOutput(<span>C</span>);
1435 },
1436 );
1437
1438 it(
1439 'updates in an async action are entangled even if useTransition hook ' +
1440 'is unmounted before it finishes (class component)',
1441 async () => {
1442 let startTransition;
1443 function Updater() {
1444 const [isPending, _start] = useTransition();
1445 startTransition = _start;
1446 return (
1447 <span>
1448 <Text text={'Pending: ' + isPending} />
1449 </span>
1450 );
1451 }
1452
1453 let setText;
1454 class Sibling extends React.Component {
1455 state = {text: 'A'};
1456 render() {
1457 setText = text => this.setState({text});
1458 return (
1459 <span>
1460 <Text text={this.state.text} />
1461 </span>
1462 );
1463 }
1464 }
1465
1466 function App({showUpdater}) {
1467 return (
1468 <>
1469 {showUpdater ? <Updater /> : null}
1470 <Sibling />
1471 </>
1472 );
1473 }
1474
1475 const root = ReactNoop.createRoot();
1476 await act(() => {
1477 root.render(<App showUpdater={true} />);
1478 });
1479 assertLog(['Pending: false', 'A']);
1480 expect(root).toMatchRenderedOutput(
1481 <>
1482 <span>Pending: false</span>
1483 <span>A</span>
1484 </>,
1485 );
1486
1487 // Start an async action that has multiple updates with async
1488 // operations in between.
1489 await act(() => {
1490 startTransition(async () => {
1491 Scheduler.log('Async action started');
1492 startTransition(() => setText('B'));
1493
1494 await getText('Wait before updating to C');
1495
1496 Scheduler.log('Async action ended');
1497 startTransition(() => setText('C'));
1498 });
1499 });
1500 assertLog(['Async action started', 'Pending: true']);
1501 expect(root).toMatchRenderedOutput(
1502 <>
1503 <span>Pending: true</span>
1504 <span>A</span>
1505 </>,
1506 );
1507
1508 // Delete the component that contains the useTransition hook. This
1509 // component no longer blocks the transition from completing. But the
1510 // pending update to Sibling should not be allowed to finish, because it's
1511 // part of the async action.
1512 await act(() => {
1513 root.render(<App showUpdater={false} />);
1514 });
1515 assertLog(['A']);
1516 expect(root).toMatchRenderedOutput(<span>A</span>);
1517
1518 // Finish the async action. Notice the intermediate B state was never
1519 // shown, because it was batched with the update that came later in the
1520 // same action.
1521 await act(() => resolveText('Wait before updating to C'));
1522 assertLog(['Async action ended', 'C']);
1523 expect(root).toMatchRenderedOutput(<span>C</span>);
1524
1525 // Check that subsequent updates are unaffected.
1526 await act(() => setText('D'));
1527 assertLog(['D']);
1528 expect(root).toMatchRenderedOutput(<span>D</span>);
1529 },
1530 );
1531
1532 it(
1533 'updates in an async action are entangled even if useTransition hook ' +
1534 'is unmounted before it finishes (root update)',
1535 async () => {
1536 let startTransition;
1537 function Updater() {
1538 const [isPending, _start] = useTransition();
1539 startTransition = _start;
1540 return (
1541 <span>
1542 <Text text={'Pending: ' + isPending} />
1543 </span>
1544 );
1545 }
1546
1547 let setShowUpdater;
1548 function App({text}) {
1549 const [showUpdater, _setShowUpdater] = useState(true);
1550 setShowUpdater = _setShowUpdater;
1551 return (
1552 <>
1553 {showUpdater ? <Updater /> : null}
1554 <span>
1555 <Text text={text} />
1556 </span>
1557 </>
1558 );
1559 }
1560
1561 const root = ReactNoop.createRoot();
1562 await act(() => {
1563 root.render(<App text="A" />);
1564 });
1565 assertLog(['Pending: false', 'A']);
1566 expect(root).toMatchRenderedOutput(
1567 <>
1568 <span>Pending: false</span>
1569 <span>A</span>
1570 </>,
1571 );
1572
1573 // Start an async action that has multiple updates with async
1574 // operations in between.
1575 await act(() => {
1576 startTransition(async () => {
1577 Scheduler.log('Async action started');
1578 startTransition(() => root.render(<App text="B" />));
1579
1580 await getText('Wait before updating to C');
1581
1582 Scheduler.log('Async action ended');
1583 startTransition(() => root.render(<App text="C" />));
1584 });
1585 });
1586 assertLog(['Async action started', 'Pending: true']);
1587 expect(root).toMatchRenderedOutput(
1588 <>
1589 <span>Pending: true</span>
1590 <span>A</span>
1591 </>,
1592 );
1593
1594 // Delete the component that contains the useTransition hook. This
1595 // component no longer blocks the transition from completing. But the
1596 // pending update to Sibling should not be allowed to finish, because it's
1597 // part of the async action.
1598 await act(() => setShowUpdater(false));
1599 assertLog(['A']);
1600 expect(root).toMatchRenderedOutput(<span>A</span>);
1601
1602 // Finish the async action. Notice the intermediate B state was never
1603 // shown, because it was batched with the update that came later in the
1604 // same action.
1605 await act(() => resolveText('Wait before updating to C'));
1606 assertLog(['Async action ended', 'C']);
1607 expect(root).toMatchRenderedOutput(<span>C</span>);
1608
1609 // Check that subsequent updates are unaffected.
1610 await act(() => root.render(<App text="D" />));
1611 assertLog(['D']);
1612 expect(root).toMatchRenderedOutput(<span>D</span>);
1613 },
1614 );
1615
1616 it('React.startTransition supports async actions', async () => {
1617 const startTransition = React.startTransition;
1618
1619 function App({text}) {
1620 return <Text text={text} />;
1621 }
1622
1623 const root = ReactNoop.createRoot();
1624 await act(() => {
1625 root.render(<App text="A" />);
1626 });
1627 assertLog(['A']);
1628
1629 await act(() => {
1630 startTransition(async () => {
1631 // Update to B
1632 root.render(<App text="B" />);
1633
1634 // There's an async gap before C is updated
1635 await getText('Wait before updating to C');
1636 root.render(<App text="C" />);
1637
1638 Scheduler.log('Async action ended');
1639 });
1640 });
1641 // The update to B is blocked because the async action hasn't completed yet.
1642 assertLog([]);
1643 expect(root).toMatchRenderedOutput('A');
1644
1645 // Finish the async action
1646 await act(() => resolveText('Wait before updating to C'));
1647
1648 // Now both B and C can finish in a single batch.
1649 assertLog(['Async action ended', 'C']);
1650 expect(root).toMatchRenderedOutput('C');
1651 });
1652
1653 it('useOptimistic works with async actions passed to React.startTransition', async () => {
1654 const startTransition = React.startTransition;
1655
1656 let setOptimisticText;
1657 function App({text: canonicalText}) {
1658 const [text, _setOptimisticText] = useOptimistic(
1659 canonicalText,
1660 (_, optimisticText) => `${optimisticText} (loading...)`,
1661 );
1662 setOptimisticText = _setOptimisticText;
1663 return (
1664 <span>
1665 <Text text={text} />
1666 </span>
1667 );
1668 }
1669
1670 const root = ReactNoop.createRoot();
1671 await act(() => {
1672 root.render(<App text="Initial" />);
1673 });
1674 assertLog(['Initial']);
1675 expect(root).toMatchRenderedOutput(<span>Initial</span>);
1676
1677 // Start an async action using the non-hook form of startTransition. The
1678 // action includes an optimistic update.
1679 await act(() => {
1680 startTransition(async () => {
1681 Scheduler.log('Async action started');
1682 setOptimisticText('Updated');
1683 await getText('Yield before updating');
1684 Scheduler.log('Async action ended');
1685 startTransition(() => root.render(<App text="Updated" />));
1686 });
1687 });
1688 // Because the action hasn't finished yet, the optimistic UI is shown.
1689 assertLog(['Async action started', 'Updated (loading...)']);
1690 expect(root).toMatchRenderedOutput(<span>Updated (loading...)</span>);
1691
1692 // Finish the async action. The optimistic state is reverted and replaced by
1693 // the canonical state.
1694 await act(() => resolveText('Yield before updating'));
1695 assertLog(['Async action ended', 'Updated']);
1696 expect(root).toMatchRenderedOutput(<span>Updated</span>);
1697 });
1698
1699 it(
1700 'regression: updates in an action passed to React.startTransition are batched ' +
1701 'even if there were no updates before the first await',
1702 async () => {
1703 // Regression for a bug that occurred in an older, too-clever-by-half
1704 // implementation of the isomorphic startTransition API. Now, the
1705 // isomorphic startTransition is literally the composition of every
1706 // reconciler instance's startTransition, so the behavior is less likely
1707 // to regress in the future.
1708 const startTransition = React.startTransition;
1709
1710 let setOptimisticText;
1711 function App({text: canonicalText}) {
1712 const [text, _setOptimisticText] = useOptimistic(
1713 canonicalText,
1714 (_, optimisticText) => `${optimisticText} (loading...)`,
1715 );
1716 setOptimisticText = _setOptimisticText;
1717 return (
1718 <span>
1719 <Text text={text} />
1720 </span>
1721 );
1722 }
1723
1724 const root = ReactNoop.createRoot();
1725 await act(() => {
1726 root.render(<App text="Initial" />);
1727 });
1728 assertLog(['Initial']);
1729 expect(root).toMatchRenderedOutput(<span>Initial</span>);
1730
1731 // Start an async action using the non-hook form of startTransition. The
1732 // action includes an optimistic update.
1733 await act(() => {
1734 startTransition(async () => {
1735 Scheduler.log('Async action started');
1736
1737 // Yield to an async task *before* any updates have occurred.
1738 await getText('Yield before optimistic update');
1739
1740 // This optimistic update happens after an async gap. In the
1741 // regression case, this update was not correctly associated with
1742 // the outer async action, causing the optimistic update to be
1743 // immediately reverted.
1744 setOptimisticText('Updated');
1745
1746 await getText('Yield before updating');
1747 Scheduler.log('Async action ended');
1748 startTransition(() => root.render(<App text="Updated" />));
1749 });
1750 });
1751 assertLog(['Async action started']);
1752
1753 // Wait for an async gap, then schedule an optimistic update.
1754 await act(() => resolveText('Yield before optimistic update'));
1755
1756 // Because the action hasn't finished yet, the optimistic UI is shown.
1757 assertLog(['Updated (loading...)']);
1758 expect(root).toMatchRenderedOutput(<span>Updated (loading...)</span>);
1759
1760 // Finish the async action. The optimistic state is reverted and replaced
1761 // by the canonical state.
1762 await act(() => resolveText('Yield before updating'));
1763 assertLog(['Async action ended', 'Updated']);
1764 expect(root).toMatchRenderedOutput(<span>Updated</span>);
1765 },
1766 );
1767
1768 it('React.startTransition captures async errors and passes them to reportError', async () => {
1769 await act(() => {
1770 React.startTransition(async () => {
1771 throw new Error('Oops');
1772 });
1773 });
1774 assertLog(['reportError: Oops']);
1775 });
1776
1777 it('React.startTransition captures sync errors and passes them to reportError', async () => {
1778 await act(() => {
1779 try {
1780 React.startTransition(() => {
1781 throw new Error('Oops');
1782 });
1783 } catch (e) {
1784 throw new Error('Should not be reachable.');
1785 }
1786 });
1787 assertLog(['reportError: Oops']);
1788 });
1789
1790 // @gate enableOptimisticKey
1791 it('reconciles against new items when optimisticKey is used', async () => {
1792 const startTransition = React.startTransition;
1793
1794 function Item({text}) {
1795 const [initialText] = React.useState(text);
1796 return <span>{initialText + '-' + text}</span>;
1797 }
1798
1799 let addOptimisticItem;
1800 function App({items}) {
1801 const [optimisticItems, _addOptimisticItem] = useOptimistic(
1802 items,
1803 (canonicalItems, optimisticText) =>
1804 canonicalItems.concat({
1805 id: React.optimisticKey,
1806 text: optimisticText,
1807 }),
1808 );
1809 addOptimisticItem = _addOptimisticItem;
1810 return (
1811 <div>
1812 {optimisticItems.map(item => (
1813 <Item key={item.id} text={item.text} />
1814 ))}
1815 </div>
1816 );
1817 }
1818
1819 const A = {
1820 id: 'a',
1821 text: 'A',
1822 };
1823
1824 const B = {
1825 id: 'b',
1826 text: 'B',
1827 };
1828
1829 const root = ReactNoop.createRoot();
1830 await act(() => {
1831 root.render(<App items={[A]} />);
1832 });
1833 expect(root).toMatchRenderedOutput(
1834 <div>
1835 <span>A-A</span>
1836 </div>,
1837 );
1838
1839 // Start an async action using the non-hook form of startTransition. The
1840 // action includes an optimistic update.
1841 await act(() => {
1842 startTransition(async () => {
1843 addOptimisticItem('b');
1844 await getText('Yield before updating');
1845 startTransition(() => root.render(<App items={[A, B]} />));
1846 });
1847 });
1848 // Because the action hasn't finished yet, the optimistic UI is shown.
1849 expect(root).toMatchRenderedOutput(
1850 <div>
1851 <span>A-A</span>
1852 <span>b-b</span>
1853 </div>,
1854 );
1855
1856 // Finish the async action. The optimistic state is reverted and replaced by
1857 // the canonical state. The state is transferred to the new row.
1858 await act(() => {
1859 resolveText('Yield before updating');
1860 });
1861 expect(root).toMatchRenderedOutput(
1862 <div>
1863 <span>A-A</span>
1864 <span>b-B</span>
1865 </div>,
1866 );
1867 });
1868 });