Add forwards and backwards options to SuspenseList (#15918)
* Add forwards option * Add backwards option * Add comment * Add customized warning messages for case and typos * Add some more tests for insertions and updates in start/middle/end
Sebastian Markbåge committed
Jun 20, 2019 at 11:03 UTC
270dc2e4debe8c198ce6dc9200ab72df4ee4926b
4 files changed
+734
-29
packages/react-reconciler/src/ReactFiberBeginWork.js
+149
-8
@@ -127,6 +127,7 @@ import {
127
addSubtreeSuspenseContext,
128
setShallowSuspenseContext,
129
} from './ReactFiberSuspenseContext';
130
+import {isShowingAnyFallbacks} from './ReactFiberSuspenseComponent';
131
import {
132
pushProvider,
133
propagateContextChange,
@@ -1983,8 +1984,36 @@ function propagateSuspenseContextChange(
1984
}
1985
}
1986
1986
-type SuspenseListRevealOrder = 'together' | void;
1987
+function findLastContentRow(firstChild: null | Fiber): null | Fiber {
1988
+ // This is going to find the last row among these children that is already
1989
+ // showing content on the screen, as opposed to being in fallback state or
1990
+ // new. If a row has multiple Suspense boundaries, any of them being in the
1991
+ // fallback state, counts as the whole row being in a fallback state.
1992
+ // Note that the "rows" will be workInProgress, but any nested children
1993
+ // will still be current since we haven't rendered them yet. The mounted
1994
+ // order may not be the same as the new order. We use the new order.
1995
+ let row = firstChild;
1996
+ let lastContentRow: null | Fiber = null;
1997
+ while (row !== null) {
1998
+ let currentRow = row.alternate;
1999
+ // New rows can't be content rows.
2000
+ if (currentRow !== null && !isShowingAnyFallbacks(currentRow)) {
2001
+ lastContentRow = row;
2002
+ }
2003
+ row = row.sibling;
2004
+ }
2005
+ return lastContentRow;
2006
+}
2007
+
2008
+type SuspenseListRevealOrder = 'forwards' | 'backwards' | 'together' | void;
2009
2010
+// This can end up rendering this component multiple passes.
2011
+// The first pass splits the children fibers into two sets. A head and tail.
2012
+// We first render the head. If anything is in fallback state, we do another
2013
+// pass through beginWork to rerender all children (including the tail) with
2014
+// the force suspend context. If the first render didn't have anything in
2015
+// in fallback state. Then we render each row in the tail one-by-one.
2016
+// That happens in the completeWork phase without going back to beginWork.
2017
function updateSuspenseListComponent(
2018
current: Fiber | null,
2019
workInProgress: Fiber,
@@ -2033,6 +2062,10 @@ function updateSuspenseListComponent(
2062
);
2063
suspenseListState = {
2064
didSuspend: true,
2065
+ isBackwards: false,
2066
+ rendering: null,
2067
+ last: null,
2068
+ tail: null,
2069
};
2070
} else {
2071
let didForceFallback =
@@ -2063,7 +2096,79 @@ function updateSuspenseListComponent(
2096
}
2097
2098
switch (revealOrder) {
2066
- // TODO: For other reveal orders we'll need to split the nextChildFibers set.
2099
+ case 'forwards': {
2100
+ // If need to force fallbacks in this pass we're just going to
2101
+ // force the whole set to suspend so we don't have to do anything
2102
+ // further here.
2103
+ if (!shouldForceFallback) {
2104
+ let lastContentRow = findLastContentRow(nextChildFibers);
2105
+ let tail;
2106
+ if (lastContentRow === null) {
2107
+ // The whole list is part of the tail.
2108
+ // TODO: We could fast path by just rendering the tail now.
2109
+ tail = nextChildFibers;
2110
+ nextChildFibers = null;
2111
+ } else {
2112
+ // Disconnect the tail rows after the content row.
2113
+ // We're going to render them separately later.
2114
+ tail = lastContentRow.sibling;
2115
+ lastContentRow.sibling = null;
2116
+ }
2117
+ if (suspenseListState === null) {
2118
+ suspenseListState = {
2119
+ didSuspend: false,
2120
+ isBackwards: false,
2121
+ rendering: null,
2122
+ last: lastContentRow,
2123
+ tail: tail,
2124
+ };
2125
+ } else {
2126
+ suspenseListState.tail = tail;
2127
+ }
2128
+ }
2129
+ break;
2130
+ }
2131
+ case 'backwards': {
2132
+ // If need to force fallbacks in this pass we're just going to
2133
+ // force the whole set to suspend so we don't have to do anything
2134
+ // further here.
2135
+ if (!shouldForceFallback) {
2136
+ // We're going to find the first row that has existing content.
2137
+ // At the same time we're going to reverse the list of everything
2138
+ // we pass in the meantime. That's going to be our tail in reverse
2139
+ // order.
2140
+ let tail = null;
2141
+ let row = nextChildFibers;
2142
+ nextChildFibers = null;
2143
+ while (row !== null) {
2144
+ let currentRow = row.alternate;
2145
+ // New rows can't be content rows.
2146
+ if (currentRow !== null && !isShowingAnyFallbacks(currentRow)) {
2147
+ // This is the beginning of the main content.
2148
+ nextChildFibers = row;
2149
+ break;
2150
+ }
2151
+ let nextRow = row.sibling;
2152
+ row.sibling = tail;
2153
+ tail = row;
2154
+ row = nextRow;
2155
+ }
2156
+ // TODO: If nextChildFibers is null, we can continue on the tail immediately.
2157
+ if (suspenseListState === null) {
2158
+ suspenseListState = {
2159
+ didSuspend: false,
2160
+ isBackwards: true,
2161
+ rendering: null,
2162
+ last: null,
2163
+ tail: tail,
2164
+ };
2165
+ } else {
2166
+ suspenseListState.isBackwards = true;
2167
+ suspenseListState.tail = tail;
2168
+ }
2169
+ }
2170
+ break;
2171
+ }
2172
case 'together': {
2173
break;
2174
}
@@ -2076,12 +2181,48 @@ function updateSuspenseListComponent(
2181
!didWarnAboutRevealOrder[revealOrder]
2182
) {
2183
didWarnAboutRevealOrder[revealOrder] = true;
2079
- warning(
2080
- false,
2081
- '"%s" is not a supported revealOrder on <SuspenseList />. ' +
2082
- 'Did you mean "together"?',
2083
- revealOrder,
2084
- );
2184
+ if (typeof revealOrder === 'string') {
2185
+ switch (revealOrder.toLowerCase()) {
2186
+ case 'together':
2187
+ case 'forwards':
2188
+ case 'backwards': {
2189
+ warning(
2190
+ false,
2191
+ '"%s" is not a valid value for revealOrder on <SuspenseList />. ' +
2192
+ 'Use lowercase "%s" instead.',
2193
+ revealOrder,
2194
+ revealOrder.toLowerCase(),
2195
+ );
2196
+ break;
2197
+ }
2198
+ case 'forward':
2199
+ case 'backward': {
2200
+ warning(
2201
+ false,
2202
+ '"%s" is not a valid value for revealOrder on <SuspenseList />. ' +
2203
+ 'React uses the -s suffix in the spelling. Use "%ss" instead.',
2204
+ revealOrder,
2205
+ revealOrder.toLowerCase(),
2206
+ );
2207
+ break;
2208
+ }
2209
+ default:
2210
+ warning(
2211
+ false,
2212
+ '"%s" is not a supported revealOrder on <SuspenseList />. ' +
2213
+ 'Did you mean "together", "forwards" or "backwards"?',
2214
+ revealOrder,
2215
+ );
2216
+ break;
2217
+ }
2218
+ } else {
2219
+ warning(
2220
+ false,
2221
+ '%s is not a supported value for revealOrder on <SuspenseList />. ' +
2222
+ 'Did you mean "together", "forwards" or "backwards"?',
2223
+ revealOrder,
2224
+ );
2225
+ }
2226
}
2227
}
2228
// We mark this as having captured but it really just says to the
packages/react-reconciler/src/ReactFiberCompleteWork.js
+83
-20
@@ -18,7 +18,10 @@ import type {
18
ChildSet,
19
} from './ReactFiberHostConfig';
20
import type {ReactEventComponentInstance} from 'shared/ReactTypes';
21
-import type {SuspenseState} from './ReactFiberSuspenseComponent';
21
+import type {
22
+ SuspenseState,
23
+ SuspenseListState,
24
+} from './ReactFiberSuspenseComponent';
25
import type {SuspenseContext} from './ReactFiberSuspenseContext';
26
27
import {
@@ -84,7 +87,12 @@ import {
87
InvisibleParentSuspenseContext,
88
hasSuspenseContext,
89
popSuspenseContext,
90
+ pushSuspenseContext,
91
+ setShallowSuspenseContext,
92
+ ForceSuspenseFallback,
93
+ setDefaultShallowSuspenseContext,
94
} from './ReactFiberSuspenseContext';
95
+import {isShowingAnyFallbacks} from './ReactFiberSuspenseComponent';
96
import {
97
isContextProvider as isLegacyContextProvider,
98
popContext as popLegacyContext,
@@ -917,25 +925,80 @@ function completeWork(
925
popSuspenseContext(workInProgress);
926
927
if ((workInProgress.effectTag & DidCapture) === NoEffect) {
920
- // This is the first pass. We need to figure out if anything is still
921
- // suspended in the rendered set.
922
- const renderedChildren = workInProgress.child;
923
- // If new content unsuspended, but there's still some content that
924
- // didn't. Then we need to do a second pass that forces everything
925
- // to keep showing their fallbacks.
926
- const needsRerender = hasSuspendedChildrenAndNewContent(
927
- workInProgress,
928
- renderedChildren,
929
- );
930
- if (needsRerender) {
931
- // Rerender the whole list, but this time, we'll force fallbacks
932
- // to stay in place.
933
- workInProgress.effectTag |= DidCapture;
934
- // Reset the effect list before doing the second pass since that's now invalid.
935
- workInProgress.firstEffect = workInProgress.lastEffect = null;
936
- // Schedule work so we know not to bail out.
937
- workInProgress.expirationTime = renderExpirationTime;
938
- return workInProgress;
928
+ let suspenseListState: null | SuspenseListState =
929
+ workInProgress.memoizedState;
930
+ if (
931
+ suspenseListState === null ||
932
+ suspenseListState.rendering === null
933
+ ) {
934
+ // This is the first pass. We need to figure out if anything is still
935
+ // suspended in the rendered set.
936
+ const renderedChildren = workInProgress.child;
937
+ // If new content unsuspended, but there's still some content that
938
+ // didn't. Then we need to do a second pass that forces everything
939
+ // to keep showing their fallbacks.
940
+ const needsRerender = hasSuspendedChildrenAndNewContent(
941
+ workInProgress,
942
+ renderedChildren,
943
+ );
944
+ if (needsRerender) {
945
+ // Rerender the whole list, but this time, we'll force fallbacks
946
+ // to stay in place.
947
+ workInProgress.effectTag |= DidCapture;
948
+ // Reset the effect list before doing the second pass since that's now invalid.
949
+ workInProgress.firstEffect = workInProgress.lastEffect = null;
950
+ // Schedule work so we know not to bail out.
951
+ workInProgress.expirationTime = renderExpirationTime;
952
+ return workInProgress;
953
+ }
954
+ } else {
955
+ // Append the rendered row to the child list.
956
+ let rendered = suspenseListState.rendering;
957
+ if (!suspenseListState.didSuspend) {
958
+ suspenseListState.didSuspend = isShowingAnyFallbacks(rendered);
959
+ }
960
+ if (suspenseListState.isBackwards) {
961
+ // The effect list of the backwards tail will have been added
962
+ // to the end. This breaks the guarantee that life-cycles fire in
963
+ // sibling order but that isn't a strong guarantee promised by React.
964
+ // Especially since these might also just pop in during future commits.
965
+ // Append to the beginning of the list.
966
+ rendered.sibling = workInProgress.child;
967
+ workInProgress.child = rendered;
968
+ } else {
969
+ let previousSibling = suspenseListState.last;
970
+ if (previousSibling !== null) {
971
+ previousSibling.sibling = rendered;
972
+ } else {
973
+ workInProgress.child = rendered;
974
+ }
975
+ suspenseListState.last = rendered;
976
+ }
977
+ }
978
+
979
+ if (suspenseListState !== null && suspenseListState.tail !== null) {
980
+ // We still have tail rows to render.
981
+ // Pop a row.
982
+ let next = suspenseListState.tail;
983
+ suspenseListState.rendering = next;
984
+ suspenseListState.tail = next.sibling;
985
+ next.sibling = null;
986
+
987
+ // Restore the context.
988
+ // TODO: We can probably just avoid popping it instead and only
989
+ // setting it the first time we go from not suspended to suspended.
990
+ let suspenseContext = suspenseStackCursor.current;
991
+ if (suspenseListState.didSuspend) {
992
+ suspenseContext = setShallowSuspenseContext(
993
+ suspenseContext,
994
+ ForceSuspenseFallback,
995
+ );
996
+ } else {
997
+ suspenseContext = setDefaultShallowSuspenseContext(suspenseContext);
998
+ }
999
+ pushSuspenseContext(workInProgress, suspenseContext);
1000
+ // Do a pass over the next row.
1001
+ return next;
1002
}
1003
} else {
1004
workInProgress.effectTag &= ~DidCapture;
packages/react-reconciler/src/ReactFiberSuspenseComponent.js
+36
@@ -8,12 +8,20 @@
8
*/
9
10
import type {Fiber} from './ReactFiber';
11
+import {SuspenseComponent} from 'shared/ReactWorkTags';
12
13
// TODO: This is now an empty object. Should we switch this to a boolean?
14
export type SuspenseState = {||};
15
16
export type SuspenseListState = {|
17
didSuspend: boolean,
18
+ isBackwards: boolean,
19
+ // The currently rendering tail row.
20
+ rendering: null | Fiber,
21
+ // The last of the already rendered children.
22
+ last: null | Fiber,
23
+ // Remaining rows on the tail of the list.
24
+ tail: null | Fiber,
25
|};
26
27
export function shouldCaptureSuspense(
@@ -43,3 +51,31 @@ export function shouldCaptureSuspense(
51
// If the parent is not able to handle it, we must handle it.
52
return true;
53
}
54
+
55
+export function isShowingAnyFallbacks(row: Fiber): boolean {
56
+ let node = row;
57
+ while (node !== null) {
58
+ if (node.tag === SuspenseComponent) {
59
+ const state: SuspenseState | null = node.memoizedState;
60
+ if (state !== null) {
61
+ return true;
62
+ }
63
+ } else if (node.child !== null) {
64
+ node.child.return = node;
65
+ node = node.child;
66
+ continue;
67
+ }
68
+ if (node === row) {
69
+ return false;
70
+ }
71
+ while (node.sibling === null) {
72
+ if (node.return === null || node.return === row) {
73
+ return false;
74
+ }
75
+ node = node.return;
76
+ }
77
+ node.sibling.return = node.return;
78
+ node = node.sibling;
79
+ }
80
+ return false;
81
+}
packages/react-reconciler/src/__tests__/ReactSuspenseList-test.internal.js
+466
-1
@@ -56,7 +56,46 @@ describe('ReactSuspenseList', () => {
56
57
expect(() => Scheduler.flushAll()).toWarnDev([
58
'Warning: "something" is not a supported revealOrder on ' +
59
- '<SuspenseList />. Did you mean "together"?' +
59
+ '<SuspenseList />. Did you mean "together", "forwards" or "backwards"?' +
60
+ '\n in SuspenseList (at **)' +
61
+ '\n in Foo (at **)',
62
+ ]);
63
+ });
64
+
65
+ it('warns if a upper case revealOrder option is used', () => {
66
+ function Foo() {
67
+ return (
68
+ <SuspenseList revealOrder="TOGETHER">
69
+ <Suspense fallback="Loading">Content</Suspense>
70
+ </SuspenseList>
71
+ );
72
+ }
73
+
74
+ ReactNoop.render(<Foo />);
75
+
76
+ expect(() => Scheduler.flushAll()).toWarnDev([
77
+ 'Warning: "TOGETHER" is not a valid value for revealOrder on ' +
78
+ '<SuspenseList />. Use lowercase "together" instead.' +
79
+ '\n in SuspenseList (at **)' +
80
+ '\n in Foo (at **)',
81
+ ]);
82
+ });
83
+
84
+ it('warns if a misspelled revealOrder option is used', () => {
85
+ function Foo() {
86
+ return (
87
+ <SuspenseList revealOrder="forward">
88
+ <Suspense fallback="Loading">Content</Suspense>
89
+ </SuspenseList>
90
+ );
91
+ }
92
+
93
+ ReactNoop.render(<Foo />);
94
+
95
+ expect(() => Scheduler.flushAll()).toWarnDev([
96
+ 'Warning: "forward" is not a valid value for revealOrder on ' +
97
+ '<SuspenseList />. React uses the -s suffix in the spelling. ' +
98
+ 'Use "forwards" instead.' +
99
'\n in SuspenseList (at **)' +
100
'\n in Foo (at **)',
101
]);
@@ -562,4 +601,430 @@ describe('ReactSuspenseList', () => {
601
</Fragment>,
602
);
603
});
604
+
605
+ it('displays each items in "forwards" order', async () => {
606
+ let A = createAsyncText('A');
607
+ let B = createAsyncText('B');
608
+ let C = createAsyncText('C');
609
+
610
+ function Foo() {
611
+ return (
612
+ <SuspenseList revealOrder="forwards">
613
+ <Suspense fallback={<Text text="Loading A" />}>
614
+ <A />
615
+ </Suspense>
616
+ <Suspense fallback={<Text text="Loading B" />}>
617
+ <B />
618
+ </Suspense>
619
+ <Suspense fallback={<Text text="Loading C" />}>
620
+ <C />
621
+ </Suspense>
622
+ </SuspenseList>
623
+ );
624
+ }
625
+
626
+ await C.resolve();
627
+
628
+ ReactNoop.render(<Foo />);
629
+
630
+ expect(Scheduler).toFlushAndYield([
631
+ 'Suspend! [A]',
632
+ 'Loading A',
633
+ 'Loading B',
634
+ 'Loading C',
635
+ ]);
636
+
637
+ expect(ReactNoop).toMatchRenderedOutput(
638
+ <Fragment>
639
+ <span>Loading A</span>
640
+ <span>Loading B</span>
641
+ <span>Loading C</span>
642
+ </Fragment>,
643
+ );
644
+
645
+ await A.resolve();
646
+
647
+ expect(Scheduler).toFlushAndYield(['A', 'Suspend! [B]']);
648
+
649
+ expect(ReactNoop).toMatchRenderedOutput(
650
+ <Fragment>
651
+ <span>A</span>
652
+ <span>Loading B</span>
653
+ <span>Loading C</span>
654
+ </Fragment>,
655
+ );
656
+
657
+ await B.resolve();
658
+
659
+ expect(Scheduler).toFlushAndYield(['B', 'C']);
660
+
661
+ expect(ReactNoop).toMatchRenderedOutput(
662
+ <Fragment>
663
+ <span>A</span>
664
+ <span>B</span>
665
+ <span>C</span>
666
+ </Fragment>,
667
+ );
668
+ });
669
+
670
+ it('displays each items in "backwards" order', async () => {
671
+ let A = createAsyncText('A');
672
+ let B = createAsyncText('B');
673
+ let C = createAsyncText('C');
674
+
675
+ function Foo() {
676
+ return (
677
+ <SuspenseList revealOrder="backwards">
678
+ <Suspense fallback={<Text text="Loading A" />}>
679
+ <A />
680
+ </Suspense>
681
+ <Suspense fallback={<Text text="Loading B" />}>
682
+ <B />
683
+ </Suspense>
684
+ <Suspense fallback={<Text text="Loading C" />}>
685
+ <C />
686
+ </Suspense>
687
+ </SuspenseList>
688
+ );
689
+ }
690
+
691
+ await A.resolve();
692
+
693
+ ReactNoop.render(<Foo />);
694
+
695
+ expect(Scheduler).toFlushAndYield([
696
+ 'Suspend! [C]',
697
+ 'Loading C',
698
+ 'Loading B',
699
+ 'Loading A',
700
+ ]);
701
+
702
+ expect(ReactNoop).toMatchRenderedOutput(
703
+ <Fragment>
704
+ <span>Loading A</span>
705
+ <span>Loading B</span>
706
+ <span>Loading C</span>
707
+ </Fragment>,
708
+ );
709
+
710
+ await C.resolve();
711
+
712
+ expect(Scheduler).toFlushAndYield(['C', 'Suspend! [B]']);
713
+
714
+ expect(ReactNoop).toMatchRenderedOutput(
715
+ <Fragment>
716
+ <span>Loading A</span>
717
+ <span>Loading B</span>
718
+ <span>C</span>
719
+ </Fragment>,
720
+ );
721
+
722
+ await B.resolve();
723
+
724
+ expect(Scheduler).toFlushAndYield(['B', 'A']);
725
+
726
+ expect(ReactNoop).toMatchRenderedOutput(
727
+ <Fragment>
728
+ <span>A</span>
729
+ <span>B</span>
730
+ <span>C</span>
731
+ </Fragment>,
732
+ );
733
+ });
734
+
735
+ it('displays added row at the top "together" and the bottom in "forwards" order', async () => {
736
+ let A = createAsyncText('A');
737
+ let B = createAsyncText('B');
738
+ let C = createAsyncText('C');
739
+ let D = createAsyncText('D');
740
+ let E = createAsyncText('E');
741
+ let F = createAsyncText('F');
742
+
743
+ function Foo({items}) {
744
+ return (
745
+ <SuspenseList revealOrder="forwards">
746
+ {items.map(([key, Component]) => (
747
+ <Suspense key={key} fallback={<Text text={'Loading ' + key} />}>
748
+ <Component />
749
+ </Suspense>
750
+ ))}
751
+ </SuspenseList>
752
+ );
753
+ }
754
+
755
+ await B.resolve();
756
+ await D.resolve();
757
+
758
+ ReactNoop.render(<Foo items={[['B', B], ['D', D]]} />);
759
+
760
+ expect(Scheduler).toFlushAndYield(['B', 'D']);
761
+
762
+ expect(ReactNoop).toMatchRenderedOutput(
763
+ <Fragment>
764
+ <span>B</span>
765
+ <span>D</span>
766
+ </Fragment>,
767
+ );
768
+
769
+ // Insert items in the beginning, middle and end.
770
+ ReactNoop.render(
771
+ <Foo
772
+ items={[['A', A], ['B', B], ['C', C], ['D', D], ['E', E], ['F', F]]}
773
+ />,
774
+ );
775
+
776
+ expect(Scheduler).toFlushAndYield([
777
+ 'Suspend! [A]',
778
+ 'Loading A',
779
+ 'B',
780
+ 'Suspend! [C]',
781
+ 'Loading C',
782
+ 'D',
783
+ 'Suspend! [E]',
784
+ 'Loading E',
785
+ 'Loading F',
786
+ ]);
787
+
788
+ expect(ReactNoop).toMatchRenderedOutput(
789
+ <Fragment>
790
+ <span>Loading A</span>
791
+ <span>B</span>
792
+ <span>Loading C</span>
793
+ <span>D</span>
794
+ <span>Loading E</span>
795
+ <span>Loading F</span>
796
+ </Fragment>,
797
+ );
798
+
799
+ await A.resolve();
800
+
801
+ expect(Scheduler).toFlushAndYield(['A', 'Suspend! [C]']);
802
+
803
+ // Even though we could show A, it is still in a fallback state because
804
+ // C is not yet resolved. We need to resolve everything in the head first.
805
+ expect(ReactNoop).toMatchRenderedOutput(
806
+ <Fragment>
807
+ <span>Loading A</span>
808
+ <span>B</span>
809
+ <span>Loading C</span>
810
+ <span>D</span>
811
+ <span>Loading E</span>
812
+ <span>Loading F</span>
813
+ </Fragment>,
814
+ );
815
+
816
+ await C.resolve();
817
+
818
+ expect(Scheduler).toFlushAndYield(['A', 'C', 'Suspend! [E]']);
819
+
820
+ // We can now resolve the full head.
821
+ expect(ReactNoop).toMatchRenderedOutput(
822
+ <Fragment>
823
+ <span>A</span>
824
+ <span>B</span>
825
+ <span>C</span>
826
+ <span>D</span>
827
+ <span>Loading E</span>
828
+ <span>Loading F</span>
829
+ </Fragment>,
830
+ );
831
+
832
+ await E.resolve();
833
+
834
+ expect(Scheduler).toFlushAndYield(['E', 'Suspend! [F]']);
835
+
836
+ // In the tail we can resolve one-by-one.
837
+ expect(ReactNoop).toMatchRenderedOutput(
838
+ <Fragment>
839
+ <span>A</span>
840
+ <span>B</span>
841
+ <span>C</span>
842
+ <span>D</span>
843
+ <span>E</span>
844
+ <span>Loading F</span>
845
+ </Fragment>,
846
+ );
847
+
848
+ await F.resolve();
849
+
850
+ // We can also delete some items.
851
+ ReactNoop.render(<Foo items={[['D', D], ['E', E], ['F', F]]} />);
852
+
853
+ expect(Scheduler).toFlushAndYield(['D', 'E', 'F']);
854
+
855
+ expect(ReactNoop).toMatchRenderedOutput(
856
+ <Fragment>
857
+ <span>D</span>
858
+ <span>E</span>
859
+ <span>F</span>
860
+ </Fragment>,
861
+ );
862
+ });
863
+
864
+ it('displays added row at the top "together" and the bottom in "forwards" order', async () => {
865
+ let A = createAsyncText('A');
866
+ let B = createAsyncText('B');
867
+ let D = createAsyncText('D');
868
+ let F = createAsyncText('F');
869
+
870
+ function createSyncText(text) {
871
+ return function() {
872
+ return <Text text={text} />;
873
+ };
874
+ }
875
+
876
+ let As = createSyncText('A');
877
+ let Bs = createSyncText('B');
878
+ let Cs = createSyncText('C');
879
+ let Ds = createSyncText('D');
880
+ let Es = createSyncText('E');
881
+ let Fs = createSyncText('F');
882
+
883
+ function Foo({items}) {
884
+ return (
885
+ <SuspenseList revealOrder="backwards">
886
+ {items.map(([key, Component]) => (
887
+ <Suspense key={key} fallback={<Text text={'Loading ' + key} />}>
888
+ <Component />
889
+ </Suspense>
890
+ ))}
891
+ </SuspenseList>
892
+ );
893
+ }
894
+
895
+ // The first pass doesn't suspend.
896
+ ReactNoop.render(
897
+ <Foo
898
+ items={[
899
+ ['A', As],
900
+ ['B', Bs],
901
+ ['C', Cs],
902
+ ['D', Ds],
903
+ ['E', Es],
904
+ ['F', Fs],
905
+ ]}
906
+ />,
907
+ );
908
+ expect(Scheduler).toFlushAndYield(['F', 'E', 'D', 'C', 'B', 'A']);
909
+ expect(ReactNoop).toMatchRenderedOutput(
910
+ <Fragment>
911
+ <span>A</span>
912
+ <span>B</span>
913
+ <span>C</span>
914
+ <span>D</span>
915
+ <span>E</span>
916
+ <span>F</span>
917
+ </Fragment>,
918
+ );
919
+
920
+ // Update items in the beginning, middle and end to start suspending.
921
+ ReactNoop.render(
922
+ <Foo
923
+ items={[['A', A], ['B', B], ['C', Cs], ['D', D], ['E', Es], ['F', F]]}
924
+ />,
925
+ );
926
+
927
+ expect(Scheduler).toFlushAndYield([
928
+ 'Suspend! [A]',
929
+ 'Loading A',
930
+ 'Suspend! [B]',
931
+ 'Loading B',
932
+ 'C',
933
+ 'Suspend! [D]',
934
+ 'Loading D',
935
+ 'E',
936
+ 'Suspend! [F]',
937
+ 'Loading F',
938
+ ]);
939
+
940
+ // This will suspend, since the boundaries are avoided. Give them
941
+ // time to display their loading states.
942
+ jest.advanceTimersByTime(500);
943
+
944
+ expect(ReactNoop).toMatchRenderedOutput(
945
+ <Fragment>
946
+ <span hidden={true}>A</span>
947
+ <span>Loading A</span>
948
+ <span hidden={true}>B</span>
949
+ <span>Loading B</span>
950
+ <span>C</span>
951
+ <span hidden={true}>D</span>
952
+ <span>Loading D</span>
953
+ <span>E</span>
954
+ <span hidden={true}>F</span>
955
+ <span>Loading F</span>
956
+ </Fragment>,
957
+ );
958
+
959
+ await F.resolve();
960
+
961
+ expect(Scheduler).toFlushAndYield(['F']);
962
+
963
+ // Even though we could show F, it is still in a fallback state because
964
+ // E is not yet resolved. We need to resolve everything in the head first.
965
+ expect(ReactNoop).toMatchRenderedOutput(
966
+ <Fragment>
967
+ <span hidden={true}>A</span>
968
+ <span>Loading A</span>
969
+ <span hidden={true}>B</span>
970
+ <span>Loading B</span>
971
+ <span>C</span>
972
+ <span hidden={true}>D</span>
973
+ <span>Loading D</span>
974
+ <span>E</span>
975
+ <span hidden={true}>F</span>
976
+ <span>Loading F</span>
977
+ </Fragment>,
978
+ );
979
+
980
+ await D.resolve();
981
+
982
+ expect(Scheduler).toFlushAndYield(['D', 'F', 'Suspend! [B]']);
983
+
984
+ // We can now resolve the full head.
985
+ expect(ReactNoop).toMatchRenderedOutput(
986
+ <Fragment>
987
+ <span hidden={true}>A</span>
988
+ <span>Loading A</span>
989
+ <span hidden={true}>B</span>
990
+ <span>Loading B</span>
991
+ <span>C</span>
992
+ <span>D</span>
993
+ <span>E</span>
994
+ <span>F</span>
995
+ </Fragment>,
996
+ );
997
+
998
+ await B.resolve();
999
+
1000
+ expect(Scheduler).toFlushAndYield(['B', 'Suspend! [A]']);
1001
+
1002
+ // In the tail we can resolve one-by-one.
1003
+ expect(ReactNoop).toMatchRenderedOutput(
1004
+ <Fragment>
1005
+ <span hidden={true}>A</span>
1006
+ <span>Loading A</span>
1007
+ <span>B</span>
1008
+ <span>C</span>
1009
+ <span>D</span>
1010
+ <span>E</span>
1011
+ <span>F</span>
1012
+ </Fragment>,
1013
+ );
1014
+
1015
+ await A.resolve();
1016
+
1017
+ expect(Scheduler).toFlushAndYield(['A']);
1018
+
1019
+ expect(ReactNoop).toMatchRenderedOutput(
1020
+ <Fragment>
1021
+ <span>A</span>
1022
+ <span>B</span>
1023
+ <span>C</span>
1024
+ <span>D</span>
1025
+ <span>E</span>
1026
+ <span>F</span>
1027
+ </Fragment>,
1028
+ );
1029
+ });
1030
});