main
js 623 lines 19.1 KB
Raw
1 /**
2 * Copyright (c) Meta Platforms, Inc. and affiliates.
3 *
4 * This source code is licensed under the MIT license found in the
5 * LICENSE file in the root directory of this source tree.
6 *
7 * @emails react-core
8 * @jest-environment node
9 */
10
11 let Profiler;
12 let React;
13 let ReactNoop;
14 let Scheduler;
15 let ReactFeatureFlags;
16 let ReactCache;
17 let Suspense;
18 let TextResource;
19 let textResourceShouldFail;
20 let waitForAll;
21 let assertLog;
22 let act;
23
24 describe('ReactSuspensePlaceholder', () => {
25 beforeEach(() => {
26 jest.resetModules();
27
28 ReactFeatureFlags = require('shared/ReactFeatureFlags');
29
30 ReactFeatureFlags.enableProfilerTimer = true;
31 React = require('react');
32 ReactNoop = require('react-noop-renderer');
33 Scheduler = require('scheduler');
34 ReactCache = require('react-cache');
35
36 Profiler = React.Profiler;
37 Suspense = React.Suspense;
38
39 const InternalTestUtils = require('internal-test-utils');
40 waitForAll = InternalTestUtils.waitForAll;
41 assertLog = InternalTestUtils.assertLog;
42 act = InternalTestUtils.act;
43
44 TextResource = ReactCache.unstable_createResource(
45 ([text, ms = 0]) => {
46 let listeners = null;
47 let status = 'pending';
48 let value = null;
49 return {
50 then(resolve, reject) {
51 switch (status) {
52 case 'pending': {
53 if (listeners === null) {
54 listeners = [{resolve, reject}];
55 setTimeout(() => {
56 if (textResourceShouldFail) {
57 Scheduler.log(`Promise rejected [${text}]`);
58 status = 'rejected';
59 value = new Error('Failed to load: ' + text);
60 listeners.forEach(listener => listener.reject(value));
61 } else {
62 Scheduler.log(`Promise resolved [${text}]`);
63 status = 'resolved';
64 value = text;
65 listeners.forEach(listener => listener.resolve(value));
66 }
67 }, ms);
68 } else {
69 listeners.push({resolve, reject});
70 }
71 break;
72 }
73 case 'resolved': {
74 resolve(value);
75 break;
76 }
77 case 'rejected': {
78 reject(value);
79 break;
80 }
81 }
82 },
83 };
84 },
85 ([text, ms]) => text,
86 );
87 textResourceShouldFail = false;
88 });
89
90 function Text({fakeRenderDuration = 0, text = 'Text'}) {
91 Scheduler.unstable_advanceTime(fakeRenderDuration);
92 Scheduler.log(text);
93 return text;
94 }
95
96 function AsyncText({fakeRenderDuration = 0, ms, text}) {
97 Scheduler.unstable_advanceTime(fakeRenderDuration);
98 try {
99 TextResource.read([text, ms]);
100 Scheduler.log(text);
101 return text;
102 } catch (promise) {
103 if (typeof promise.then === 'function') {
104 Scheduler.log(`Suspend! [${text}]`);
105 } else {
106 Scheduler.log(`Error! [${text}]`);
107 }
108 throw promise;
109 }
110 }
111
112 it('times out children that are already hidden', async () => {
113 class HiddenText extends React.PureComponent {
114 render() {
115 const text = this.props.text;
116 Scheduler.log(text);
117 return <span hidden={true}>{text}</span>;
118 }
119 }
120
121 function App(props) {
122 return (
123 <Suspense fallback={<Text text="Loading..." />}>
124 <HiddenText text="A" />
125 <span>
126 <AsyncText ms={1000} text={props.middleText} />
127 </span>
128 <span>
129 <Text text="C" />
130 </span>
131 </Suspense>
132 );
133 }
134
135 // Initial mount
136 ReactNoop.render(<App middleText="B" />);
137
138 await waitForAll([
139 'A',
140 'Suspend! [B]',
141 'Loading...',
142 // pre-warming
143 'A',
144 'Suspend! [B]',
145 'C',
146 ]);
147 expect(ReactNoop).toMatchRenderedOutput('Loading...');
148
149 await act(() => jest.advanceTimersByTime(1000));
150 assertLog(['Promise resolved [B]', 'A', 'B', 'C']);
151
152 expect(ReactNoop).toMatchRenderedOutput(
153 <>
154 <span hidden={true}>A</span>
155 <span>B</span>
156 <span>C</span>
157 </>,
158 );
159
160 // Update
161 ReactNoop.render(<App middleText="B2" />);
162 await waitForAll([
163 'Suspend! [B2]',
164 'Loading...',
165 // pre-warming
166 'Suspend! [B2]',
167 'C',
168 ]);
169
170 // Time out the update
171 jest.advanceTimersByTime(750);
172 await waitForAll([]);
173 expect(ReactNoop).toMatchRenderedOutput(
174 <>
175 <span hidden={true}>A</span>
176 <span hidden={true}>B</span>
177 <span hidden={true}>C</span>
178 Loading...
179 </>,
180 );
181
182 // Resolve the promise
183 await act(() => jest.advanceTimersByTime(1000));
184 assertLog(['Promise resolved [B2]', 'B2', 'C']);
185
186 // Render the final update. A should still be hidden, because it was
187 // given a `hidden` prop.
188 expect(ReactNoop).toMatchRenderedOutput(
189 <>
190 <span hidden={true}>A</span>
191 <span>B2</span>
192 <span>C</span>
193 </>,
194 );
195 });
196
197 it('times out text nodes', async () => {
198 function App(props) {
199 return (
200 <Suspense fallback={<Text text="Loading..." />}>
201 <Text text="A" />
202 <AsyncText ms={1000} text={props.middleText} />
203 <Text text="C" />
204 </Suspense>
205 );
206 }
207
208 // Initial mount
209 ReactNoop.render(<App middleText="B" />);
210
211 await waitForAll([
212 'A',
213 'Suspend! [B]',
214 'Loading...',
215 // pre-warming
216 'A',
217 'Suspend! [B]',
218 'C',
219 ]);
220
221 expect(ReactNoop).not.toMatchRenderedOutput('ABC');
222
223 await act(() => jest.advanceTimersByTime(1000));
224 assertLog(['Promise resolved [B]', 'A', 'B', 'C']);
225 expect(ReactNoop).toMatchRenderedOutput('ABC');
226
227 // Update
228 ReactNoop.render(<App middleText="B2" />);
229 await waitForAll([
230 'A',
231 'Suspend! [B2]',
232 'Loading...',
233 // pre-warming
234 'A',
235 'Suspend! [B2]',
236 'C',
237 ]);
238 // Time out the update
239 jest.advanceTimersByTime(750);
240 await waitForAll([]);
241 expect(ReactNoop).toMatchRenderedOutput('Loading...');
242
243 // Resolve the promise
244 await act(() => jest.advanceTimersByTime(1000));
245 assertLog(['Promise resolved [B2]', 'A', 'B2', 'C']);
246
247 // Render the final update. A should still be hidden, because it was
248 // given a `hidden` prop.
249 expect(ReactNoop).toMatchRenderedOutput('AB2C');
250 });
251
252 it('preserves host context for text nodes', async () => {
253 function App(props) {
254 return (
255 // uppercase is a special type that causes React Noop to render child
256 // text nodes as uppercase.
257 <uppercase>
258 <Suspense fallback={<Text text="Loading..." />}>
259 <Text text="a" />
260 <AsyncText ms={1000} text={props.middleText} />
261 <Text text="c" />
262 </Suspense>
263 </uppercase>
264 );
265 }
266
267 // Initial mount
268 ReactNoop.render(<App middleText="b" />);
269
270 await waitForAll([
271 'a',
272 'Suspend! [b]',
273 'Loading...',
274 // pre-warming
275 'a',
276 'Suspend! [b]',
277 'c',
278 ]);
279
280 expect(ReactNoop).toMatchRenderedOutput(<uppercase>LOADING...</uppercase>);
281
282 await act(() => jest.advanceTimersByTime(1000));
283 assertLog(['Promise resolved [b]', 'a', 'b', 'c']);
284 expect(ReactNoop).toMatchRenderedOutput(<uppercase>ABC</uppercase>);
285
286 // Update
287 ReactNoop.render(<App middleText="b2" />);
288 await waitForAll([
289 'a',
290 'Suspend! [b2]',
291 'Loading...',
292 'a',
293 'Suspend! [b2]',
294 'c',
295 ]);
296 // Time out the update
297 jest.advanceTimersByTime(750);
298 await waitForAll([]);
299 expect(ReactNoop).toMatchRenderedOutput(<uppercase>LOADING...</uppercase>);
300
301 // Resolve the promise
302 await act(() => jest.advanceTimersByTime(1000));
303 assertLog(['Promise resolved [b2]', 'a', 'b2', 'c']);
304
305 // Render the final update. A should still be hidden, because it was
306 // given a `hidden` prop.
307 expect(ReactNoop).toMatchRenderedOutput(<uppercase>AB2C</uppercase>);
308 });
309
310 describe('profiler durations', () => {
311 let App;
312 let onRender;
313
314 beforeEach(() => {
315 // Order of parameters: id, phase, actualDuration, treeBaseDuration
316 onRender = jest.fn();
317
318 const Fallback = () => {
319 Scheduler.log('Fallback');
320 Scheduler.unstable_advanceTime(10);
321 return 'Loading...';
322 };
323
324 const Suspending = () => {
325 Scheduler.log('Suspending');
326 Scheduler.unstable_advanceTime(2);
327 return <AsyncText ms={1000} text="Loaded" fakeRenderDuration={1} />;
328 };
329
330 App = ({shouldSuspend, text = 'Text', textRenderDuration = 5}) => {
331 Scheduler.log('App');
332 return (
333 <Profiler id="root" onRender={onRender}>
334 <Suspense fallback={<Fallback />}>
335 {shouldSuspend && <Suspending />}
336 <Text fakeRenderDuration={textRenderDuration} text={text} />
337 </Suspense>
338 </Profiler>
339 );
340 };
341 });
342
343 describe('when suspending during mount', () => {
344 // @gate !disableLegacyMode && !disableLegacyMode
345 it('properly accounts for base durations when a suspended times out in a legacy tree', async () => {
346 ReactNoop.renderLegacySyncRoot(<App shouldSuspend={true} />);
347 assertLog([
348 'App',
349 'Suspending',
350 'Suspend! [Loaded]',
351 'Text',
352 'Fallback',
353 ]);
354 expect(ReactNoop).toMatchRenderedOutput('Loading...');
355 expect(onRender).toHaveBeenCalledTimes(1);
356
357 // Initial mount only shows the "Loading..." Fallback.
358 // The treeBaseDuration then should be 10ms spent rendering Fallback,
359 // but the actualDuration should also include the 8ms spent rendering the hidden tree.
360 expect(onRender.mock.calls[0][2]).toBe(18);
361 expect(onRender.mock.calls[0][3]).toBe(10);
362
363 jest.advanceTimersByTime(1000);
364
365 assertLog(['Promise resolved [Loaded]']);
366
367 ReactNoop.flushSync();
368
369 assertLog(['Loaded']);
370 expect(ReactNoop).toMatchRenderedOutput('LoadedText');
371 expect(onRender).toHaveBeenCalledTimes(2);
372
373 // When the suspending data is resolved and our final UI is rendered,
374 // the baseDuration should only include the 1ms re-rendering AsyncText,
375 // but the treeBaseDuration should include the full 8ms spent in the tree.
376 expect(onRender.mock.calls[1][2]).toBe(1);
377 expect(onRender.mock.calls[1][3]).toBe(8);
378 });
379
380 it('properly accounts for base durations when a suspended times out in a concurrent tree', async () => {
381 ReactNoop.render(<App shouldSuspend={true} />);
382
383 await waitForAll([
384 'App',
385 'Suspending',
386 'Suspend! [Loaded]',
387 'Fallback',
388 // pre-warming
389 'Suspending',
390 'Suspend! [Loaded]',
391 'Text',
392 ]);
393 // Since this is initial render we immediately commit the fallback. Another test below
394 // deals with the update case where this suspends.
395 expect(ReactNoop).toMatchRenderedOutput('Loading...');
396 expect(onRender).toHaveBeenCalledTimes(
397 gate('alwaysThrottleRetries') ? 1 : 2,
398 );
399
400 // Initial mount only shows the "Loading..." Fallback.
401 // The treeBaseDuration then should be 10ms spent rendering Fallback,
402 // but the actualDuration should also include the 3ms spent rendering the hidden tree.
403 expect(onRender.mock.calls[0][2]).toBe(13);
404 expect(onRender.mock.calls[0][3]).toBe(10);
405
406 // Resolve the pending promise.
407 await act(() => jest.advanceTimersByTime(1000));
408 assertLog([
409 'Promise resolved [Loaded]',
410 'Suspending',
411 'Loaded',
412 'Text',
413 ]);
414 expect(ReactNoop).toMatchRenderedOutput('LoadedText');
415
416 expect(onRender).toHaveBeenCalledTimes(3);
417
418 // When the suspending data is resolved and our final UI is rendered,
419 // both times should include the 8ms re-rendering Suspending and AsyncText.
420 expect(onRender.mock.calls[2][2]).toBe(8);
421 expect(onRender.mock.calls[2][3]).toBe(8);
422 });
423 });
424
425 describe('when suspending during update', () => {
426 // @gate !disableLegacyMode && !disableLegacyMode
427 it('properly accounts for base durations when a suspended times out in a legacy tree', async () => {
428 ReactNoop.renderLegacySyncRoot(
429 <App shouldSuspend={false} textRenderDuration={5} />,
430 );
431 assertLog(['App', 'Text']);
432 expect(ReactNoop).toMatchRenderedOutput('Text');
433 expect(onRender).toHaveBeenCalledTimes(1);
434
435 // Initial mount only shows the "Text" text.
436 // It should take 5ms to render.
437 expect(onRender.mock.calls[0][2]).toBe(5);
438 expect(onRender.mock.calls[0][3]).toBe(5);
439
440 ReactNoop.render(<App shouldSuspend={true} textRenderDuration={5} />);
441 assertLog([
442 'App',
443 'Suspending',
444 'Suspend! [Loaded]',
445 'Text',
446 'Fallback',
447 ]);
448 expect(ReactNoop).toMatchRenderedOutput('Loading...');
449 expect(onRender).toHaveBeenCalledTimes(2);
450
451 // The suspense update should only show the "Loading..." Fallback.
452 // The actual duration should include 10ms spent rendering Fallback,
453 // plus the 8ms render all of the hidden, suspended subtree.
454 // But the tree base duration should only include 10ms spent rendering Fallback,
455 expect(onRender.mock.calls[1][2]).toBe(18);
456 expect(onRender.mock.calls[1][3]).toBe(10);
457
458 ReactNoop.renderLegacySyncRoot(
459 <App shouldSuspend={true} text="New" textRenderDuration={6} />,
460 );
461 assertLog([
462 'App',
463 'Suspending',
464 'Suspend! [Loaded]',
465 'New',
466 'Fallback',
467 ]);
468 expect(ReactNoop).toMatchRenderedOutput('Loading...');
469 expect(onRender).toHaveBeenCalledTimes(3);
470
471 expect(onRender.mock.calls[1][2]).toBe(18);
472 expect(onRender.mock.calls[1][3]).toBe(10);
473 jest.advanceTimersByTime(1000);
474
475 assertLog(['Promise resolved [Loaded]']);
476
477 ReactNoop.flushSync();
478
479 assertLog(['Loaded']);
480 expect(ReactNoop).toMatchRenderedOutput('LoadedNew');
481 expect(onRender).toHaveBeenCalledTimes(4);
482
483 // When the suspending data is resolved and our final UI is rendered,
484 // the baseDuration should only include the 1ms re-rendering AsyncText,
485 // but the treeBaseDuration should include the full 9ms spent in the tree.
486 expect(onRender.mock.calls[3][2]).toBe(1);
487 expect(onRender.mock.calls[3][3]).toBe(9);
488 });
489
490 it('properly accounts for base durations when a suspended times out in a concurrent tree', async () => {
491 const Fallback = () => {
492 Scheduler.log('Fallback');
493 Scheduler.unstable_advanceTime(10);
494 return 'Loading...';
495 };
496
497 const Suspending = () => {
498 Scheduler.log('Suspending');
499 Scheduler.unstable_advanceTime(2);
500 return <AsyncText ms={1000} text="Loaded" fakeRenderDuration={1} />;
501 };
502
503 App = ({shouldSuspend, text = 'Text', textRenderDuration = 5}) => {
504 Scheduler.log('App');
505 return (
506 <Profiler id="root" onRender={onRender}>
507 <Suspense fallback={<Fallback />}>
508 {shouldSuspend && <Suspending />}
509 <Text fakeRenderDuration={textRenderDuration} text={text} />
510 </Suspense>
511 </Profiler>
512 );
513 };
514
515 ReactNoop.render(
516 <>
517 <App shouldSuspend={false} textRenderDuration={5} />
518 <Suspense fallback={null} />
519 </>,
520 );
521
522 await waitForAll(['App', 'Text']);
523 expect(ReactNoop).toMatchRenderedOutput('Text');
524 expect(onRender).toHaveBeenCalledTimes(1);
525
526 // Initial mount only shows the "Text" text.
527 // It should take 5ms to render.
528 expect(onRender.mock.calls[0][2]).toBe(5);
529 expect(onRender.mock.calls[0][3]).toBe(5);
530
531 ReactNoop.render(
532 <>
533 <App shouldSuspend={true} textRenderDuration={5} />
534 <Suspense fallback={null} />
535 </>,
536 );
537 await waitForAll([
538 'App',
539 'Suspending',
540 'Suspend! [Loaded]',
541 'Fallback',
542 // pre-warming
543 'Suspending',
544 'Suspend! [Loaded]',
545 'Text',
546 ]);
547 // Show the fallback UI.
548 expect(ReactNoop).toMatchRenderedOutput('Loading...');
549 expect(onRender).toHaveBeenCalledTimes(
550 gate('alwaysThrottleRetries') ? 2 : 3,
551 );
552
553 jest.advanceTimersByTime(900);
554
555 // The suspense update should only show the "Loading..." Fallback.
556 // The actual duration should include 10ms spent rendering Fallback,
557 // plus the 3ms render all of the partially rendered suspended subtree.
558 // But the tree base duration should only include 10ms spent rendering Fallback.
559 expect(onRender.mock.calls[1][2]).toBe(13);
560 expect(onRender.mock.calls[1][3]).toBe(10);
561
562 // Update again while timed out.
563 // Since this test was originally written we added an optimization to avoid
564 // suspending in the case that we already timed out. To simulate the old
565 // behavior, we add a different suspending boundary as a sibling.
566 ReactNoop.render(
567 <>
568 <App shouldSuspend={true} text="New" textRenderDuration={6} />
569 <Suspense fallback={null}>
570 <AsyncText ms={100} text="Sibling" fakeRenderDuration={1} />
571 </Suspense>
572 </>,
573 );
574
575 // TODO: This is here only to shift us into the next JND bucket. A
576 // consequence of AsyncText relying on the same timer queue as React's
577 // internal Suspense timer. We should decouple our AsyncText helpers
578 // from timers.
579 Scheduler.unstable_advanceTime(200);
580
581 await waitForAll([
582 'App',
583 'Suspending',
584 'Suspend! [Loaded]',
585 'Fallback',
586 'Suspend! [Sibling]',
587 // pre-warming
588 'Suspending',
589 'Suspend! [Loaded]',
590 'New',
591 'Suspend! [Sibling]',
592 ]);
593 expect(ReactNoop).toMatchRenderedOutput('Loading...');
594
595 expect(onRender).toHaveBeenCalledTimes(
596 gate('alwaysThrottleRetries') ? 4 : 5,
597 );
598
599 // Resolve the pending promise.
600 await act(async () => {
601 jest.advanceTimersByTime(100);
602 assertLog([
603 'Promise resolved [Loaded]',
604 'Promise resolved [Sibling]',
605 ]);
606 await waitForAll(['Suspending', 'Loaded', 'New', 'Sibling']);
607 });
608
609 expect(onRender).toHaveBeenCalledTimes(
610 gate('alwaysThrottleRetries') ? 5 : 6,
611 );
612
613 // When the suspending data is resolved and our final UI is rendered,
614 // both times should include the 6ms rendering Text,
615 // the 2ms rendering Suspending, and the 1ms rendering AsyncText.
616 expect(onRender.mock.calls[4][2]).toBe(9);
617 expect(onRender.mock.calls[4][3]).toBe(
618 gate('alwaysThrottleRetries') ? 9 : 10,
619 );
620 });
621 });
622 });
623 });