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