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