Suspense component does not capture if `fallback` is not defined (#13879)
* Suspense component does not capture if `fallback` is not defined A missing fallback prop means the exception should propagate to the next parent (like a rethrow). That way a Suspense component can specify other props like maxDuration without needing to provide a fallback, too. Closes #13864 * Change order of checks
Andrew Clark committed
Oct 18, 2018 at 16:07 UTC
8ced545e3df95afab6fa35bc29f9320bafbcef26
4 files changed
+87
-29
packages/react-reconciler/src/ReactFiberUnwindWork.js
+3
-3
@@ -205,9 +205,9 @@ function throwException(
205
workInProgress = returnFiber;
206
do {
207
if (workInProgress.tag === SuspenseComponent) {
208
- const state = workInProgress.memoizedState;
209
- const didTimeout = state !== null && state.didTimeout;
210
- if (!didTimeout) {
208
+ const fallback = workInProgress.memoizedProps.fallback;
209
+ const didTimeout = workInProgress.memoizedState;
210
+ if (!didTimeout && workInProgress.memoizedProps.fallback !== undefined) {
211
// Found the nearest boundary.
212
213
// If the boundary is not in concurrent mode, we should not suspend, and
packages/react-reconciler/src/__tests__/ReactPure-test.internal.js
+9
-9
@@ -57,11 +57,11 @@ describe('pure', () => {
57
Counter = pure(Counter);
58
59
ReactNoop.render(
60
- <Suspense>
60
+ <Suspense fallback={<Text text="Loading..." />}>
61
<Counter count={0} />
62
</Suspense>,
63
);
64
- expect(ReactNoop.flush()).toEqual([]);
64
+ expect(ReactNoop.flush()).toEqual(['Loading...']);
65
await Promise.resolve();
66
expect(ReactNoop.flush()).toEqual([0]);
67
expect(ReactNoop.getChildren()).toEqual([span(0)]);
@@ -107,7 +107,7 @@ describe('pure', () => {
107
state = {count: 0};
108
render() {
109
return (
110
- <Suspense>
110
+ <Suspense fallback={<Text text="Loading..." />}>
111
<CountContext.Provider value={this.state.count}>
112
<Counter label="Count" />
113
</CountContext.Provider>
@@ -118,7 +118,7 @@ describe('pure', () => {
118
119
const parent = React.createRef(null);
120
ReactNoop.render(<Parent ref={parent} />);
121
- expect(ReactNoop.flush()).toEqual([]);
121
+ expect(ReactNoop.flush()).toEqual(['Loading...']);
122
await Promise.resolve();
123
expect(ReactNoop.flush()).toEqual(['Count: 0']);
124
expect(ReactNoop.getChildren()).toEqual([span('Count: 0')]);
@@ -148,11 +148,11 @@ describe('pure', () => {
148
});
149
150
ReactNoop.render(
151
- <Suspense>
151
+ <Suspense fallback={<Text text="Loading..." />}>
152
<Counter count={0} />
153
</Suspense>,
154
);
155
- expect(ReactNoop.flush()).toEqual([]);
155
+ expect(ReactNoop.flush()).toEqual(['Loading...']);
156
await Promise.resolve();
157
expect(ReactNoop.flush()).toEqual([0]);
158
expect(ReactNoop.getChildren()).toEqual([span(0)]);
@@ -204,7 +204,7 @@ describe('pure', () => {
204
const divRef = React.createRef();
205
206
ReactNoop.render(
207
- <Suspense>
207
+ <Suspense fallback={<Text text="Loading..." />}>
208
<Transparent ref={divRef} />
209
</Suspense>,
210
);
@@ -224,11 +224,11 @@ describe('pure', () => {
224
const divRef2 = React.createRef();
225
226
ReactNoop.render(
227
- <Suspense>
227
+ <Suspense fallback={<Text text="Loading..." />}>
228
<Transparent ref={divRef} />
229
</Suspense>,
230
);
231
- expect(ReactNoop.flush()).toEqual([]);
231
+ expect(ReactNoop.flush()).toEqual(['Loading...']);
232
await Promise.resolve();
233
expect(ReactNoop.flush()).toEqual(['Text']);
234
expect(divRef.current.type).toBe('div');
packages/react-reconciler/src/__tests__/ReactSuspense-test.internal.js
+47
-1
@@ -106,7 +106,7 @@ describe('ReactSuspense', () => {
106
function Foo() {
107
ReactTestRenderer.unstable_yield('Foo');
108
return (
109
- <Suspense>
109
+ <Suspense fallback={<Text text="Loading..." />}>
110
<Bar>
111
<AsyncText text="A" ms={100} />
112
<Text text="B" />
@@ -126,6 +126,7 @@ describe('ReactSuspense', () => {
126
'Suspend! [A]',
127
// But we keep rendering the siblings
128
'B',
129
+ 'Loading...',
130
]);
131
expect(root).toMatchRenderedOutput(null);
132
@@ -271,4 +272,49 @@ describe('ReactSuspense', () => {
272
expect(ReactTestRenderer).toHaveYielded(['Hi', 'Did mount: Hi']);
273
expect(root).toMatchRenderedOutput('Hi');
274
});
275
+
276
+ it('only captures if `fallback` is defined', () => {
277
+ const root = ReactTestRenderer.create(
278
+ <Suspense fallback={<Text text="Loading..." />}>
279
+ <Suspense maxDuration={100}>
280
+ <AsyncText text="Hi" ms={5000} />
281
+ </Suspense>
282
+ </Suspense>,
283
+ {
284
+ unstable_isConcurrent: true,
285
+ },
286
+ );
287
+
288
+ expect(root).toFlushAndYield([
289
+ 'Suspend! [Hi]',
290
+ // The outer fallback should be rendered, because the inner one does not
291
+ // have a `fallback` prop
292
+ 'Loading...',
293
+ ]);
294
+ jest.advanceTimersByTime(1000);
295
+ expect(ReactTestRenderer).toHaveYielded([]);
296
+ expect(root).toFlushAndYield([]);
297
+ expect(root).toMatchRenderedOutput('Loading...');
298
+
299
+ jest.advanceTimersByTime(5000);
300
+ expect(ReactTestRenderer).toHaveYielded(['Promise resolved [Hi]']);
301
+ expect(root).toFlushAndYield(['Hi']);
302
+ expect(root).toMatchRenderedOutput('Hi');
303
+ });
304
+
305
+ it('throws if tree suspends and none of the Suspense ancestors have a fallback', () => {
306
+ const root = ReactTestRenderer.create(
307
+ <Suspense>
308
+ <AsyncText text="Hi" ms={1000} />
309
+ </Suspense>,
310
+ {
311
+ unstable_isConcurrent: true,
312
+ },
313
+ );
314
+
315
+ expect(root).toFlushAndThrow(
316
+ 'An update was suspended, but no placeholder UI was provided.',
317
+ );
318
+ expect(ReactTestRenderer).toHaveYielded(['Suspend! [Hi]', 'Suspend! [Hi]']);
319
+ });
320
});
packages/react-reconciler/src/__tests__/ReactSuspenseWithNoopRenderer-test.internal.js
+28
-16
@@ -104,7 +104,7 @@ describe('ReactSuspenseWithNoopRenderer', () => {
104
function Foo() {
105
ReactNoop.yield('Foo');
106
return (
107
- <Suspense>
107
+ <Suspense fallback={<Text text="Loading..." />}>
108
<Bar>
109
<AsyncText text="A" ms={100} />
110
<Text text="B" />
@@ -121,6 +121,7 @@ describe('ReactSuspenseWithNoopRenderer', () => {
121
'Suspend! [A]',
122
// But we keep rendering the siblings
123
'B',
124
+ 'Loading...',
125
]);
126
expect(ReactNoop.getChildren()).toEqual([]);
127
@@ -193,7 +194,7 @@ describe('ReactSuspenseWithNoopRenderer', () => {
194
195
it('continues rendering siblings after suspending', async () => {
196
ReactNoop.render(
196
- <Suspense>
197
+ <Suspense fallback={<Text text="Loading..." />}>
198
<Text text="A" />
199
<AsyncText text="B" />
200
<Text text="C" />
@@ -201,7 +202,13 @@ describe('ReactSuspenseWithNoopRenderer', () => {
202
</Suspense>,
203
);
204
// B suspends. Continue rendering the remaining siblings.
204
- expect(ReactNoop.flush()).toEqual(['A', 'Suspend! [B]', 'C', 'D']);
205
+ expect(ReactNoop.flush()).toEqual([
206
+ 'A',
207
+ 'Suspend! [B]',
208
+ 'C',
209
+ 'D',
210
+ 'Loading...',
211
+ ]);
212
// Did not commit yet.
213
expect(ReactNoop.getChildren()).toEqual([]);
214
@@ -243,7 +250,7 @@ describe('ReactSuspenseWithNoopRenderer', () => {
250
const errorBoundary = React.createRef();
251
function App() {
252
return (
246
- <Suspense>
253
+ <Suspense fallback={<Text text="Loading..." />}>
254
<ErrorBoundary ref={errorBoundary}>
255
<AsyncText text="Result" ms={1000} />
256
</ErrorBoundary>
@@ -252,7 +259,7 @@ describe('ReactSuspenseWithNoopRenderer', () => {
259
}
260
261
ReactNoop.render(<App />);
255
- expect(ReactNoop.flush()).toEqual(['Suspend! [Result]']);
262
+ expect(ReactNoop.flush()).toEqual(['Suspend! [Result]', 'Loading...']);
263
expect(ReactNoop.getChildren()).toEqual([]);
264
265
textResourceShouldFail = true;
@@ -278,7 +285,7 @@ describe('ReactSuspenseWithNoopRenderer', () => {
285
errorBoundary.current.reset();
286
cache.invalidate();
287
281
- expect(ReactNoop.flush()).toEqual(['Suspend! [Result]']);
288
+ expect(ReactNoop.flush()).toEqual(['Suspend! [Result]', 'Loading...']);
289
ReactNoop.expire(1000);
290
await advanceTimers(1000);
291
expect(ReactNoop.flush()).toEqual(['Promise resolved [Result]', 'Result']);
@@ -356,7 +363,7 @@ describe('ReactSuspenseWithNoopRenderer', () => {
363
it('can update at a higher priority while in a suspended state', async () => {
364
function App(props) {
365
return (
359
- <Suspense>
366
+ <Suspense fallback={<Text text="Loading..." />}>
367
<Text text={props.highPri} />
368
<AsyncText text={props.lowPri} />
369
</Suspense>
@@ -376,6 +383,7 @@ describe('ReactSuspenseWithNoopRenderer', () => {
383
'A',
384
// Suspends
385
'Suspend! [2]',
386
+ 'Loading...',
387
]);
388
389
// While we're still waiting for the low-pri update to complete, update the
@@ -395,7 +403,7 @@ describe('ReactSuspenseWithNoopRenderer', () => {
403
it('keeps working on lower priority work after being pinged', async () => {
404
function App(props) {
405
return (
398
- <Suspense>
406
+ <Suspense fallback={<Text text="Loading..." />}>
407
<AsyncText text="A" />
408
{props.showB && <Text text="B" />}
409
</Suspense>
@@ -403,13 +411,13 @@ describe('ReactSuspenseWithNoopRenderer', () => {
411
}
412
413
ReactNoop.render(<App showB={false} />);
406
- expect(ReactNoop.flush()).toEqual(['Suspend! [A]']);
414
+ expect(ReactNoop.flush()).toEqual(['Suspend! [A]', 'Loading...']);
415
expect(ReactNoop.getChildren()).toEqual([]);
416
417
// Advance React's virtual time by enough to fall into a new async bucket.
418
ReactNoop.expire(1200);
419
ReactNoop.render(<App showB={true} />);
412
- expect(ReactNoop.flush()).toEqual(['Suspend! [A]', 'B']);
420
+ expect(ReactNoop.flush()).toEqual(['Suspend! [A]', 'B', 'Loading...']);
421
expect(ReactNoop.getChildren()).toEqual([]);
422
423
await advanceTimers(0);
@@ -676,13 +684,17 @@ describe('ReactSuspenseWithNoopRenderer', () => {
684
685
it('a Suspense component correctly handles more than one suspended child', async () => {
686
ReactNoop.render(
679
- <Suspense maxDuration={0}>
687
+ <Suspense maxDuration={0} fallback={<Text text="Loading..." />}>
688
<AsyncText text="A" ms={100} />
689
<AsyncText text="B" ms={100} />
690
</Suspense>,
691
);
684
- expect(ReactNoop.expire(10000)).toEqual(['Suspend! [A]', 'Suspend! [B]']);
685
- expect(ReactNoop.getChildren()).toEqual([]);
692
+ expect(ReactNoop.expire(10000)).toEqual([
693
+ 'Suspend! [A]',
694
+ 'Suspend! [B]',
695
+ 'Loading...',
696
+ ]);
697
+ expect(ReactNoop.getChildren()).toEqual([span('Loading...')]);
698
699
await advanceTimers(100);
700
@@ -722,7 +734,7 @@ describe('ReactSuspenseWithNoopRenderer', () => {
734
it('starts working on an update even if its priority falls between two suspended levels', async () => {
735
function App(props) {
736
return (
725
- <Suspense maxDuration={10000}>
737
+ <Suspense fallback={<Text text="Loading..." />} maxDuration={10000}>
738
{props.text === 'C' ? (
739
<Text text="C" />
740
) : (
@@ -735,7 +747,7 @@ describe('ReactSuspenseWithNoopRenderer', () => {
747
// Schedule an update
748
ReactNoop.render(<App text="A" />);
749
// The update should suspend.
738
- expect(ReactNoop.flush()).toEqual(['Suspend! [A]']);
750
+ expect(ReactNoop.flush()).toEqual(['Suspend! [A]', 'Loading...']);
751
expect(ReactNoop.getChildren()).toEqual([]);
752
753
// Advance time until right before it expires. This number may need to
@@ -748,7 +760,7 @@ describe('ReactSuspenseWithNoopRenderer', () => {
760
// Schedule another low priority update.
761
ReactNoop.render(<App text="B" />);
762
// This update should also suspend.
751
- expect(ReactNoop.flush()).toEqual(['Suspend! [B]']);
763
+ expect(ReactNoop.flush()).toEqual(['Suspend! [B]', 'Loading...']);
764
expect(ReactNoop.getChildren()).toEqual([]);
765
766
// Schedule a high priority update. Its expiration time will fall between