1
+let React;
2
+let ReactFeatureFlags;
3
+let Fragment;
4
+let ReactNoop;
5
+let SimpleCacheProvider;
6
+let Timeout;
7
+
8
+let cache;
9
+let TextResource;
10
+let textResourceShouldFail;
11
+
12
+describe('ReactSuspense', () => {
13
+ beforeEach(() => {
14
+ jest.resetModules();
15
+ ReactFeatureFlags = require('shared/ReactFeatureFlags');
16
+ ReactFeatureFlags.debugRenderPhaseSideEffectsForStrictMode = false;
17
+ ReactFeatureFlags.enableSuspense = true;
18
+ React = require('react');
19
+ Fragment = React.Fragment;
20
+ ReactNoop = require('react-noop-renderer');
21
+ SimpleCacheProvider = require('simple-cache-provider');
22
+ Timeout = React.Timeout;
23
+
24
+ function invalidateCache() {
25
+ cache = SimpleCacheProvider.createCache(invalidateCache);
26
+ }
27
+ invalidateCache();
28
+ TextResource = SimpleCacheProvider.createResource(([text, ms = 0]) => {
29
+ return new Promise((resolve, reject) =>
30
+ setTimeout(() => {
31
+ if (textResourceShouldFail) {
32
+ ReactNoop.yield(`Promise rejected [${text}]`);
33
+ reject(new Error('Failed to load: ' + text));
34
+ } else {
35
+ ReactNoop.yield(`Promise resolved [${text}]`);
36
+ resolve(text);
37
+ }
38
+ }, ms),
39
+ );
40
+ }, ([text, ms]) => text);
41
+ textResourceShouldFail = false;
42
+ });
43
+
44
+ function div(...children) {
45
+ children = children.map(c => (typeof c === 'string' ? {text: c} : c));
46
+ return {type: 'div', children, prop: undefined};
47
+ }
48
+
49
+ function span(prop) {
50
+ return {type: 'span', children: [], prop};
51
+ }
52
+
53
+ function advanceTimers(ms) {
54
+ // Note: This advances Jest's virtual time but not React's. Use
55
+ // ReactNoop.expire for that.
56
+ if (typeof ms !== 'number') {
57
+ throw new Error('Must specify ms');
58
+ }
59
+ jest.advanceTimersByTime(ms);
60
+ // Wait until the end of the current tick
61
+ return new Promise(resolve => {
62
+ setImmediate(resolve);
63
+ });
64
+ }
65
+
66
+ function Text(props) {
67
+ ReactNoop.yield(props.text);
68
+ return <span prop={props.text} />;
69
+ }
70
+
71
+ function AsyncText(props) {
72
+ const text = props.text;
73
+ try {
74
+ TextResource.read(cache, [props.text, props.ms]);
75
+ ReactNoop.yield(text);
76
+ return <span prop={text} />;
77
+ } catch (promise) {
78
+ if (typeof promise.then === 'function') {
79
+ ReactNoop.yield(`Suspend! [${text}]`);
80
+ } else {
81
+ ReactNoop.yield(`Error! [${text}]`);
82
+ }
83
+ throw promise;
84
+ }
85
+ }
86
+
87
+ function Fallback(props) {
88
+ return (
89
+ <Timeout ms={props.timeout}>
90
+ {didExpire => (didExpire ? props.placeholder : props.children)}
91
+ </Timeout>
92
+ );
93
+ }
94
+ it('suspends rendering and continues later', async () => {
95
+ function Bar(props) {
96
+ ReactNoop.yield('Bar');
97
+ return props.children;
98
+ }
99
+
100
+ function Foo() {
101
+ ReactNoop.yield('Foo');
102
+ return (
103
+ <Fallback>
104
+ <Bar>
105
+ <AsyncText text="A" ms={100} />
106
+ <Text text="B" />
107
+ </Bar>
108
+ </Fallback>
109
+ );
110
+ }
111
+
112
+ ReactNoop.render(<Foo />);
113
+ expect(ReactNoop.flush()).toEqual([
114
+ 'Foo',
115
+ 'Bar',
116
+ // A suspends
117
+ 'Suspend! [A]',
118
+ // But we keep rendering the siblings
119
+ 'B',
120
+ ]);
121
+ expect(ReactNoop.getChildren()).toEqual([]);
122
+
123
+ // Flush some of the time
124
+ await advanceTimers(50);
125
+ // Still nothing...
126
+ expect(ReactNoop.flush()).toEqual([]);
127
+ expect(ReactNoop.getChildren()).toEqual([]);
128
+
129
+ // Flush the promise completely
130
+ await advanceTimers(50);
131
+ // Renders successfully
132
+ expect(ReactNoop.flush()).toEqual([
133
+ 'Promise resolved [A]',
134
+ 'Foo',
135
+ 'Bar',
136
+ 'A',
137
+ 'B',
138
+ ]);
139
+ expect(ReactNoop.getChildren()).toEqual([span('A'), span('B')]);
140
+ });
141
+
142
+ it('continues rendering siblings after suspending', async () => {
143
+ ReactNoop.render(
144
+ <Fallback>
145
+ <Text text="A" />
146
+ <AsyncText text="B" />
147
+ <Text text="C" />
148
+ <Text text="D" />
149
+ </Fallback>,
150
+ );
151
+ // B suspends. Continue rendering the remaining siblings.
152
+ expect(ReactNoop.flush()).toEqual(['A', 'Suspend! [B]', 'C', 'D']);
153
+ // Did not commit yet.
154
+ expect(ReactNoop.getChildren()).toEqual([]);
155
+
156
+ // Wait for data to resolve
157
+ await advanceTimers(100);
158
+ // Renders successfully
159
+ expect(ReactNoop.flush()).toEqual([
160
+ 'Promise resolved [B]',
161
+ 'A',
162
+ 'B',
163
+ 'C',
164
+ 'D',
165
+ ]);
166
+ expect(ReactNoop.getChildren()).toEqual([
167
+ span('A'),
168
+ span('B'),
169
+ span('C'),
170
+ span('D'),
171
+ ]);
172
+ });
173
+
174
+ it('retries on error', async () => {
175
+ class ErrorBoundary extends React.Component {
176
+ state = {error: null};
177
+ componentDidCatch(error) {
178
+ this.setState({error});
179
+ }
180
+ reset() {
181
+ this.setState({error: null});
182
+ }
183
+ render() {
184
+ if (this.state.error !== null) {
185
+ return <Text text={'Caught error: ' + this.state.error.message} />;
186
+ }
187
+ return this.props.children;
188
+ }
189
+ }
190
+
191
+ const errorBoundary = React.createRef();
192
+ function App() {
193
+ return (
194
+ <Fallback>
195
+ <ErrorBoundary ref={errorBoundary}>
196
+ <AsyncText text="Result" ms={1000} />
197
+ </ErrorBoundary>
198
+ </Fallback>
199
+ );
200
+ }
201
+
202
+ ReactNoop.render(<App />);
203
+ expect(ReactNoop.flush()).toEqual(['Suspend! [Result]']);
204
+ expect(ReactNoop.getChildren()).toEqual([]);
205
+
206
+ textResourceShouldFail = true;
207
+ ReactNoop.expire(1000);
208
+ await advanceTimers(1000);
209
+ textResourceShouldFail = false;
210
+
211
+ expect(ReactNoop.flush()).toEqual([
212
+ 'Promise rejected [Result]',
213
+ 'Error! [Result]',
214
+ 'Caught error: Failed to load: Result',
215
+ ]);
216
+ expect(ReactNoop.getChildren()).toEqual([
217
+ span('Caught error: Failed to load: Result'),
218
+ ]);
219
+
220
+ // Reset the error boundary and cache, and try again.
221
+ errorBoundary.current.reset();
222
+ cache.invalidate();
223
+
224
+ expect(ReactNoop.flush()).toEqual(['Suspend! [Result]']);
225
+ ReactNoop.expire(1000);
226
+ await advanceTimers(1000);
227
+ expect(ReactNoop.flush()).toEqual(['Promise resolved [Result]', 'Result']);
228
+ expect(ReactNoop.getChildren()).toEqual([span('Result')]);
229
+ });
230
+
231
+ it('retries on error after falling back to a placeholder', async () => {
232
+ class ErrorBoundary extends React.Component {
233
+ state = {error: null};
234
+ componentDidCatch(error) {
235
+ this.setState({error});
236
+ }
237
+ reset() {
238
+ this.setState({error: null});
239
+ }
240
+ render() {
241
+ if (this.state.error !== null) {
242
+ return <Text text={'Caught error: ' + this.state.error.message} />;
243
+ }
244
+ return this.props.children;
245
+ }
246
+ }
247
+
248
+ const errorBoundary = React.createRef();
249
+ function App() {
250
+ return (
251
+ <Fallback timeout={1000} placeholder={<Text text="Loading..." />}>
252
+ <ErrorBoundary ref={errorBoundary}>
253
+ <AsyncText text="Result" ms={3000} />
254
+ </ErrorBoundary>
255
+ </Fallback>
256
+ );
257
+ }
258
+
259
+ ReactNoop.render(<App />);
260
+ expect(ReactNoop.flush()).toEqual(['Suspend! [Result]']);
261
+ expect(ReactNoop.getChildren()).toEqual([]);
262
+
263
+ ReactNoop.expire(2000);
264
+ await advanceTimers(2000);
265
+ expect(ReactNoop.flush()).toEqual(['Suspend! [Result]', 'Loading...']);
266
+ expect(ReactNoop.getChildren()).toEqual([span('Loading...')]);
267
+
268
+ textResourceShouldFail = true;
269
+ ReactNoop.expire(1000);
270
+ await advanceTimers(1000);
271
+ textResourceShouldFail = false;
272
+
273
+ expect(ReactNoop.flush()).toEqual([
274
+ 'Promise rejected [Result]',
275
+ 'Error! [Result]',
276
+ 'Caught error: Failed to load: Result',
277
+ ]);
278
+ expect(ReactNoop.getChildren()).toEqual([
279
+ span('Caught error: Failed to load: Result'),
280
+ ]);
281
+
282
+ // Reset the error boundary and cache, and try again.
283
+ errorBoundary.current.reset();
284
+ cache.invalidate();
285
+
286
+ expect(ReactNoop.flush()).toEqual(['Suspend! [Result]']);
287
+ ReactNoop.expire(3000);
288
+ await advanceTimers(3000);
289
+ expect(ReactNoop.flush()).toEqual(['Promise resolved [Result]', 'Result']);
290
+ expect(ReactNoop.getChildren()).toEqual([span('Result')]);
291
+ });
292
+
293
+ it('can update at a higher priority while in a suspended state', async () => {
294
+ function App(props) {
295
+ return (
296
+ <Fallback>
297
+ <Text text={props.highPri} />
298
+ <AsyncText text={props.lowPri} />
299
+ </Fallback>
300
+ );
301
+ }
302
+
303
+ // Initial mount
304
+ ReactNoop.render(<App highPri="A" lowPri="1" />);
305
+ ReactNoop.flush();
306
+ await advanceTimers(0);
307
+ ReactNoop.flush();
308
+ expect(ReactNoop.getChildren()).toEqual([span('A'), span('1')]);
309
+
310
+ // Update the low-pri text
311
+ ReactNoop.render(<App highPri="A" lowPri="2" />);
312
+ expect(ReactNoop.flush()).toEqual([
313
+ 'A',
314
+ // Suspends
315
+ 'Suspend! [2]',
316
+ ]);
317
+
318
+ // While we're still waiting for the low-pri update to complete, update the
319
+ // high-pri text at high priority.
320
+ ReactNoop.flushSync(() => {
321
+ ReactNoop.render(<App highPri="B" lowPri="1" />);
322
+ });
323
+ expect(ReactNoop.flush()).toEqual(['B', '1']);
324
+ expect(ReactNoop.getChildren()).toEqual([span('B'), span('1')]);
325
+
326
+ // Unblock the low-pri text and finish
327
+ await advanceTimers(0);
328
+ expect(ReactNoop.flush()).toEqual(['Promise resolved [2]']);
329
+ expect(ReactNoop.getChildren()).toEqual([span('B'), span('1')]);
330
+ });
331
+
332
+ it('keeps working on lower priority work after being pinged', async () => {
333
+ function App(props) {
334
+ return (
335
+ <Fallback>
336
+ <AsyncText text="A" />
337
+ {props.showB && <Text text="B" />}
338
+ </Fallback>
339
+ );
340
+ }
341
+
342
+ ReactNoop.render(<App showB={false} />);
343
+ expect(ReactNoop.flush()).toEqual(['Suspend! [A]']);
344
+ expect(ReactNoop.getChildren()).toEqual([]);
345
+
346
+ // Advance React's virtual time by enough to fall into a new async bucket.
347
+ ReactNoop.expire(1200);
348
+ ReactNoop.render(<App showB={true} />);
349
+ expect(ReactNoop.flush()).toEqual(['Suspend! [A]', 'B']);
350
+ expect(ReactNoop.getChildren()).toEqual([]);
351
+
352
+ await advanceTimers(0);
353
+ expect(ReactNoop.flush()).toEqual(['Promise resolved [A]', 'A', 'B']);
354
+ expect(ReactNoop.getChildren()).toEqual([span('A'), span('B')]);
355
+ });
356
+
357
+ it('tries rendering a lower priority pending update even if a higher priority one suspends', async () => {
358
+ function App(props) {
359
+ if (props.hide) {
360
+ return <Text text="(empty)" />;
361
+ }
362
+ return (
363
+ <Fallback>
364
+ <AsyncText ms={2000} text="Async" />
365
+ </Fallback>
366
+ );
367
+ }
368
+
369
+ // Schedule a high pri update and a low pri update, without rendering in
370
+ // between.
371
+ ReactNoop.interactiveUpdates(() => {
372
+ // High pri
373
+ ReactNoop.render(<App />);
374
+ });
375
+ // Low pri
376
+ ReactNoop.render(<App hide={true} />);
377
+
378
+ expect(ReactNoop.flush()).toEqual([
379
+ // The first update suspends
380
+ 'Suspend! [Async]',
381
+ // but we have another pending update that we can work on
382
+ '(empty)',
383
+ ]);
384
+ expect(ReactNoop.getChildren()).toEqual([span('(empty)')]);
385
+ });
386
+
387
+ it('coalesces all async updates when in a suspended state', async () => {
388
+ ReactNoop.render(
389
+ <Fallback>
390
+ <AsyncText text="A" />
391
+ </Fallback>,
392
+ );
393
+ ReactNoop.flush();
394
+ await advanceTimers(0);
395
+ ReactNoop.flush();
396
+ expect(ReactNoop.getChildren()).toEqual([span('A')]);
397
+
398
+ ReactNoop.render(
399
+ <Fallback>
400
+ <AsyncText text="B" ms={50} />
401
+ </Fallback>,
402
+ );
403
+ expect(ReactNoop.flush()).toEqual(['Suspend! [B]']);
404
+ expect(ReactNoop.getChildren()).toEqual([span('A')]);
405
+
406
+ // Advance React's virtual time so that C falls into a new expiration bucket
407
+ ReactNoop.expire(1000);
408
+ ReactNoop.render(
409
+ <Fallback>
410
+ <AsyncText text="C" ms={100} />
411
+ </Fallback>,
412
+ );
413
+ expect(ReactNoop.flush()).toEqual([
414
+ // Tries C first, since it has a later expiration time
415
+ 'Suspend! [C]',
416
+ // Does not retry B, because its promise has not resolved yet.
417
+ ]);
418
+
419
+ expect(ReactNoop.getChildren()).toEqual([span('A')]);
420
+
421
+ // Unblock B
422
+ await advanceTimers(90);
423
+ // Even though B's promise resolved, the view is still suspended because it
424
+ // coalesced with C.
425
+ expect(ReactNoop.flush()).toEqual(['Promise resolved [B]']);
426
+ expect(ReactNoop.getChildren()).toEqual([span('A')]);
427
+
428
+ // Unblock C
429
+ await advanceTimers(50);
430
+ expect(ReactNoop.flush()).toEqual(['Promise resolved [C]', 'C']);
431
+ expect(ReactNoop.getChildren()).toEqual([span('C')]);
432
+ });
433
+
434
+ it('forces an expiration after an update times out', async () => {
435
+ ReactNoop.render(
436
+ <Fragment>
437
+ <Fallback placeholder={<Text text="Loading..." />}>
438
+ <AsyncText text="Async" ms={20000} />
439
+ </Fallback>
440
+ <Text text="Sync" />
441
+ </Fragment>,
442
+ );
443
+
444
+ expect(ReactNoop.flush()).toEqual([
445
+ // The async child suspends
446
+ 'Suspend! [Async]',
447
+ // Continue on the sibling
448
+ 'Sync',
449
+ ]);
450
+ // The update hasn't expired yet, so we commit nothing.
451
+ expect(ReactNoop.getChildren()).toEqual([]);
452
+
453
+ // Advance both React's virtual time and Jest's timers by enough to expire
454
+ // the update, but not by enough to flush the suspending promise.
455
+ ReactNoop.expire(10000);
456
+ await advanceTimers(10000);
457
+ expect(ReactNoop.flushExpired()).toEqual([
458
+ // Still suspended.
459
+ 'Suspend! [Async]',
460
+ // Now that the update has expired, we render the fallback UI
461
+ 'Loading...',
462
+ 'Sync',
463
+ ]);
464
+ expect(ReactNoop.getChildren()).toEqual([span('Loading...'), span('Sync')]);
465
+
466
+ // Once the promise resolves, we render the suspended view
467
+ await advanceTimers(10000);
468
+ expect(ReactNoop.flush()).toEqual(['Promise resolved [Async]', 'Async']);
469
+ expect(ReactNoop.getChildren()).toEqual([span('Async'), span('Sync')]);
470
+ });
471
+
472
+ it('switches to an inner fallback even if it expires later', async () => {
473
+ ReactNoop.render(
474
+ <Fragment>
475
+ <Text text="Sync" />
476
+ <Fallback timeout={1000} placeholder={<Text text="Loading outer..." />}>
477
+ <AsyncText text="Outer content" ms={2000} />
478
+ <Fallback
479
+ timeout={3000}
480
+ placeholder={<Text text="Loading inner..." />}>
481
+ <AsyncText text="Inner content" ms={4000} />
482
+ </Fallback>
483
+ </Fallback>
484
+ </Fragment>,
485
+ );
486
+
487
+ expect(ReactNoop.flush()).toEqual([
488
+ 'Sync',
489
+ // The async content suspends
490
+ 'Suspend! [Outer content]',
491
+ 'Suspend! [Inner content]',
492
+ ]);
493
+ // The update hasn't expired yet, so we commit nothing.
494
+ expect(ReactNoop.getChildren()).toEqual([]);
495
+
496
+ // Expire the outer timeout, but don't expire the inner one.
497
+ // We should see the outer loading placeholder.
498
+ ReactNoop.expire(1500);
499
+ await advanceTimers(1500);
500
+ expect(ReactNoop.flush()).toEqual([
501
+ 'Sync',
502
+ // Still suspended.
503
+ 'Suspend! [Outer content]',
504
+ 'Suspend! [Inner content]',
505
+ // We attempt to fallback to the inner placeholder
506
+ 'Loading inner...',
507
+ // But the outer content is still suspended, so we need to fallback to
508
+ // the outer placeholder.
509
+ 'Loading outer...',
510
+ ]);
511
+
512
+ expect(ReactNoop.getChildren()).toEqual([
513
+ span('Sync'),
514
+ span('Loading outer...'),
515
+ ]);
516
+
517
+ // Resolve the outer content's promise
518
+ ReactNoop.expire(1000);
519
+ await advanceTimers(1000);
520
+ expect(ReactNoop.flush()).toEqual([
521
+ 'Promise resolved [Outer content]',
522
+ 'Outer content',
523
+ // Inner content still hasn't loaded
524
+ 'Suspend! [Inner content]',
525
+ 'Loading inner...',
526
+ ]);
527
+ // We should now see the inner fallback UI.
528
+ expect(ReactNoop.getChildren()).toEqual([
529
+ span('Sync'),
530
+ span('Outer content'),
531
+ span('Loading inner...'),
532
+ ]);
533
+
534
+ // Finally, flush the inner promise. We should see the complete screen.
535
+ ReactNoop.expire(3000);
536
+ await advanceTimers(3000);
537
+ expect(ReactNoop.flush()).toEqual([
538
+ 'Promise resolved [Inner content]',
539
+ 'Inner content',
540
+ ]);
541
+ expect(ReactNoop.getChildren()).toEqual([
542
+ span('Sync'),
543
+ span('Outer content'),
544
+ span('Inner content'),
545
+ ]);
546
+ });
547
+
548
+ it('renders an expiration boundary synchronously', async () => {
549
+ // Synchronously render a tree that suspends
550
+ ReactNoop.flushSync(() =>
551
+ ReactNoop.render(
552
+ <Fragment>
553
+ <Fallback placeholder={<Text text="Loading..." />}>
554
+ <AsyncText text="Async" />
555
+ </Fallback>
556
+ <Text text="Sync" />
557
+ </Fragment>,
558
+ ),
559
+ );
560
+ expect(ReactNoop.clearYields()).toEqual([
561
+ // The async child suspends
562
+ 'Suspend! [Async]',
563
+ // We immediately render the fallback UI
564
+ 'Loading...',
565
+ // Continue on the sibling
566
+ 'Sync',
567
+ ]);
568
+ // The tree commits synchronously
569
+ expect(ReactNoop.getChildren()).toEqual([span('Loading...'), span('Sync')]);
570
+
571
+ // Once the promise resolves, we render the suspended view
572
+ await advanceTimers(0);
573
+ expect(ReactNoop.flush()).toEqual(['Promise resolved [Async]', 'Async']);
574
+ expect(ReactNoop.getChildren()).toEqual([span('Async'), span('Sync')]);
575
+ });
576
+
577
+ it('suspending inside an expired expiration boundary will bubble to the next one', async () => {
578
+ ReactNoop.flushSync(() =>
579
+ ReactNoop.render(
580
+ <Fragment>
581
+ <Fallback placeholder={<Text text="Loading (outer)..." />}>
582
+ <Fallback placeholder={<AsyncText text="Loading (inner)..." />}>
583
+ <AsyncText text="Async" />
584
+ </Fallback>
585
+ <Text text="Sync" />
586
+ </Fallback>
587
+ </Fragment>,
588
+ ),
589
+ );
590
+ expect(ReactNoop.clearYields()).toEqual([
591
+ 'Suspend! [Async]',
592
+ 'Suspend! [Loading (inner)...]',
593
+ 'Sync',
594
+ 'Loading (outer)...',
595
+ ]);
596
+ // The tree commits synchronously
597
+ expect(ReactNoop.getChildren()).toEqual([span('Loading (outer)...')]);
598
+ });
599
+
600
+ it('expires early with a `timeout` option', async () => {
601
+ ReactNoop.render(
602
+ <Fragment>
603
+ <Fallback timeout={1000} placeholder={<Text text="Loading..." />}>
604
+ <AsyncText text="Async" ms={3000} />
605
+ </Fallback>
606
+ <Text text="Sync" />
607
+ </Fragment>,
608
+ );
609
+
610
+ expect(ReactNoop.flush()).toEqual([
611
+ // The async child suspends
612
+ 'Suspend! [Async]',
613
+ // Continue on the sibling
614
+ 'Sync',
615
+ ]);
616
+ // The update hasn't expired yet, so we commit nothing.
617
+ expect(ReactNoop.getChildren()).toEqual([]);
618
+
619
+ // Advance both React's virtual time and Jest's timers by enough to trigger
620
+ // the timeout, but not by enough to flush the promise or reach the true
621
+ // expiration time.
622
+ ReactNoop.expire(2000);
623
+ await advanceTimers(2000);
624
+ expect(ReactNoop.flush()).toEqual([
625
+ // Still suspended.
626
+ 'Suspend! [Async]',
627
+ // Now that the expiration view has timed out, we render the fallback UI
628
+ 'Loading...',
629
+ 'Sync',
630
+ ]);
631
+ expect(ReactNoop.getChildren()).toEqual([span('Loading...'), span('Sync')]);
632
+
633
+ // Once the promise resolves, we render the suspended view
634
+ await advanceTimers(1000);
635
+ expect(ReactNoop.flush()).toEqual(['Promise resolved [Async]', 'Async']);
636
+ expect(ReactNoop.getChildren()).toEqual([span('Async'), span('Sync')]);
637
+ });
638
+
639
+ it('throws a helpful error when a synchronous update is suspended', () => {
640
+ expect(() => {
641
+ ReactNoop.flushSync(() =>
642
+ ReactNoop.render(<Timeout>{() => <AsyncText text="Async" />}</Timeout>),
643
+ );
644
+ }).toThrow(
645
+ 'A synchronous update was suspended, but no fallback UI was provided.',
646
+ );
647
+ });
648
+
649
+ it('throws a helpful error when an expired update is suspended', async () => {
650
+ ReactNoop.render(
651
+ <Timeout>{() => <AsyncText text="Async" ms={20000} />}</Timeout>,
652
+ );
653
+ expect(ReactNoop.flush()).toEqual(['Suspend! [Async]']);
654
+ await advanceTimers(10000);
655
+ ReactNoop.expire(10000);
656
+ expect(() => {
657
+ expect(ReactNoop.flush()).toEqual(['Suspend! [Async]']);
658
+ }).toThrow(
659
+ 'An update was suspended for longer than the timeout, but no fallback ' +
660
+ 'UI was provided.',
661
+ );
662
+ });
663
+
664
+ it('a Timeout component correctly handles more than one suspended child', async () => {
665
+ ReactNoop.render(
666
+ <Fallback timeout={0}>
667
+ <AsyncText text="A" ms={100} />
668
+ <AsyncText text="B" ms={100} />
669
+ </Fallback>,
670
+ );
671
+ ReactNoop.expire(10000);
672
+ expect(ReactNoop.flush()).toEqual(['Suspend! [A]', 'Suspend! [B]']);
673
+ expect(ReactNoop.getChildren()).toEqual([]);
674
+
675
+ await advanceTimers(100);
676
+
677
+ expect(ReactNoop.flush()).toEqual([
678
+ 'Promise resolved [A]',
679
+ 'Promise resolved [B]',
680
+ 'A',
681
+ 'B',
682
+ ]);
683
+ expect(ReactNoop.getChildren()).toEqual([span('A'), span('B')]);
684
+ });
685
+
686
+ it('can resume rendering earlier than a timeout', async () => {
687
+ ReactNoop.render(
688
+ <Fallback timeout={1000} placeholder={<Text text="Loading..." />}>
689
+ <AsyncText text="Async" ms={100} />
690
+ </Fallback>,
691
+ );
692
+ expect(ReactNoop.flush()).toEqual(['Suspend! [Async]']);
693
+ expect(ReactNoop.getChildren()).toEqual([]);
694
+
695
+ // Advance time by an amount slightly smaller than what's necessary to
696
+ // resolve the promise
697
+ await advanceTimers(99);
698
+
699
+ // Nothing has rendered yet
700
+ expect(ReactNoop.flush()).toEqual([]);
701
+ expect(ReactNoop.getChildren()).toEqual([]);
702
+
703
+ // Resolve the promise
704
+ await advanceTimers(1);
705
+ // We can now resume rendering
706
+ expect(ReactNoop.flush()).toEqual(['Promise resolved [Async]', 'Async']);
707
+ expect(ReactNoop.getChildren()).toEqual([span('Async')]);
708
+ });
709
+
710
+ it('starts working on an update even if its priority falls between two suspended levels', async () => {
711
+ function App(props) {
712
+ return (
713
+ <Fallback timeout={10000}>
714
+ {props.text === 'C' ? (
715
+ <Text text="C" />
716
+ ) : (
717
+ <AsyncText text={props.text} ms={10000} />
718
+ )}
719
+ </Fallback>
720
+ );
721
+ }
722
+
723
+ // Schedule an update
724
+ ReactNoop.render(<App text="A" />);
725
+ // The update should suspend.
726
+ expect(ReactNoop.flush()).toEqual(['Suspend! [A]']);
727
+ expect(ReactNoop.getChildren()).toEqual([]);
728
+
729
+ // Advance time until right before it expires. This number may need to
730
+ // change if the default expiration for low priority updates is adjusted.
731
+ await advanceTimers(4999);
732
+ ReactNoop.expire(4999);
733
+ expect(ReactNoop.flush()).toEqual([]);
734
+ expect(ReactNoop.getChildren()).toEqual([]);
735
+
736
+ // Schedule another low priority update.
737
+ ReactNoop.render(<App text="B" />);
738
+ // This update should also suspend.
739
+ expect(ReactNoop.flush()).toEqual(['Suspend! [B]']);
740
+ expect(ReactNoop.getChildren()).toEqual([]);
741
+
742
+ // Schedule a high priority update. Its expiration time will fall between
743
+ // the expiration times of the previous two updates.
744
+ ReactNoop.interactiveUpdates(() => {
745
+ ReactNoop.render(<App text="C" />);
746
+ });
747
+ expect(ReactNoop.flush()).toEqual(['C']);
748
+ expect(ReactNoop.getChildren()).toEqual([span('C')]);
749
+
750
+ await advanceTimers(10000);
751
+ // Flush the remaining work.
752
+ expect(ReactNoop.flush()).toEqual([
753
+ 'Promise resolved [A]',
754
+ 'Promise resolved [B]',
755
+ ]);
756
+ expect(ReactNoop.getChildren()).toEqual([span('C')]);
757
+ });
758
+
759
+ it('can hide a tree to unblock its surroundings', async () => {
760
+ function App() {
761
+ return (
762
+ <Timeout ms={1000}>
763
+ {didTimeout => (
764
+ <Fragment>
765
+ <div hidden={didTimeout}>
766
+ <AsyncText text="Async" ms={3000} />
767
+ </div>
768
+ {didTimeout ? <Text text="Loading..." /> : null}
769
+ </Fragment>
770
+ )}
771
+ </Timeout>
772
+ );
773
+ }
774
+
775
+ ReactNoop.render(<App />);
776
+ expect(ReactNoop.flush()).toEqual(['Suspend! [Async]']);
777
+ expect(ReactNoop.getChildren()).toEqual([]);
778
+
779
+ ReactNoop.expire(2000);
780
+ await advanceTimers(2000);
781
+ expect(ReactNoop.flush()).toEqual([
782
+ 'Suspend! [Async]',
783
+ 'Loading...',
784
+ 'Suspend! [Async]',
785
+ ]);
786
+ expect(ReactNoop.getChildren()).toEqual([div(), span('Loading...')]);
787
+
788
+ ReactNoop.expire(1000);
789
+ await advanceTimers(1000);
790
+
791
+ expect(ReactNoop.flush()).toEqual(['Promise resolved [Async]', 'Async']);
792
+ expect(ReactNoop.getChildren()).toEqual([div(span('Async'))]);
793
+ });
794
+
795
+ describe('splitting a high-pri update into high and low', () => {
796
+ React = require('react');
797
+
798
+ class AsyncValue extends React.Component {
799
+ state = {asyncValue: this.props.defaultValue};
800
+ componentDidMount() {
801
+ ReactNoop.deferredUpdates(() => {
802
+ this.setState((state, props) => ({asyncValue: props.value}));
803
+ });
804
+ }
805
+ componentDidUpdate() {
806
+ if (this.props.value !== this.state.asyncValue) {
807
+ ReactNoop.deferredUpdates(() => {
808
+ this.setState((state, props) => ({asyncValue: props.value}));
809
+ });
810
+ }
811
+ }
812
+ render() {
813
+ return this.props.children(this.state.asyncValue);
814
+ }
815
+ }
816
+
817
+ it('coalesces async values when in a suspended state', async () => {
818
+ function App(props) {
819
+ const highPriText = props.text;
820
+ return (
821
+ <Fallback>
822
+ <AsyncValue value={highPriText} defaultValue={null}>
823
+ {lowPriText => (
824
+ <Fragment>
825
+ <Text text={`High-pri: ${highPriText}`} />
826
+ {lowPriText && (
827
+ <AsyncText text={`Low-pri: ${lowPriText}`} ms={100} />
828
+ )}
829
+ </Fragment>
830
+ )}
831
+ </AsyncValue>
832
+ </Fallback>
833
+ );
834
+ }
835
+
836
+ function renderAppSync(props) {
837
+ ReactNoop.flushSync(() => ReactNoop.render(<App {...props} />));
838
+ }
839
+
840
+ // Initial mount
841
+ renderAppSync({text: 'A'});
842
+ expect(ReactNoop.flush()).toEqual([
843
+ // First we render at high priority
844
+ 'High-pri: A',
845
+ // Then we come back later to render a low priority
846
+ 'High-pri: A',
847
+ // The low-pri view suspends
848
+ 'Suspend! [Low-pri: A]',
849
+ ]);
850
+ expect(ReactNoop.getChildren()).toEqual([span('High-pri: A')]);
851
+
852
+ // Partially flush the promise for 'A', not by enough to resolve it.
853
+ await advanceTimers(99);
854
+
855
+ // Advance React's virtual time so that the next update falls into a new
856
+ // expiration bucket
857
+ ReactNoop.expire(2000);
858
+ // Update to B. At this point, the low-pri view still hasn't updated
859
+ // to 'A'.
860
+ renderAppSync({text: 'B'});
861
+ expect(ReactNoop.flush()).toEqual([
862
+ // First we render at high priority
863
+ 'High-pri: B',
864
+ // Then we come back later to render a low priority
865
+ 'High-pri: B',
866
+ // The low-pri view suspends
867
+ 'Suspend! [Low-pri: B]',
868
+ ]);
869
+ expect(ReactNoop.getChildren()).toEqual([span('High-pri: B')]);
870
+
871
+ // Flush the rest of the promise for 'A', without flushing the one
872
+ // for 'B'.
873
+ await advanceTimers(1);
874
+ expect(ReactNoop.flush()).toEqual([
875
+ // A is unblocked
876
+ 'Promise resolved [Low-pri: A]',
877
+ // But we don't try to render it, because there's a lower priority
878
+ // update that is also suspended.
879
+ ]);
880
+ expect(ReactNoop.getChildren()).toEqual([span('High-pri: B')]);
881
+
882
+ // Flush the remaining work.
883
+ await advanceTimers(99);
884
+ expect(ReactNoop.flush()).toEqual([
885
+ // B is unblocked
886
+ 'Promise resolved [Low-pri: B]',
887
+ // Now we can continue rendering the async view
888
+ 'High-pri: B',
889
+ 'Low-pri: B',
890
+ ]);
891
+ expect(ReactNoop.getChildren()).toEqual([
892
+ span('High-pri: B'),
893
+ span('Low-pri: B'),
894
+ ]);
895
+ });
896
+ });
897
+
898
+ describe('a Delay component', () => {
899
+ function Never() {
900
+ // Throws a promise that resolves after some arbitrarily large
901
+ // number of seconds. The idea is that this component will never
902
+ // resolve. It's always wrapped by a Timeout.
903
+ throw new Promise(resolve => setTimeout(() => resolve(), 10000));
904
+ }
905
+
906
+ function Delay({ms}) {
907
+ return (
908
+ <Timeout ms={ms}>
909
+ {didTimeout => {
910
+ if (didTimeout) {
911
+ // Once ms has elapsed, render null. This allows the rest of the
912
+ // tree to resume rendering.
913
+ return null;
914
+ }
915
+ return <Never />;
916
+ }}
917
+ </Timeout>
918
+ );
919
+ }
920
+
921
+ function DebouncedText({text, ms}) {
922
+ return (
923
+ <Fragment>
924
+ <Delay ms={ms} />
925
+ <Text text={text} />
926
+ </Fragment>
927
+ );
928
+ }
929
+
930
+ it('works', async () => {
931
+ ReactNoop.render(<DebouncedText text="A" ms={1000} />);
932
+ ReactNoop.flush();
933
+ expect(ReactNoop.getChildren()).toEqual([]);
934
+
935
+ await advanceTimers(800);
936
+ ReactNoop.expire(800);
937
+ ReactNoop.flush();
938
+ expect(ReactNoop.getChildren()).toEqual([]);
939
+
940
+ await advanceTimers(1000);
941
+ ReactNoop.expire(1000);
942
+ ReactNoop.flush();
943
+ expect(ReactNoop.getChildren()).toEqual([span('A')]);
944
+ });
945
+ });
946
+});