1
+let React;
2
+let ReactNoop;
3
+let Scheduler;
4
+let act;
5
+let LegacyHidden;
6
+let Activity;
7
+let Suspense;
8
+let useState;
9
+let useEffect;
10
+let startTransition;
11
+let textCache;
12
+let waitFor;
13
+let waitForPaint;
14
+let assertLog;
15
+
16
+describe('Activity Suspense', () => {
17
+ beforeEach(() => {
18
+ jest.resetModules();
19
+
20
+ React = require('react');
21
+ ReactNoop = require('react-noop-renderer');
22
+ Scheduler = require('scheduler');
23
+ act = require('internal-test-utils').act;
24
+ LegacyHidden = React.unstable_LegacyHidden;
25
+ Activity = React.unstable_Activity;
26
+ Suspense = React.Suspense;
27
+ useState = React.useState;
28
+ useEffect = React.useEffect;
29
+ startTransition = React.startTransition;
30
+
31
+ const InternalTestUtils = require('internal-test-utils');
32
+ waitFor = InternalTestUtils.waitFor;
33
+ waitForPaint = InternalTestUtils.waitForPaint;
34
+ assertLog = InternalTestUtils.assertLog;
35
+
36
+ textCache = new Map();
37
+ });
38
+
39
+ function resolveText(text) {
40
+ const record = textCache.get(text);
41
+ if (record === undefined) {
42
+ const newRecord = {
43
+ status: 'resolved',
44
+ value: text,
45
+ };
46
+ textCache.set(text, newRecord);
47
+ } else if (record.status === 'pending') {
48
+ const thenable = record.value;
49
+ record.status = 'resolved';
50
+ record.value = text;
51
+ thenable.pings.forEach(t => t());
52
+ }
53
+ }
54
+
55
+ function readText(text) {
56
+ const record = textCache.get(text);
57
+ if (record !== undefined) {
58
+ switch (record.status) {
59
+ case 'pending':
60
+ Scheduler.log(`Suspend! [${text}]`);
61
+ throw record.value;
62
+ case 'rejected':
63
+ throw record.value;
64
+ case 'resolved':
65
+ return record.value;
66
+ }
67
+ } else {
68
+ Scheduler.log(`Suspend! [${text}]`);
69
+ const thenable = {
70
+ pings: [],
71
+ then(resolve) {
72
+ if (newRecord.status === 'pending') {
73
+ thenable.pings.push(resolve);
74
+ } else {
75
+ Promise.resolve().then(() => resolve(newRecord.value));
76
+ }
77
+ },
78
+ };
79
+
80
+ const newRecord = {
81
+ status: 'pending',
82
+ value: thenable,
83
+ };
84
+ textCache.set(text, newRecord);
85
+
86
+ throw thenable;
87
+ }
88
+ }
89
+
90
+ function Text({text}) {
91
+ Scheduler.log(text);
92
+ return text;
93
+ }
94
+
95
+ function AsyncText({text}) {
96
+ readText(text);
97
+ Scheduler.log(text);
98
+ return text;
99
+ }
100
+
101
+ // @gate enableActivity
102
+ it('basic example of suspending inside hidden tree', async () => {
103
+ const root = ReactNoop.createRoot();
104
+
105
+ function App() {
106
+ return (
107
+ <Suspense fallback={<Text text="Loading..." />}>
108
+ <span>
109
+ <Text text="Visible" />
110
+ </span>
111
+ <Activity mode="hidden">
112
+ <span>
113
+ <AsyncText text="Hidden" />
114
+ </span>
115
+ </Activity>
116
+ </Suspense>
117
+ );
118
+ }
119
+
120
+ // The hidden tree hasn't finished loading, but we should still be able to
121
+ // show the surrounding contents. The outer Suspense boundary
122
+ // isn't affected.
123
+ await act(() => {
124
+ root.render(<App />);
125
+ });
126
+ assertLog(['Visible', 'Suspend! [Hidden]']);
127
+ expect(root).toMatchRenderedOutput(<span>Visible</span>);
128
+
129
+ // When the data resolves, we should be able to finish prerendering
130
+ // the hidden tree.
131
+ await act(async () => {
132
+ await resolveText('Hidden');
133
+ });
134
+ assertLog(['Hidden']);
135
+ expect(root).toMatchRenderedOutput(
136
+ <>
137
+ <span>Visible</span>
138
+ <span hidden={true}>Hidden</span>
139
+ </>,
140
+ );
141
+ });
142
+
143
+ // @gate enableLegacyHidden
144
+ test('LegacyHidden does not handle suspense', async () => {
145
+ const root = ReactNoop.createRoot();
146
+
147
+ function App() {
148
+ return (
149
+ <Suspense fallback={<Text text="Loading..." />}>
150
+ <span>
151
+ <Text text="Visible" />
152
+ </span>
153
+ <LegacyHidden mode="hidden">
154
+ <span>
155
+ <AsyncText text="Hidden" />
156
+ </span>
157
+ </LegacyHidden>
158
+ </Suspense>
159
+ );
160
+ }
161
+
162
+ // Unlike Activity, LegacyHidden never captures if something suspends
163
+ await act(() => {
164
+ root.render(<App />);
165
+ });
166
+ assertLog(['Visible', 'Suspend! [Hidden]', 'Loading...']);
167
+ // Nearest Suspense boundary switches to a fallback even though the
168
+ // suspended content is hidden.
169
+ expect(root).toMatchRenderedOutput(
170
+ <>
171
+ <span hidden={true}>Visible</span>
172
+ Loading...
173
+ </>,
174
+ );
175
+ });
176
+
177
+ // @gate __DEV__ && enableActivity
178
+ test('Regression: Suspending on hide should not infinite loop.', async () => {
179
+ // This regression only repros in public act.
180
+ global.IS_REACT_ACT_ENVIRONMENT = true;
181
+ const root = ReactNoop.createRoot();
182
+
183
+ let setMode;
184
+ function Container({text}) {
185
+ const [mode, _setMode] = React.useState('visible');
186
+ setMode = _setMode;
187
+ useEffect(() => {
188
+ return () => {
189
+ Scheduler.log(`Clear [${text}]`);
190
+ textCache.delete(text);
191
+ };
192
+ });
193
+ return (
194
+ //$FlowFixMe
195
+ <Suspense fallback="Loading">
196
+ <Activity mode={mode}>
197
+ <AsyncText text={text} />
198
+ </Activity>
199
+ </Suspense>
200
+ );
201
+ }
202
+
203
+ await React.act(() => {
204
+ root.render(<Container text="hello" />);
205
+ });
206
+ assertLog([
207
+ 'Suspend! [hello]',
208
+ ...(gate(flags => flags.enableSiblingPrerendering)
209
+ ? ['Suspend! [hello]']
210
+ : []),
211
+ ]);
212
+ expect(root).toMatchRenderedOutput('Loading');
213
+
214
+ await React.act(async () => {
215
+ await resolveText('hello');
216
+ });
217
+ assertLog(['hello']);
218
+ expect(root).toMatchRenderedOutput('hello');
219
+
220
+ await React.act(async () => {
221
+ setMode('hidden');
222
+ });
223
+ assertLog(['Clear [hello]', 'Suspend! [hello]']);
224
+ expect(root).toMatchRenderedOutput('');
225
+ });
226
+
227
+ // @gate enableActivity
228
+ test("suspending inside currently hidden tree that's switching to visible", async () => {
229
+ const root = ReactNoop.createRoot();
230
+
231
+ function Details({open, children}) {
232
+ return (
233
+ <Suspense fallback={<Text text="Loading..." />}>
234
+ <span>
235
+ <Text text={open ? 'Open' : 'Closed'} />
236
+ </span>
237
+ <Activity mode={open ? 'visible' : 'hidden'}>
238
+ <span>{children}</span>
239
+ </Activity>
240
+ </Suspense>
241
+ );
242
+ }
243
+
244
+ // The hidden tree hasn't finished loading, but we should still be able to
245
+ // show the surrounding contents. It doesn't matter that there's no
246
+ // Suspense boundary because the unfinished content isn't visible.
247
+ await act(() => {
248
+ root.render(
249
+ <Details open={false}>
250
+ <AsyncText text="Async" />
251
+ </Details>,
252
+ );
253
+ });
254
+ assertLog(['Closed', 'Suspend! [Async]']);
255
+ expect(root).toMatchRenderedOutput(<span>Closed</span>);
256
+
257
+ // But when we switch the boundary from hidden to visible, it should
258
+ // now bubble to the nearest Suspense boundary.
259
+ await act(() => {
260
+ startTransition(() => {
261
+ root.render(
262
+ <Details open={true}>
263
+ <AsyncText text="Async" />
264
+ </Details>,
265
+ );
266
+ });
267
+ });
268
+ assertLog(['Open', 'Suspend! [Async]', 'Loading...']);
269
+ // It should suspend with delay to prevent the already-visible Suspense
270
+ // boundary from switching to a fallback
271
+ expect(root).toMatchRenderedOutput(<span>Closed</span>);
272
+
273
+ // Resolve the data and finish rendering
274
+ await act(async () => {
275
+ await resolveText('Async');
276
+ });
277
+ assertLog(['Open', 'Async']);
278
+ expect(root).toMatchRenderedOutput(
279
+ <>
280
+ <span>Open</span>
281
+ <span>Async</span>
282
+ </>,
283
+ );
284
+ });
285
+
286
+ // @gate enableActivity
287
+ test("suspending inside currently visible tree that's switching to hidden", async () => {
288
+ const root = ReactNoop.createRoot();
289
+
290
+ function Details({open, children}) {
291
+ return (
292
+ <Suspense fallback={<Text text="Loading..." />}>
293
+ <span>
294
+ <Text text={open ? 'Open' : 'Closed'} />
295
+ </span>
296
+ <Activity mode={open ? 'visible' : 'hidden'}>
297
+ <span>{children}</span>
298
+ </Activity>
299
+ </Suspense>
300
+ );
301
+ }
302
+
303
+ // Initial mount. Nothing suspends
304
+ await act(() => {
305
+ root.render(
306
+ <Details open={true}>
307
+ <Text text="(empty)" />
308
+ </Details>,
309
+ );
310
+ });
311
+ assertLog(['Open', '(empty)']);
312
+ expect(root).toMatchRenderedOutput(
313
+ <>
314
+ <span>Open</span>
315
+ <span>(empty)</span>
316
+ </>,
317
+ );
318
+
319
+ // Update that suspends inside the currently visible tree
320
+ await act(() => {
321
+ startTransition(() => {
322
+ root.render(
323
+ <Details open={true}>
324
+ <AsyncText text="Async" />
325
+ </Details>,
326
+ );
327
+ });
328
+ });
329
+ assertLog(['Open', 'Suspend! [Async]', 'Loading...']);
330
+ // It should suspend with delay to prevent the already-visible Suspense
331
+ // boundary from switching to a fallback
332
+ expect(root).toMatchRenderedOutput(
333
+ <>
334
+ <span>Open</span>
335
+ <span>(empty)</span>
336
+ </>,
337
+ );
338
+
339
+ // Update that hides the suspended tree
340
+ await act(() => {
341
+ startTransition(() => {
342
+ root.render(
343
+ <Details open={false}>
344
+ <AsyncText text="Async" />
345
+ </Details>,
346
+ );
347
+ });
348
+ });
349
+ // Now the visible part of the tree can commit without being blocked
350
+ // by the suspended content, which is hidden.
351
+ assertLog(['Closed', 'Suspend! [Async]']);
352
+ expect(root).toMatchRenderedOutput(
353
+ <>
354
+ <span>Closed</span>
355
+ <span hidden={true}>(empty)</span>
356
+ </>,
357
+ );
358
+
359
+ // Resolve the data and finish rendering
360
+ await act(async () => {
361
+ await resolveText('Async');
362
+ });
363
+ assertLog(['Async']);
364
+ expect(root).toMatchRenderedOutput(
365
+ <>
366
+ <span>Closed</span>
367
+ <span hidden={true}>Async</span>
368
+ </>,
369
+ );
370
+ });
371
+
372
+ // @gate enableActivity
373
+ test('update that suspends inside hidden tree', async () => {
374
+ let setText;
375
+ function Child() {
376
+ const [text, _setText] = useState('A');
377
+ setText = _setText;
378
+ return <AsyncText text={text} />;
379
+ }
380
+
381
+ function App({show}) {
382
+ return (
383
+ <Activity mode={show ? 'visible' : 'hidden'}>
384
+ <span>
385
+ <Child />
386
+ </span>
387
+ </Activity>
388
+ );
389
+ }
390
+
391
+ const root = ReactNoop.createRoot();
392
+ resolveText('A');
393
+ await act(() => {
394
+ root.render(<App show={false} />);
395
+ });
396
+ assertLog(['A']);
397
+
398
+ await act(() => {
399
+ startTransition(() => {
400
+ setText('B');
401
+ });
402
+ });
403
+ });
404
+
405
+ // @gate enableActivity
406
+ test('updates at multiple priorities that suspend inside hidden tree', async () => {
407
+ let setText;
408
+ let setStep;
409
+ function Child() {
410
+ const [text, _setText] = useState('A');
411
+ setText = _setText;
412
+
413
+ const [step, _setStep] = useState(0);
414
+ setStep = _setStep;
415
+
416
+ return <AsyncText text={text + step} />;
417
+ }
418
+
419
+ function App({show}) {
420
+ return (
421
+ <Activity mode={show ? 'visible' : 'hidden'}>
422
+ <span>
423
+ <Child />
424
+ </span>
425
+ </Activity>
426
+ );
427
+ }
428
+
429
+ const root = ReactNoop.createRoot();
430
+ resolveText('A0');
431
+ await act(() => {
432
+ root.render(<App show={false} />);
433
+ });
434
+ assertLog(['A0']);
435
+ expect(root).toMatchRenderedOutput(<span hidden={true}>A0</span>);
436
+
437
+ await act(() => {
438
+ React.startTransition(() => {
439
+ setStep(1);
440
+ });
441
+ ReactNoop.flushSync(() => {
442
+ setText('B');
443
+ });
444
+ });
445
+ assertLog([
446
+ // The high priority render suspends again
447
+ 'Suspend! [B0]',
448
+ // There's still pending work in another lane, so we should attempt
449
+ // that, too.
450
+ 'Suspend! [B1]',
451
+ ]);
452
+ expect(root).toMatchRenderedOutput(<span hidden={true}>A0</span>);
453
+
454
+ // Resolve the data and finish rendering
455
+ await act(() => {
456
+ resolveText('B1');
457
+ });
458
+ assertLog(['B1']);
459
+ expect(root).toMatchRenderedOutput(<span hidden={true}>B1</span>);
460
+ });
461
+
462
+ // @gate enableActivity
463
+ test('detect updates to a hidden tree during a concurrent event', async () => {
464
+ // This is a pretty complex test case. It relates to how we detect if an
465
+ // update is made to a hidden tree: when scheduling the update, we walk up
466
+ // the fiber return path to see if any of the parents is a hidden Activity
467
+ // component. This doesn't work if there's already a render in progress,
468
+ // because the tree might be about to flip to hidden. To avoid a data race,
469
+ // queue updates atomically: wait to queue the update until after the
470
+ // current render has finished.
471
+
472
+ let setInner;
473
+ function Child({outer}) {
474
+ const [inner, _setInner] = useState(0);
475
+ setInner = _setInner;
476
+
477
+ useEffect(() => {
478
+ // Inner and outer values are always updated simultaneously, so they
479
+ // should always be consistent.
480
+ if (inner !== outer) {
481
+ Scheduler.log('Tearing! Inner and outer are inconsistent!');
482
+ } else {
483
+ Scheduler.log('Inner and outer are consistent');
484
+ }
485
+ }, [inner, outer]);
486
+
487
+ return <Text text={'Inner: ' + inner} />;
488
+ }
489
+
490
+ let setOuter;
491
+ function App({show}) {
492
+ const [outer, _setOuter] = useState(0);
493
+ setOuter = _setOuter;
494
+ return (
495
+ <>
496
+ <Activity mode={show ? 'visible' : 'hidden'}>
497
+ <span>
498
+ <Child outer={outer} />
499
+ </span>
500
+ </Activity>
501
+ <span>
502
+ <Text text={'Outer: ' + outer} />
503
+ </span>
504
+ <Suspense fallback={<Text text="Loading..." />}>
505
+ <span>
506
+ <Text text={'Sibling: ' + outer} />
507
+ </span>
508
+ </Suspense>
509
+ </>
510
+ );
511
+ }
512
+
513
+ // Render a hidden tree
514
+ const root = ReactNoop.createRoot();
515
+ resolveText('Async: 0');
516
+ await act(() => {
517
+ root.render(<App show={true} />);
518
+ });
519
+ assertLog([
520
+ 'Inner: 0',
521
+ 'Outer: 0',
522
+ 'Sibling: 0',
523
+ 'Inner and outer are consistent',
524
+ ]);
525
+ expect(root).toMatchRenderedOutput(
526
+ <>
527
+ <span>Inner: 0</span>
528
+ <span>Outer: 0</span>
529
+ <span>Sibling: 0</span>
530
+ </>,
531
+ );
532
+
533
+ await act(async () => {
534
+ // Update a value both inside and outside the hidden tree. These values
535
+ // must always be consistent.
536
+ startTransition(() => {
537
+ setOuter(1);
538
+ setInner(1);
539
+ // In the same render, also hide the offscreen tree.
540
+ root.render(<App show={false} />);
541
+ });
542
+
543
+ await waitFor([
544
+ // The outer update will commit, but the inner update is deferred until
545
+ // a later render.
546
+ 'Outer: 1',
547
+ ]);
548
+
549
+ // Assert that we haven't committed quite yet
550
+ expect(root).toMatchRenderedOutput(
551
+ <>
552
+ <span>Inner: 0</span>
553
+ <span>Outer: 0</span>
554
+ <span>Sibling: 0</span>
555
+ </>,
556
+ );
557
+
558
+ // Before the tree commits, schedule a concurrent event. The inner update
559
+ // is to a tree that's just about to be hidden.
560
+ startTransition(() => {
561
+ setOuter(2);
562
+ setInner(2);
563
+ });
564
+
565
+ // Finish rendering and commit the in-progress render.
566
+ await waitForPaint(['Sibling: 1']);
567
+ expect(root).toMatchRenderedOutput(
568
+ <>
569
+ <span hidden={true}>Inner: 0</span>
570
+ <span>Outer: 1</span>
571
+ <span>Sibling: 1</span>
572
+ </>,
573
+ );
574
+
575
+ // Now reveal the hidden tree at high priority.
576
+ ReactNoop.flushSync(() => {
577
+ root.render(<App show={true} />);
578
+ });
579
+ assertLog([
580
+ // There are two pending updates on Inner, but only the first one
581
+ // is processed, even though they share the same lane. If the second
582
+ // update were erroneously processed, then Inner would be inconsistent
583
+ // with Outer.
584
+ 'Inner: 1',
585
+ 'Outer: 1',
586
+ 'Sibling: 1',
587
+ 'Inner and outer are consistent',
588
+ ]);
589
+ });
590
+ assertLog([
591
+ 'Inner: 2',
592
+ 'Outer: 2',
593
+ 'Sibling: 2',
594
+ 'Inner and outer are consistent',
595
+ ]);
596
+ expect(root).toMatchRenderedOutput(
597
+ <>
598
+ <span>Inner: 2</span>
599
+ <span>Outer: 2</span>
600
+ <span>Sibling: 2</span>
601
+ </>,
602
+ );
603
+ });
604
+});