548
expect(root).toMatchRenderedOutput('C');
549
},
550
);
551
+
552
+ // @gate experimental
553
+ // @gate enableCache
554
+ test('interrupt a refresh transition if a new transition is scheduled', async () => {
555
+ const root = ReactNoop.createRoot();
556
+
557
+ await ReactNoop.act(async () => {
558
+ root.render(
559
+ <>
560
+ <Suspense fallback={<Text text="Loading..." />} />
561
+ <Text text="Initial" />
562
+ </>,
563
+ );
564
+ });
565
+ expect(Scheduler).toHaveYielded(['Initial']);
566
+ expect(root).toMatchRenderedOutput('Initial');
567
+
568
+ await ReactNoop.act(async () => {
569
+ // Start a refresh transition
570
+ startTransition(() => {
571
+ root.render(
572
+ <>
573
+ <Suspense fallback={<Text text="Loading..." />}>
574
+ <AsyncText text="Async" />
575
+ </Suspense>
576
+ <Text text="After Suspense" />
577
+ <Text text="Sibling" />
578
+ </>,
579
+ );
580
+ });
581
+
582
+ // Partially render it.
583
+ expect(Scheduler).toFlushAndYieldThrough([
584
+ // Once we the update suspends, we know it's a refresh transition,
585
+ // because the Suspense boundary has already mounted.
586
+ 'Suspend! [Async]',
587
+ 'Loading...',
588
+ 'After Suspense',
589
+ ]);
590
+
591
+ // Schedule a new transition
592
+ startTransition(async () => {
593
+ root.render(
594
+ <>
595
+ <Suspense fallback={<Text text="Loading..." />} />
596
+ <Text text="Updated" />
597
+ </>,
598
+ );
599
+ });
600
+ });
601
+
602
+ // Because the first one is going to suspend regardless, we should
603
+ // immediately switch to rendering the new transition.
604
+ expect(Scheduler).toHaveYielded(['Updated']);
605
+ expect(root).toMatchRenderedOutput('Updated');
606
+ });
607
+
608
+ // @gate experimental
609
+ // @gate enableCache
610
+ test(
611
+ "interrupt a refresh transition when something suspends and we've " +
612
+ 'already bailed out on another transition in a parent',
613
+ async () => {
614
+ let setShouldSuspend;
615
+
616
+ function Parent({children}) {
617
+ const [shouldHideInParent, _setShouldHideInParent] = useState(false);
618
+ setShouldHideInParent = _setShouldHideInParent;
619
+ Scheduler.unstable_yieldValue(
620
+ 'shouldHideInParent: ' + shouldHideInParent,
621
+ );
622
+ if (shouldHideInParent) {
623
+ return <Text text="(empty)" />;
624
+ }
625
+ return children;
626
+ }
627
+
628
+ let setShouldHideInParent;
629
+ function App() {
630
+ const [shouldSuspend, _setShouldSuspend] = useState(false);
631
+ setShouldSuspend = _setShouldSuspend;
632
+ return (
633
+ <>
634
+ <Text text="A" />
635
+ <Parent>
636
+ <Suspense fallback={<Text text="Loading..." />}>
637
+ {shouldSuspend ? <AsyncText text="Async" /> : null}
638
+ </Suspense>
639
+ </Parent>
640
+ <Text text="B" />
641
+ <Text text="C" />
642
+ </>
643
+ );
644
+ }
645
+
646
+ const root = ReactNoop.createRoot();
647
+
648
+ await act(async () => {
649
+ root.render(<App />);
650
+ expect(Scheduler).toFlushAndYield([
651
+ 'A',
652
+ 'shouldHideInParent: false',
653
+ 'B',
654
+ 'C',
655
+ ]);
656
+ expect(root).toMatchRenderedOutput('ABC');
657
+
658
+ // Schedule an update
659
+ startTransition(() => {
660
+ setShouldSuspend(true);
661
+ });
662
+
663
+ // Now we need to trigger schedule another transition in a different
664
+ // lane from the first one. At the time this was written, all transitions are worked on
665
+ // simultaneously, unless a transition was already in progress when a
666
+ // new one was scheduled. So, partially render the first transition.
667
+ expect(Scheduler).toFlushAndYieldThrough(['A']);
668
+
669
+ // Now schedule a second transition. We won't interrupt the first one.
670
+ React.unstable_startTransition(() => {
671
+ setShouldHideInParent(true);
672
+ });
673
+ // Continue rendering the first transition.
674
+ expect(Scheduler).toFlushAndYieldThrough([
675
+ 'shouldHideInParent: false',
676
+ 'Suspend! [Async]',
677
+ 'Loading...',
678
+ 'B',
679
+ ]);
680
+ // Should not have committed loading state
681
+ expect(root).toMatchRenderedOutput('ABC');
682
+
683
+ // At this point, we've processed the parent update queue, so we know
684
+ // that it has a pending update from the second transition, even though
685
+ // we skipped it during this render. And we know this is a refresh
686
+ // transition, because we had to render a loading state. So the next
687
+ // time we re-enter the work loop (we don't interrupt immediately, we
688
+ // just wait for the next time slice), we should throw out the
689
+ // suspended first transition and try the second one.
690
+ expect(Scheduler).toFlushUntilNextPaint([
691
+ 'shouldHideInParent: true',
692
+ '(empty)',
693
+ ]);
694
+ expect(root).toMatchRenderedOutput('A(empty)BC');
695
+
696
+ // Since the two transitions are not entangled, we then later go back
697
+ // and finish retry the first transition. Not really relevant to this
698
+ // test but I'll assert the result anyway.
699
+ expect(Scheduler).toFlushAndYield([
700
+ 'A',
701
+ 'shouldHideInParent: true',
702
+ '(empty)',
703
+ 'B',
704
+ 'C',
705
+ ]);
706
+ expect(root).toMatchRenderedOutput('A(empty)BC');
707
+ });
708
+ },
709
+ );
710
+
711
+ // @gate experimental
712
+ // @gate enableCache
713
+ test(
714
+ 'interrupt a refresh transition when something suspends and a parent ' +
715
+ 'component received an interleaved update after its queue was processed',
716
+ async () => {
717
+ // Title is confusing so I'll try to explain further: This is similar to
718
+ // the previous test, except instead of skipped over a transition update
719
+ // in a parent, the parent receives an interleaved update *after* its
720
+ // begin phase has already finished.
721
+
722
+ function App({shouldSuspend, step}) {
723
+ return (
724
+ <>
725
+ <Text text={`A${step}`} />
726
+ <Suspense fallback={<Text text="Loading..." />}>
727
+ {shouldSuspend ? <AsyncText text="Async" ms={2000} /> : null}
728
+ </Suspense>
729
+ <Text text={`B${step}`} />
730
+ <Text text={`C${step}`} />
731
+ </>
732
+ );
733
+ }
734
+
735
+ const root = ReactNoop.createRoot();
736
+
737
+ await ReactNoop.act(async () => {
738
+ root.render(<App shouldSuspend={false} step={0} />);
739
+ });
740
+ expect(Scheduler).toHaveYielded(['A0', 'B0', 'C0']);
741
+ expect(root).toMatchRenderedOutput('A0B0C0');
742
+
743
+ await ReactNoop.act(async () => {
744
+ // This update will suspend.
745
+ startTransition(() => {
746
+ root.render(<App shouldSuspend={true} step={1} />);
747
+ });
748
+ // Flush past the root, but stop before the async component.
749
+ expect(Scheduler).toFlushAndYieldThrough(['A1']);
750
+
751
+ // Schedule another transition on the root, which already completed.
752
+ startTransition(() => {
753
+ root.render(<App shouldSuspend={false} step={2} />);
754
+ });
755
+ // We'll keep working on the first update.
756
+ expect(Scheduler).toFlushAndYieldThrough([
757
+ // Now the async component suspends
758
+ 'Suspend! [Async]',
759
+ 'Loading...',
760
+ 'B1',
761
+ ]);
762
+ // Should not have committed loading state
763
+ expect(root).toMatchRenderedOutput('A0B0C0');
764
+
765
+ // After suspending, should abort the first update and switch to the
766
+ // second update. So, C1 should not appear in the log.
767
+ // TODO: This should work even if React does not yield to the main
768
+ // thread. Should use same mechanism as selective hydration to interrupt
769
+ // the render before the end of the current slice of work.
770
+ expect(Scheduler).toFlushAndYield(['A2', 'B2', 'C2']);
771
+
772
+ expect(root).toMatchRenderedOutput('A2B2C2');
773
+ });
774
+ },
775
+ );
776
});