Capture suspense boundaries with undefined fallbacks (#21854)
Ricky committed
Jul 12, 2021 at 14:50 UTC
c2c6ea1fdead7a33a33664675365b1575315276e
14 files changed
+452
-129
packages/react-dom/src/__tests__/ReactDOMServerPartialHydration-test.internal.js
+6
-6
@@ -714,7 +714,7 @@ describe('ReactDOMServerPartialHydration', () => {
714
expect(container.textContent).toBe('Hi');
715
});
716
717
- it('shows the fallback of the outer if fallback is missing', async () => {
717
+ it('treats missing fallback the same as if it was defined', async () => {
718
// This is the same exact test as above but with a nested Suspense without a fallback.
719
// This should be a noop.
720
let suspend = false;
@@ -759,7 +759,8 @@ describe('ReactDOMServerPartialHydration', () => {
759
Scheduler.unstable_flushAll();
760
jest.runAllTimers();
761
762
- expect(ref.current).toBe(null);
762
+ const span = container.getElementsByTagName('span')[0];
763
+ expect(ref.current).toBe(span);
764
765
// Render an update, but leave it still suspended.
766
root.render(<App text="Hi" className="hi" />);
@@ -768,9 +769,9 @@ describe('ReactDOMServerPartialHydration', () => {
769
Scheduler.unstable_flushAll();
770
jest.runAllTimers();
771
771
- expect(container.getElementsByTagName('span').length).toBe(0);
772
- expect(ref.current).toBe(null);
773
- expect(container.textContent).toBe('Loading...');
772
+ expect(container.getElementsByTagName('span').length).toBe(1);
773
+ expect(ref.current).toBe(span);
774
+ expect(container.textContent).toBe('');
775
776
// Unsuspending shows the content.
777
suspend = false;
@@ -780,7 +781,6 @@ describe('ReactDOMServerPartialHydration', () => {
781
Scheduler.unstable_flushAll();
782
jest.runAllTimers();
783
783
- const span = container.getElementsByTagName('span')[0];
784
expect(span.textContent).toBe('Hi');
785
expect(span.className).toBe('hi');
786
expect(ref.current).toBe(span);
packages/react-dom/src/__tests__/ReactServerRenderingHydration-test.js
+6
-4
@@ -471,10 +471,12 @@ describe('ReactDOMServerHydration', () => {
471
element,
472
);
473
474
- // Because this didn't have a fallback, it was hydrated as if it's
475
- // not a Suspense boundary.
476
- expect(ref.current).toBe(div);
477
- expect(element.innerHTML).toBe('<div>Hello World</div>');
474
+ // The content should've been client rendered.
475
+ expect(ref.current).not.toBe(div);
476
+ // Unfortunately, since we don't delete the tail at the root, a duplicate will remain.
477
+ expect(element.innerHTML).toBe(
478
+ '<div>Hello World</div><div>Hello World</div>',
479
+ );
480
});
481
482
// regression test for https://github.com/facebook/react/issues/17170
packages/react-dom/src/server/ReactPartialRenderer.js
-19
@@ -1123,25 +1123,6 @@ class ReactDOMServerRenderer {
1123
case REACT_SUSPENSE_TYPE: {
1124
if (enableSuspenseServerRenderer) {
1125
const fallback = ((nextChild: any): ReactElement).props.fallback;
1126
- if (fallback === undefined) {
1127
- // If there is no fallback, then this just behaves as a fragment.
1128
- const nextChildren = toArray(
1129
- ((nextChild: any): ReactElement).props.children,
1130
- );
1131
- const frame: Frame = {
1132
- type: null,
1133
- domNamespace: parentNamespace,
1134
- children: nextChildren,
1135
- childIndex: 0,
1136
- context: context,
1137
- footer: '',
1138
- };
1139
- if (__DEV__) {
1140
- ((frame: any): FrameDev).debugElementStack = [];
1141
- }
1142
- this.stack.push(frame);
1143
- return '';
1144
- }
1126
const fallbackChildren = toArray(fallback);
1127
const nextChildren = toArray(
1128
((nextChild: any): ReactElement).props.children,
packages/react-reconciler/src/ReactFiberBeginWork.new.js
+14
-22
@@ -1867,12 +1867,8 @@ function updateSuspenseComponent(current, workInProgress, renderLanes) {
1867
// This is a new mount or this boundary is already showing a fallback state.
1868
// Mark this subtree context as having at least one invisible parent that could
1869
// handle the fallback state.
1870
- // Boundaries without fallbacks or should be avoided are not considered since
1871
- // they cannot handle preferred fallback states.
1872
- if (
1873
- nextProps.fallback !== undefined &&
1874
- nextProps.unstable_avoidThisFallback !== true
1875
- ) {
1870
+ // Avoided boundaries are not considered since they cannot handle preferred fallback states.
1871
+ if (nextProps.unstable_avoidThisFallback !== true) {
1872
suspenseContext = addSubtreeSuspenseContext(
1873
suspenseContext,
1874
InvisibleParentSuspenseContext,
@@ -1910,22 +1906,18 @@ function updateSuspenseComponent(current, workInProgress, renderLanes) {
1906
if (current === null) {
1907
// Initial mount
1908
// If we're currently hydrating, try to hydrate this boundary.
1913
- // But only if this has a fallback.
1914
- if (nextProps.fallback !== undefined) {
1915
- tryToClaimNextHydratableInstance(workInProgress);
1916
- // This could've been a dehydrated suspense component.
1917
- if (enableSuspenseServerRenderer) {
1918
- const suspenseState: null | SuspenseState =
1919
- workInProgress.memoizedState;
1920
- if (suspenseState !== null) {
1921
- const dehydrated = suspenseState.dehydrated;
1922
- if (dehydrated !== null) {
1923
- return mountDehydratedSuspenseComponent(
1924
- workInProgress,
1925
- dehydrated,
1926
- renderLanes,
1927
- );
1928
- }
1909
+ tryToClaimNextHydratableInstance(workInProgress);
1910
+ // This could've been a dehydrated suspense component.
1911
+ if (enableSuspenseServerRenderer) {
1912
+ const suspenseState: null | SuspenseState = workInProgress.memoizedState;
1913
+ if (suspenseState !== null) {
1914
+ const dehydrated = suspenseState.dehydrated;
1915
+ if (dehydrated !== null) {
1916
+ return mountDehydratedSuspenseComponent(
1917
+ workInProgress,
1918
+ dehydrated,
1919
+ renderLanes,
1920
+ );
1921
}
1922
}
1923
}
packages/react-reconciler/src/ReactFiberBeginWork.old.js
+14
-22
@@ -1867,12 +1867,8 @@ function updateSuspenseComponent(current, workInProgress, renderLanes) {
1867
// This is a new mount or this boundary is already showing a fallback state.
1868
// Mark this subtree context as having at least one invisible parent that could
1869
// handle the fallback state.
1870
- // Boundaries without fallbacks or should be avoided are not considered since
1871
- // they cannot handle preferred fallback states.
1872
- if (
1873
- nextProps.fallback !== undefined &&
1874
- nextProps.unstable_avoidThisFallback !== true
1875
- ) {
1870
+ // Avoided boundaries are not considered since they cannot handle preferred fallback states.
1871
+ if (nextProps.unstable_avoidThisFallback !== true) {
1872
suspenseContext = addSubtreeSuspenseContext(
1873
suspenseContext,
1874
InvisibleParentSuspenseContext,
@@ -1910,22 +1906,18 @@ function updateSuspenseComponent(current, workInProgress, renderLanes) {
1906
if (current === null) {
1907
// Initial mount
1908
// If we're currently hydrating, try to hydrate this boundary.
1913
- // But only if this has a fallback.
1914
- if (nextProps.fallback !== undefined) {
1915
- tryToClaimNextHydratableInstance(workInProgress);
1916
- // This could've been a dehydrated suspense component.
1917
- if (enableSuspenseServerRenderer) {
1918
- const suspenseState: null | SuspenseState =
1919
- workInProgress.memoizedState;
1920
- if (suspenseState !== null) {
1921
- const dehydrated = suspenseState.dehydrated;
1922
- if (dehydrated !== null) {
1923
- return mountDehydratedSuspenseComponent(
1924
- workInProgress,
1925
- dehydrated,
1926
- renderLanes,
1927
- );
1928
- }
1909
+ tryToClaimNextHydratableInstance(workInProgress);
1910
+ // This could've been a dehydrated suspense component.
1911
+ if (enableSuspenseServerRenderer) {
1912
+ const suspenseState: null | SuspenseState = workInProgress.memoizedState;
1913
+ if (suspenseState !== null) {
1914
+ const dehydrated = suspenseState.dehydrated;
1915
+ if (dehydrated !== null) {
1916
+ return mountDehydratedSuspenseComponent(
1917
+ workInProgress,
1918
+ dehydrated,
1919
+ renderLanes,
1920
+ );
1921
}
1922
}
1923
}
packages/react-reconciler/src/ReactFiberCompleteWork.new.js
+1
-3
@@ -1045,9 +1045,7 @@ function completeWork(
1045
const nextDidTimeout = nextState !== null;
1046
let prevDidTimeout = false;
1047
if (current === null) {
1048
- if (workInProgress.memoizedProps.fallback !== undefined) {
1049
- popHydrationState(workInProgress);
1050
- }
1048
+ popHydrationState(workInProgress);
1049
} else {
1050
const prevState: null | SuspenseState = current.memoizedState;
1051
prevDidTimeout = prevState !== null;
packages/react-reconciler/src/ReactFiberCompleteWork.old.js
+1
-3
@@ -1045,9 +1045,7 @@ function completeWork(
1045
const nextDidTimeout = nextState !== null;
1046
let prevDidTimeout = false;
1047
if (current === null) {
1048
- if (workInProgress.memoizedProps.fallback !== undefined) {
1049
- popHydrationState(workInProgress);
1050
- }
1048
+ popHydrationState(workInProgress);
1049
} else {
1050
const prevState: null | SuspenseState = current.memoizedState;
1051
prevDidTimeout = prevState !== null;
packages/react-reconciler/src/ReactFiberSuspenseComponent.new.js
-4
@@ -77,10 +77,6 @@ export function shouldCaptureSuspense(
77
return false;
78
}
79
const props = workInProgress.memoizedProps;
80
- // In order to capture, the Suspense component must have a fallback prop.
81
- if (props.fallback === undefined) {
82
- return false;
83
- }
80
// Regular boundaries always capture.
81
if (props.unstable_avoidThisFallback !== true) {
82
return true;
packages/react-reconciler/src/ReactFiberSuspenseComponent.old.js
-4
@@ -77,10 +77,6 @@ export function shouldCaptureSuspense(
77
return false;
78
}
79
const props = workInProgress.memoizedProps;
80
- // In order to capture, the Suspense component must have a fallback prop.
81
- if (props.fallback === undefined) {
82
- return false;
83
- }
80
// Regular boundaries always capture.
81
if (props.unstable_avoidThisFallback !== true) {
82
return true;
packages/react-reconciler/src/__tests__/ReactHooks-test.internal.js
-2
@@ -19,8 +19,6 @@ let Scheduler;
19
let ReactDOMServer;
20
let act;
21
22
-// Additional tests can be found in ReactHooksWithNoopRenderer. Plan is to
23
-// gradually migrate those to this file.
22
describe('ReactHooks', () => {
23
beforeEach(() => {
24
jest.resetModules();
packages/react-reconciler/src/__tests__/ReactSuspense-test.internal.js
+4
-40
@@ -9,8 +9,6 @@ let act;
9
let TextResource;
10
let textResourceShouldFail;
11
12
-// Additional tests can be found in ReactSuspenseWithNoopRenderer. Plan is
13
-// to gradually migrate those to this file.
12
describe('ReactSuspense', () => {
13
beforeEach(() => {
14
jest.resetModules();
@@ -391,44 +389,10 @@ describe('ReactSuspense', () => {
389
expect(root).toMatchRenderedOutput('Hi');
390
});
391
394
- it('only captures if `fallback` is defined', () => {
395
- const root = ReactTestRenderer.create(
396
- <Suspense fallback={<Text text="Loading..." />}>
397
- <Suspense>
398
- <AsyncText text="Hi" ms={5000} />
399
- </Suspense>
400
- </Suspense>,
401
- {
402
- unstable_isConcurrent: true,
403
- },
404
- );
405
-
406
- expect(Scheduler).toFlushAndYield([
407
- 'Suspend! [Hi]',
408
- // The outer fallback should be rendered, because the inner one does not
409
- // have a `fallback` prop
410
- 'Loading...',
411
- ]);
412
- jest.advanceTimersByTime(1000);
413
- expect(Scheduler).toHaveYielded([]);
414
- expect(Scheduler).toFlushAndYield([]);
415
- expect(root).toMatchRenderedOutput('Loading...');
416
-
417
- jest.advanceTimersByTime(5000);
418
- expect(Scheduler).toHaveYielded(['Promise resolved [Hi]']);
419
- expect(Scheduler).toFlushAndYield(['Hi']);
420
- expect(root).toMatchRenderedOutput('Hi');
421
- });
422
-
423
- it('throws if tree suspends and none of the Suspense ancestors have a fallback', () => {
424
- ReactTestRenderer.create(
425
- <Suspense>
426
- <AsyncText text="Hi" ms={1000} />
427
- </Suspense>,
428
- {
429
- unstable_isConcurrent: true,
430
- },
431
- );
392
+ it('throws if tree suspends and none of the Suspense ancestors have a boundary', () => {
393
+ ReactTestRenderer.create(<AsyncText text="Hi" ms={1000} />, {
394
+ unstable_isConcurrent: true,
395
+ });
396
397
expect(Scheduler).toFlushAndThrow(
398
'AsyncText suspended while rendering, but no fallback UI was specified.',
packages/react-reconciler/src/__tests__/ReactSuspenseFallback-test.js
new
+223
@@ -0,0 +1,223 @@
1
+/**
2
+ * Copyright (c) Facebook, Inc. and its 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
+ * @flow
8
+ */
9
+let React;
10
+let ReactNoop;
11
+let Scheduler;
12
+let Suspense;
13
+let getCacheForType;
14
+let caches;
15
+let seededCache;
16
+
17
+describe('ReactSuspenseFallback', () => {
18
+ beforeEach(() => {
19
+ jest.resetModules();
20
+
21
+ React = require('react');
22
+ ReactNoop = require('react-noop-renderer');
23
+ Scheduler = require('scheduler');
24
+ Suspense = React.Suspense;
25
+ getCacheForType = React.unstable_getCacheForType;
26
+ caches = [];
27
+ seededCache = null;
28
+ });
29
+
30
+ function createTextCache() {
31
+ if (seededCache !== null) {
32
+ // Trick to seed a cache before it exists.
33
+ // TODO: Need a built-in API to seed data before the initial render (i.e.
34
+ // not a refresh because nothing has mounted yet).
35
+ const cache = seededCache;
36
+ seededCache = null;
37
+ return cache;
38
+ }
39
+
40
+ const data = new Map();
41
+ const version = caches.length + 1;
42
+ const cache = {
43
+ version,
44
+ data,
45
+ resolve(text) {
46
+ const record = data.get(text);
47
+ if (record === undefined) {
48
+ const newRecord = {
49
+ status: 'resolved',
50
+ value: text,
51
+ };
52
+ data.set(text, newRecord);
53
+ } else if (record.status === 'pending') {
54
+ const thenable = record.value;
55
+ record.status = 'resolved';
56
+ record.value = text;
57
+ thenable.pings.forEach(t => t());
58
+ }
59
+ },
60
+ reject(text, error) {
61
+ const record = data.get(text);
62
+ if (record === undefined) {
63
+ const newRecord = {
64
+ status: 'rejected',
65
+ value: error,
66
+ };
67
+ data.set(text, newRecord);
68
+ } else if (record.status === 'pending') {
69
+ const thenable = record.value;
70
+ record.status = 'rejected';
71
+ record.value = error;
72
+ thenable.pings.forEach(t => t());
73
+ }
74
+ },
75
+ };
76
+ caches.push(cache);
77
+ return cache;
78
+ }
79
+
80
+ function readText(text) {
81
+ const textCache = getCacheForType(createTextCache);
82
+ const record = textCache.data.get(text);
83
+ if (record !== undefined) {
84
+ switch (record.status) {
85
+ case 'pending':
86
+ Scheduler.unstable_yieldValue(`Suspend! [${text}]`);
87
+ throw record.value;
88
+ case 'rejected':
89
+ Scheduler.unstable_yieldValue(`Error! [${text}]`);
90
+ throw record.value;
91
+ case 'resolved':
92
+ return textCache.version;
93
+ }
94
+ } else {
95
+ Scheduler.unstable_yieldValue(`Suspend! [${text}]`);
96
+
97
+ const thenable = {
98
+ pings: [],
99
+ then(resolve) {
100
+ if (newRecord.status === 'pending') {
101
+ thenable.pings.push(resolve);
102
+ } else {
103
+ Promise.resolve().then(() => resolve(newRecord.value));
104
+ }
105
+ },
106
+ };
107
+
108
+ const newRecord = {
109
+ status: 'pending',
110
+ value: thenable,
111
+ };
112
+ textCache.data.set(text, newRecord);
113
+
114
+ throw thenable;
115
+ }
116
+ }
117
+
118
+ function Text({text}) {
119
+ Scheduler.unstable_yieldValue(text);
120
+ return <span prop={text} />;
121
+ }
122
+
123
+ function AsyncText({text, showVersion}) {
124
+ const version = readText(text);
125
+ const fullText = showVersion ? `${text} [v${version}]` : text;
126
+ Scheduler.unstable_yieldValue(fullText);
127
+ return <span prop={fullText} />;
128
+ }
129
+
130
+ function span(prop) {
131
+ return {type: 'span', children: [], prop, hidden: false};
132
+ }
133
+
134
+ // @gate enableCache
135
+ it('suspends and shows fallback', () => {
136
+ ReactNoop.render(
137
+ <Suspense fallback={<Text text="Loading..." />}>
138
+ <AsyncText text="A" ms={100} />
139
+ </Suspense>,
140
+ );
141
+
142
+ expect(Scheduler).toFlushAndYield(['Suspend! [A]', 'Loading...']);
143
+ expect(ReactNoop.getChildren()).toEqual([span('Loading...')]);
144
+ });
145
+
146
+ // @gate enableCache
147
+ it('suspends and shows null fallback', () => {
148
+ ReactNoop.render(
149
+ <Suspense fallback={null}>
150
+ <AsyncText text="A" ms={100} />
151
+ </Suspense>,
152
+ );
153
+
154
+ expect(Scheduler).toFlushAndYield([
155
+ 'Suspend! [A]',
156
+ // null
157
+ ]);
158
+ expect(ReactNoop.getChildren()).toEqual([]);
159
+ });
160
+
161
+ // @gate enableCache
162
+ it('suspends and shows undefined fallback', () => {
163
+ ReactNoop.render(
164
+ <Suspense>
165
+ <AsyncText text="A" ms={100} />
166
+ </Suspense>,
167
+ );
168
+
169
+ expect(Scheduler).toFlushAndYield([
170
+ 'Suspend! [A]',
171
+ // null
172
+ ]);
173
+ expect(ReactNoop.getChildren()).toEqual([]);
174
+ });
175
+
176
+ // @gate enableCache
177
+ it('suspends and shows inner fallback', () => {
178
+ ReactNoop.render(
179
+ <Suspense fallback={<Text text="Should not show..." />}>
180
+ <Suspense fallback={<Text text="Loading..." />}>
181
+ <AsyncText text="A" ms={100} />
182
+ </Suspense>
183
+ </Suspense>,
184
+ );
185
+
186
+ expect(Scheduler).toFlushAndYield(['Suspend! [A]', 'Loading...']);
187
+ expect(ReactNoop.getChildren()).toEqual([span('Loading...')]);
188
+ });
189
+
190
+ // @gate enableCache
191
+ it('suspends and shows inner undefined fallback', () => {
192
+ ReactNoop.render(
193
+ <Suspense fallback={<Text text="Should not show..." />}>
194
+ <Suspense>
195
+ <AsyncText text="A" ms={100} />
196
+ </Suspense>
197
+ </Suspense>,
198
+ );
199
+
200
+ expect(Scheduler).toFlushAndYield([
201
+ 'Suspend! [A]',
202
+ // null
203
+ ]);
204
+ expect(ReactNoop.getChildren()).toEqual([]);
205
+ });
206
+
207
+ // @gate enableCache
208
+ it('suspends and shows inner null fallback', () => {
209
+ ReactNoop.render(
210
+ <Suspense fallback={<Text text="Should not show..." />}>
211
+ <Suspense fallback={null}>
212
+ <AsyncText text="A" ms={100} />
213
+ </Suspense>
214
+ </Suspense>,
215
+ );
216
+
217
+ expect(Scheduler).toFlushAndYield([
218
+ 'Suspend! [A]',
219
+ // null
220
+ ]);
221
+ expect(ReactNoop.getChildren()).toEqual([]);
222
+ });
223
+});
packages/react-reconciler/src/__tests__/ReactSuspenseList-test.js
+83
@@ -771,6 +771,89 @@ describe('ReactSuspenseList', () => {
771
);
772
});
773
774
+ it('boundaries without fallbacks can be coordinate with SuspenseList', async () => {
775
+ const A = createAsyncText('A');
776
+ const B = createAsyncText('B');
777
+ const C = createAsyncText('C');
778
+
779
+ function Foo({showMore}) {
780
+ return (
781
+ <Suspense fallback={<Text text="Loading" />}>
782
+ <SuspenseList revealOrder="together">
783
+ <Suspense>
784
+ <A />
785
+ </Suspense>
786
+ {showMore ? (
787
+ <>
788
+ <Suspense>
789
+ <B />
790
+ </Suspense>
791
+ <Suspense>
792
+ <C />
793
+ </Suspense>
794
+ </>
795
+ ) : null}
796
+ </SuspenseList>
797
+ </Suspense>
798
+ );
799
+ }
800
+
801
+ ReactNoop.render(<Foo />);
802
+
803
+ expect(Scheduler).toFlushAndYield([
804
+ 'Suspend! [A]',
805
+ // null
806
+ ]);
807
+
808
+ expect(ReactNoop).toMatchRenderedOutput(null);
809
+
810
+ await A.resolve();
811
+
812
+ expect(Scheduler).toFlushAndYield(['A']);
813
+
814
+ expect(ReactNoop).toMatchRenderedOutput(<span>A</span>);
815
+
816
+ // Let's do an update that should consult the avoided boundaries.
817
+ ReactNoop.render(<Foo showMore={true} />);
818
+
819
+ expect(Scheduler).toFlushAndYield([
820
+ 'A',
821
+ 'Suspend! [B]',
822
+ // null
823
+ 'Suspend! [C]',
824
+ // null
825
+ 'A',
826
+ // null
827
+ // null
828
+ ]);
829
+
830
+ // This will suspend, since the boundaries are avoided. Give them
831
+ // time to display their loading states.
832
+ jest.advanceTimersByTime(500);
833
+
834
+ // A is already showing content so it doesn't turn into a fallback.
835
+ expect(ReactNoop).toMatchRenderedOutput(<span>A</span>);
836
+
837
+ await B.resolve();
838
+
839
+ expect(Scheduler).toFlushAndYield(['B', 'Suspend! [C]']);
840
+
841
+ // Even though we could now show B, we're still waiting on C.
842
+ expect(ReactNoop).toMatchRenderedOutput(<span>A</span>);
843
+
844
+ await C.resolve();
845
+
846
+ expect(Scheduler).toFlushAndYield(['B', 'C']);
847
+
848
+ expect(ReactNoop).toMatchRenderedOutput(
849
+ <>
850
+ <span>A</span>
851
+ <span>B</span>
852
+ <span>C</span>
853
+ </>,
854
+ );
855
+ });
856
+
857
it('displays each items in "forwards" order', async () => {
858
const A = createAsyncText('A');
859
const B = createAsyncText('B');
packages/react-reconciler/src/__tests__/ReactSuspenseWithNoopRenderer-test.js
+100
@@ -2303,6 +2303,55 @@ describe('ReactSuspenseWithNoopRenderer', () => {
2303
expect(ReactNoop.getChildren()).toEqual([span('A'), span('C'), span('B')]);
2304
});
2305
2306
+ // @gate enableCache
2307
+ it('does not show the parent fallback if the inner fallback is not defined', async () => {
2308
+ function Foo({showC}) {
2309
+ Scheduler.unstable_yieldValue('Foo');
2310
+ return (
2311
+ <Suspense fallback={<Text text="Initial load..." />}>
2312
+ <Suspense>
2313
+ <AsyncText text="A" />
2314
+ {showC ? <AsyncText text="C" /> : null}
2315
+ </Suspense>
2316
+ <Text text="B" />
2317
+ </Suspense>
2318
+ );
2319
+ }
2320
+
2321
+ ReactNoop.render(<Foo />);
2322
+ expect(Scheduler).toFlushAndYield([
2323
+ 'Foo',
2324
+ 'Suspend! [A]',
2325
+ 'B',
2326
+ // null
2327
+ ]);
2328
+ expect(ReactNoop.getChildren()).toEqual([span('B')]);
2329
+
2330
+ // Eventually we resolve and show the data.
2331
+ await resolveText('A');
2332
+ expect(Scheduler).toFlushAndYield(['A']);
2333
+ expect(ReactNoop.getChildren()).toEqual([span('A'), span('B')]);
2334
+
2335
+ // Update to show C
2336
+ ReactNoop.render(<Foo showC={true} />);
2337
+ expect(Scheduler).toFlushAndYield([
2338
+ 'Foo',
2339
+ 'A',
2340
+ 'Suspend! [C]',
2341
+ // null
2342
+ 'B',
2343
+ ]);
2344
+ // Flush to skip suspended time.
2345
+ Scheduler.unstable_advanceTime(600);
2346
+ await advanceTimers(600);
2347
+ expect(ReactNoop.getChildren()).toEqual([hiddenSpan('A'), span('B')]);
2348
+
2349
+ // Later we load the data.
2350
+ await resolveText('C');
2351
+ expect(Scheduler).toFlushAndYield(['A', 'C']);
2352
+ expect(ReactNoop.getChildren()).toEqual([span('A'), span('C'), span('B')]);
2353
+ });
2354
+
2355
// @gate enableCache
2356
it('favors showing the inner fallback for nested top level avoided fallback', async () => {
2357
function Foo({showB}) {
@@ -2393,6 +2442,57 @@ describe('ReactSuspenseWithNoopRenderer', () => {
2442
}
2443
});
2444
2445
+ // @gate enableCache
2446
+ it('keeps showing an undefined fallback if it is already showing', async () => {
2447
+ function Foo({showB}) {
2448
+ Scheduler.unstable_yieldValue('Foo');
2449
+ return (
2450
+ <Suspense fallback={<Text text="Initial load..." />}>
2451
+ <Suspense fallback={undefined}>
2452
+ <Text text="A" />
2453
+ {showB ? (
2454
+ <Suspense fallback={undefined}>
2455
+ <AsyncText text="B" />
2456
+ </Suspense>
2457
+ ) : null}
2458
+ </Suspense>
2459
+ </Suspense>
2460
+ );
2461
+ }
2462
+
2463
+ ReactNoop.render(<Foo />);
2464
+ expect(Scheduler).toFlushAndYield(['Foo', 'A']);
2465
+ expect(ReactNoop.getChildren()).toEqual([span('A')]);
2466
+
2467
+ if (gate(flags => flags.enableSyncDefaultUpdates)) {
2468
+ React.startTransition(() => {
2469
+ ReactNoop.render(<Foo showB={true} />);
2470
+ });
2471
+ } else {
2472
+ ReactNoop.render(<Foo showB={true} />);
2473
+ }
2474
+
2475
+ expect(Scheduler).toFlushAndYield([
2476
+ 'Foo',
2477
+ 'A',
2478
+ 'Suspend! [B]',
2479
+ // Null
2480
+ ]);
2481
+ // Still suspended.
2482
+ expect(ReactNoop.getChildren()).toEqual([span('A')]);
2483
+
2484
+ // Flush to skip suspended time.
2485
+ Scheduler.unstable_advanceTime(600);
2486
+ await advanceTimers(600);
2487
+
2488
+ if (gate(flags => flags.enableSyncDefaultUpdates)) {
2489
+ // Transitions never fall back.
2490
+ expect(ReactNoop.getChildren()).toEqual([span('A')]);
2491
+ } else {
2492
+ expect(ReactNoop.getChildren()).toEqual([span('A')]);
2493
+ }
2494
+ });
2495
+
2496
// @gate enableCache
2497
it('commits a suspended idle pri render within a reasonable time', async () => {
2498
function Foo({renderContent}) {