main
js 771 lines 22.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 * @jest-environment node
8 */
9
10 'use strict';
11
12 let React;
13 let ReactNoop;
14 let Scheduler;
15 let act;
16 let readText;
17 let resolveText;
18 let startTransition;
19 let useState;
20 let useEffect;
21 let assertLog;
22 let waitFor;
23 let waitForAll;
24 let unstable_waitForExpired;
25
26 describe('ReactExpiration', () => {
27 beforeEach(() => {
28 jest.resetModules();
29
30 React = require('react');
31 ReactNoop = require('react-noop-renderer');
32 Scheduler = require('scheduler');
33 act = require('internal-test-utils').act;
34 startTransition = React.startTransition;
35 useState = React.useState;
36 useEffect = React.useEffect;
37
38 const InternalTestUtils = require('internal-test-utils');
39 assertLog = InternalTestUtils.assertLog;
40 waitFor = InternalTestUtils.waitFor;
41 waitForAll = InternalTestUtils.waitForAll;
42 unstable_waitForExpired = InternalTestUtils.unstable_waitForExpired;
43
44 const textCache = new Map();
45
46 readText = text => {
47 const record = textCache.get(text);
48 if (record !== undefined) {
49 switch (record.status) {
50 case 'pending':
51 throw record.promise;
52 case 'rejected':
53 throw Error('Failed to load: ' + text);
54 case 'resolved':
55 return text;
56 }
57 } else {
58 let ping;
59 const promise = new Promise(resolve => (ping = resolve));
60 const newRecord = {
61 status: 'pending',
62 ping: ping,
63 promise,
64 };
65 textCache.set(text, newRecord);
66 throw promise;
67 }
68 };
69
70 resolveText = text => {
71 const record = textCache.get(text);
72 if (record !== undefined) {
73 if (record.status === 'pending') {
74 Scheduler.log(`Promise resolved [${text}]`);
75 record.ping();
76 record.ping = null;
77 record.status = 'resolved';
78 clearTimeout(record.promise._timer);
79 record.promise = null;
80 }
81 } else {
82 const newRecord = {
83 ping: null,
84 status: 'resolved',
85 promise: null,
86 };
87 textCache.set(text, newRecord);
88 }
89 };
90 });
91
92 function Text(props) {
93 Scheduler.log(props.text);
94 return props.text;
95 }
96
97 function AsyncText(props) {
98 const text = props.text;
99 try {
100 readText(text);
101 Scheduler.log(text);
102 return text;
103 } catch (promise) {
104 if (typeof promise.then === 'function') {
105 Scheduler.log(`Suspend! [${text}]`);
106 if (typeof props.ms === 'number' && promise._timer === undefined) {
107 promise._timer = setTimeout(() => {
108 resolveText(text);
109 }, props.ms);
110 }
111 } else {
112 Scheduler.log(`Error! [${text}]`);
113 }
114 throw promise;
115 }
116 }
117
118 it('increases priority of updates as time progresses', async () => {
119 ReactNoop.render(<Text text="Step 1" />);
120 React.startTransition(() => {
121 ReactNoop.render(<Text text="Step 2" />);
122 });
123 await waitFor(['Step 1']);
124
125 expect(ReactNoop).toMatchRenderedOutput('Step 1');
126
127 // Nothing has expired yet because time hasn't advanced.
128 await unstable_waitForExpired([]);
129 expect(ReactNoop).toMatchRenderedOutput('Step 1');
130
131 // Advance time a bit, but not enough to expire the low pri update.
132 ReactNoop.expire(4500);
133 await unstable_waitForExpired([]);
134 expect(ReactNoop).toMatchRenderedOutput('Step 1');
135
136 // Advance by a little bit more. Now the update should expire and flush.
137 ReactNoop.expire(500);
138 await unstable_waitForExpired(['Step 2']);
139 expect(ReactNoop).toMatchRenderedOutput('Step 2');
140 });
141
142 it('two updates of like priority in the same event always flush within the same batch', async () => {
143 class TextClass extends React.Component {
144 componentDidMount() {
145 Scheduler.log(`${this.props.text} [commit]`);
146 }
147 componentDidUpdate() {
148 Scheduler.log(`${this.props.text} [commit]`);
149 }
150 render() {
151 Scheduler.log(`${this.props.text} [render]`);
152 return <span prop={this.props.text} />;
153 }
154 }
155
156 function interrupt() {
157 ReactNoop.flushSync(() => {
158 ReactNoop.renderToRootWithID(null, 'other-root');
159 });
160 }
161
162 // First, show what happens for updates in two separate events.
163 // Schedule an update.
164 React.startTransition(() => {
165 ReactNoop.render(<TextClass text="A" />);
166 });
167 // Advance the timer.
168 Scheduler.unstable_advanceTime(2000);
169 // Partially flush the first update, then interrupt it.
170 await waitFor(['A [render]']);
171 interrupt();
172
173 // Don't advance time by enough to expire the first update.
174 assertLog([]);
175 expect(ReactNoop).toMatchRenderedOutput(null);
176
177 // Schedule another update.
178 ReactNoop.render(<TextClass text="B" />);
179 // Both updates are batched
180 await waitForAll(['B [render]', 'B [commit]']);
181 expect(ReactNoop).toMatchRenderedOutput(<span prop="B" />);
182
183 // Now do the same thing again, except this time don't flush any work in
184 // between the two updates.
185 ReactNoop.render(<TextClass text="A" />);
186 Scheduler.unstable_advanceTime(2000);
187 assertLog([]);
188 expect(ReactNoop).toMatchRenderedOutput(<span prop="B" />);
189 // Schedule another update.
190 ReactNoop.render(<TextClass text="B" />);
191 // The updates should flush in the same batch, since as far as the scheduler
192 // knows, they may have occurred inside the same event.
193 await waitForAll(['B [render]', 'B [commit]']);
194 });
195
196 it(
197 'two updates of like priority in the same event always flush within the ' +
198 "same batch, even if there's a sync update in between",
199 async () => {
200 class TextClass extends React.Component {
201 componentDidMount() {
202 Scheduler.log(`${this.props.text} [commit]`);
203 }
204 componentDidUpdate() {
205 Scheduler.log(`${this.props.text} [commit]`);
206 }
207 render() {
208 Scheduler.log(`${this.props.text} [render]`);
209 return <span prop={this.props.text} />;
210 }
211 }
212
213 function interrupt() {
214 ReactNoop.flushSync(() => {
215 ReactNoop.renderToRootWithID(null, 'other-root');
216 });
217 }
218
219 // First, show what happens for updates in two separate events.
220 // Schedule an update.
221 React.startTransition(() => {
222 ReactNoop.render(<TextClass text="A" />);
223 });
224
225 // Advance the timer.
226 Scheduler.unstable_advanceTime(2000);
227 // Partially flush the first update, then interrupt it.
228 await waitFor(['A [render]']);
229 interrupt();
230
231 // Don't advance time by enough to expire the first update.
232 assertLog([]);
233 expect(ReactNoop).toMatchRenderedOutput(null);
234
235 // Schedule another update.
236 ReactNoop.render(<TextClass text="B" />);
237 // Both updates are batched
238 await waitForAll(['B [render]', 'B [commit]']);
239 expect(ReactNoop).toMatchRenderedOutput(<span prop="B" />);
240
241 // Now do the same thing again, except this time don't flush any work in
242 // between the two updates.
243 ReactNoop.render(<TextClass text="A" />);
244 Scheduler.unstable_advanceTime(2000);
245 assertLog([]);
246 expect(ReactNoop).toMatchRenderedOutput(<span prop="B" />);
247
248 // Perform some synchronous work. The scheduler must assume we're inside
249 // the same event.
250 interrupt();
251
252 // Schedule another update.
253 ReactNoop.render(<TextClass text="B" />);
254 // The updates should flush in the same batch, since as far as the scheduler
255 // knows, they may have occurred inside the same event.
256 await waitForAll(['B [render]', 'B [commit]']);
257 },
258 );
259
260 it('cannot update at the same expiration time that is already rendering', async () => {
261 const store = {text: 'initial'};
262 const subscribers = [];
263 class Connected extends React.Component {
264 state = {text: store.text};
265 componentDidMount() {
266 subscribers.push(this);
267 Scheduler.log(`${this.state.text} [${this.props.label}] [commit]`);
268 }
269 componentDidUpdate() {
270 Scheduler.log(`${this.state.text} [${this.props.label}] [commit]`);
271 }
272 render() {
273 Scheduler.log(`${this.state.text} [${this.props.label}] [render]`);
274 return <span prop={this.state.text} />;
275 }
276 }
277
278 function App() {
279 return (
280 <>
281 <Connected label="A" />
282 <Connected label="B" />
283 <Connected label="C" />
284 <Connected label="D" />
285 </>
286 );
287 }
288
289 // Initial mount
290 React.startTransition(() => {
291 ReactNoop.render(<App />);
292 });
293
294 await waitForAll([
295 'initial [A] [render]',
296 'initial [B] [render]',
297 'initial [C] [render]',
298 'initial [D] [render]',
299 'initial [A] [commit]',
300 'initial [B] [commit]',
301 'initial [C] [commit]',
302 'initial [D] [commit]',
303 ]);
304
305 // Partial update
306 React.startTransition(() => {
307 subscribers.forEach(s => s.setState({text: '1'}));
308 });
309
310 await waitFor(['1 [A] [render]', '1 [B] [render]']);
311
312 // Before the update can finish, update again. Even though no time has
313 // advanced, this update should be given a different expiration time than
314 // the currently rendering one. So, C and D should render with 1, not 2.
315 React.startTransition(() => {
316 subscribers.forEach(s => s.setState({text: '2'}));
317 });
318 await waitFor(['1 [C] [render]', '1 [D] [render]']);
319 });
320
321 it('stops yielding if CPU-bound update takes too long to finish', async () => {
322 const root = ReactNoop.createRoot();
323 function App() {
324 return (
325 <>
326 <Text text="A" />
327 <Text text="B" />
328 <Text text="C" />
329 <Text text="D" />
330 <Text text="E" />
331 </>
332 );
333 }
334
335 React.startTransition(() => {
336 root.render(<App />);
337 });
338
339 await waitFor(['A']);
340 await waitFor(['B']);
341 await waitFor(['C']);
342
343 Scheduler.unstable_advanceTime(10000);
344
345 await unstable_waitForExpired(['D', 'E']);
346 expect(root).toMatchRenderedOutput('ABCDE');
347 });
348
349 it('root expiration is measured from the time of the first update', async () => {
350 Scheduler.unstable_advanceTime(10000);
351
352 const root = ReactNoop.createRoot();
353 function App() {
354 return (
355 <>
356 <Text text="A" />
357 <Text text="B" />
358 <Text text="C" />
359 <Text text="D" />
360 <Text text="E" />
361 </>
362 );
363 }
364 React.startTransition(() => {
365 root.render(<App />);
366 });
367
368 await waitFor(['A']);
369 await waitFor(['B']);
370 await waitFor(['C']);
371
372 Scheduler.unstable_advanceTime(10000);
373
374 await unstable_waitForExpired(['D', 'E']);
375 expect(root).toMatchRenderedOutput('ABCDE');
376 });
377
378 it('should measure expiration times relative to module initialization', async () => {
379 // Tests an implementation detail where expiration times are computed using
380 // bitwise operations.
381
382 jest.resetModules();
383 Scheduler = require('scheduler');
384
385 const InternalTestUtils = require('internal-test-utils');
386 waitFor = InternalTestUtils.waitFor;
387 assertLog = InternalTestUtils.assertLog;
388 unstable_waitForExpired = InternalTestUtils.unstable_waitForExpired;
389
390 // Before importing the renderer, advance the current time by a number
391 // larger than the maximum allowed for bitwise operations.
392 const maxSigned31BitInt = 1073741823;
393 Scheduler.unstable_advanceTime(maxSigned31BitInt * 100);
394
395 // Now import the renderer. On module initialization, it will read the
396 // current time.
397 ReactNoop = require('react-noop-renderer');
398 React = require('react');
399
400 ReactNoop.render(<Text text="Step 1" />);
401 React.startTransition(() => {
402 ReactNoop.render(<Text text="Step 2" />);
403 });
404 await waitFor(['Step 1']);
405
406 // The update should not have expired yet.
407 await unstable_waitForExpired([]);
408
409 expect(ReactNoop).toMatchRenderedOutput('Step 1');
410
411 // Advance the time some more to expire the update.
412 Scheduler.unstable_advanceTime(10000);
413 await unstable_waitForExpired(['Step 2']);
414 expect(ReactNoop).toMatchRenderedOutput('Step 2');
415 });
416
417 it('should measure callback timeout relative to current time, not start-up time', async () => {
418 // Corresponds to a bugfix: https://github.com/facebook/react/pull/15479
419 // The bug wasn't caught by other tests because we use virtual times that
420 // default to 0, and most tests don't advance time.
421
422 // Before scheduling an update, advance the current time.
423 Scheduler.unstable_advanceTime(10000);
424
425 React.startTransition(() => {
426 ReactNoop.render('Hi');
427 });
428
429 await unstable_waitForExpired([]);
430 expect(ReactNoop).toMatchRenderedOutput(null);
431
432 // Advancing by ~5 seconds should be sufficient to expire the update. (I
433 // used a slightly larger number to allow for possible rounding.)
434 Scheduler.unstable_advanceTime(6000);
435 await unstable_waitForExpired([]);
436 expect(ReactNoop).toMatchRenderedOutput('Hi');
437 });
438
439 it('prevents starvation by sync updates by disabling time slicing if too much time has elapsed', async () => {
440 let updateSyncPri;
441 let updateNormalPri;
442 function App() {
443 const [highPri, setHighPri] = useState(0);
444 const [normalPri, setNormalPri] = useState(0);
445 updateSyncPri = () => {
446 ReactNoop.flushSync(() => {
447 setHighPri(n => n + 1);
448 });
449 };
450 updateNormalPri = () => setNormalPri(n => n + 1);
451 return (
452 <>
453 <Text text={'Sync pri: ' + highPri} />
454 {', '}
455 <Text text={'Normal pri: ' + normalPri} />
456 </>
457 );
458 }
459
460 const root = ReactNoop.createRoot();
461 await act(() => {
462 root.render(<App />);
463 });
464 assertLog(['Sync pri: 0', 'Normal pri: 0']);
465 expect(root).toMatchRenderedOutput('Sync pri: 0, Normal pri: 0');
466
467 // First demonstrate what happens when there's no starvation
468 await act(async () => {
469 React.startTransition(() => {
470 updateNormalPri();
471 });
472 await waitFor(['Sync pri: 0']);
473 updateSyncPri();
474 assertLog(['Sync pri: 1', 'Normal pri: 0']);
475
476 // The remaining work hasn't expired, so the render phase is time sliced.
477 // In other words, we can flush just the first child without flushing
478 // the rest.
479 //
480 // Yield right after first child.
481 await waitFor(['Sync pri: 1']);
482 // Now do the rest.
483 await waitForAll(['Normal pri: 1']);
484 });
485 expect(root).toMatchRenderedOutput('Sync pri: 1, Normal pri: 1');
486
487 // Do the same thing, but starve the first update
488 await act(async () => {
489 React.startTransition(() => {
490 updateNormalPri();
491 });
492 await waitFor(['Sync pri: 1']);
493
494 // This time, a lot of time has elapsed since the normal pri update
495 // started rendering. (This should advance time by some number that's
496 // definitely bigger than the constant heuristic we use to detect
497 // starvation of normal priority updates.)
498 Scheduler.unstable_advanceTime(10000);
499
500 updateSyncPri();
501 assertLog(['Sync pri: 2', 'Normal pri: 1']);
502
503 // The remaining work _has_ expired, so the render phase is _not_ time
504 // sliced. Attempting to flush just the first child also flushes the rest.
505 await waitFor(['Sync pri: 2'], {
506 additionalLogsAfterAttemptingToYield: ['Normal pri: 2'],
507 });
508 });
509 expect(root).toMatchRenderedOutput('Sync pri: 2, Normal pri: 2');
510 });
511
512 it('idle work never expires', async () => {
513 let updateSyncPri;
514 let updateIdlePri;
515 function App() {
516 const [syncPri, setSyncPri] = useState(0);
517 const [highPri, setIdlePri] = useState(0);
518 updateSyncPri = () => ReactNoop.flushSync(() => setSyncPri(n => n + 1));
519 updateIdlePri = () =>
520 ReactNoop.idleUpdates(() => {
521 setIdlePri(n => n + 1);
522 });
523 return (
524 <>
525 <Text text={'Sync pri: ' + syncPri} />
526 {', '}
527 <Text text={'Idle pri: ' + highPri} />
528 </>
529 );
530 }
531
532 const root = ReactNoop.createRoot();
533 await act(() => {
534 root.render(<App />);
535 });
536 assertLog(['Sync pri: 0', 'Idle pri: 0']);
537 expect(root).toMatchRenderedOutput('Sync pri: 0, Idle pri: 0');
538
539 // First demonstrate what happens when there's no starvation
540 await act(async () => {
541 updateIdlePri();
542 await waitFor(['Sync pri: 0']);
543 updateSyncPri();
544 });
545 // Same thing should happen as last time
546 assertLog([
547 // Interrupt idle update to render sync update
548 'Sync pri: 1',
549 'Idle pri: 0',
550 // Now render idle
551 'Sync pri: 1',
552 'Idle pri: 1',
553 ]);
554 expect(root).toMatchRenderedOutput('Sync pri: 1, Idle pri: 1');
555
556 // Do the same thing, but starve the first update
557 await act(async () => {
558 updateIdlePri();
559 await waitFor(['Sync pri: 1']);
560
561 // Advance a ridiculously large amount of time to demonstrate that the
562 // idle work never expires
563 Scheduler.unstable_advanceTime(100000);
564
565 updateSyncPri();
566 });
567 assertLog([
568 // Interrupt idle update to render sync update
569 'Sync pri: 2',
570 'Idle pri: 1',
571 // Now render idle
572 'Sync pri: 2',
573 'Idle pri: 2',
574 ]);
575 expect(root).toMatchRenderedOutput('Sync pri: 2, Idle pri: 2');
576 });
577
578 it('when multiple lanes expire, we can finish the in-progress one without including the others', async () => {
579 let setA;
580 let setB;
581 function App() {
582 const [a, _setA] = useState(0);
583 const [b, _setB] = useState(0);
584 setA = _setA;
585 setB = _setB;
586 return (
587 <>
588 <Text text={'A' + a} />
589 <Text text={'B' + b} />
590 <Text text="C" />
591 </>
592 );
593 }
594
595 const root = ReactNoop.createRoot();
596 await act(() => {
597 root.render(<App />);
598 });
599 assertLog(['A0', 'B0', 'C']);
600 expect(root).toMatchRenderedOutput('A0B0C');
601
602 await act(async () => {
603 startTransition(() => {
604 setA(1);
605 });
606 await waitFor(['A1']);
607 startTransition(() => {
608 setB(1);
609 });
610 await waitFor(['B0']);
611
612 // Expire both the transitions
613 Scheduler.unstable_advanceTime(10000);
614 // Both transitions have expired, but since they aren't related
615 // (entangled), we should be able to finish the in-progress transition
616 // without also including the next one.
617 await waitFor([], {
618 additionalLogsAfterAttemptingToYield: ['C'],
619 });
620 expect(root).toMatchRenderedOutput('A1B0C');
621
622 // The next transition also finishes without yielding.
623 await waitFor(['A1'], {
624 additionalLogsAfterAttemptingToYield: ['B1', 'C'],
625 });
626 expect(root).toMatchRenderedOutput('A1B1C');
627 });
628 });
629
630 it('updates do not expire while they are IO-bound', async () => {
631 const {Suspense} = React;
632
633 function App({step}) {
634 return (
635 <Suspense fallback={<Text text="Loading..." />}>
636 <AsyncText text={'A' + step} />
637 <Text text="B" />
638 <Text text="C" />
639 </Suspense>
640 );
641 }
642
643 const root = ReactNoop.createRoot();
644 await act(async () => {
645 await resolveText('A0');
646 root.render(<App step={0} />);
647 });
648 assertLog(['A0', 'B', 'C']);
649 expect(root).toMatchRenderedOutput('A0BC');
650
651 await act(async () => {
652 React.startTransition(() => {
653 root.render(<App step={1} />);
654 });
655 await waitForAll([
656 'Suspend! [A1]',
657 // pre-warming
658 'B',
659 'C',
660 // end pre-warming
661 'Loading...',
662 ]);
663
664 // Lots of time elapses before the promise resolves
665 Scheduler.unstable_advanceTime(10000);
666 await resolveText('A1');
667 assertLog(['Promise resolved [A1]']);
668
669 await waitFor(['A1']);
670 expect(root).toMatchRenderedOutput('A0BC');
671
672 // Lots more time elapses. We're CPU-bound now, so we should treat this
673 // as starvation.
674 Scheduler.unstable_advanceTime(10000);
675
676 // The rest of the update finishes without yielding.
677 await waitFor([], {
678 additionalLogsAfterAttemptingToYield: ['B', 'C'],
679 });
680 });
681 });
682
683 it('flushSync should not affect expired work', async () => {
684 let setA;
685 let setB;
686 function App() {
687 const [a, _setA] = useState(0);
688 const [b, _setB] = useState(0);
689 setA = _setA;
690 setB = _setB;
691 return (
692 <>
693 <Text text={'A' + a} />
694 <Text text={'B' + b} />
695 </>
696 );
697 }
698
699 const root = ReactNoop.createRoot();
700 await act(() => {
701 root.render(<App />);
702 });
703 assertLog(['A0', 'B0']);
704
705 await act(async () => {
706 startTransition(() => {
707 setA(1);
708 });
709 await waitFor(['A1']);
710
711 // Expire the in-progress update
712 Scheduler.unstable_advanceTime(10000);
713
714 ReactNoop.flushSync(() => {
715 setB(1);
716 });
717 assertLog(['A0', 'B1']);
718
719 // Now flush the original update. Because it expired, it should finish
720 // without yielding.
721 await waitFor(['A1'], {
722 additionalLogsAfterAttemptingToYield: ['B1'],
723 });
724 });
725 });
726
727 it('passive effects of expired update flush after paint', async () => {
728 function App({step}) {
729 useEffect(() => {
730 Scheduler.log('Effect: ' + step);
731 }, [step]);
732 return (
733 <>
734 <Text text={'A' + step} />
735 <Text text={'B' + step} />
736 <Text text={'C' + step} />
737 </>
738 );
739 }
740
741 const root = ReactNoop.createRoot();
742 await act(() => {
743 root.render(<App step={0} />);
744 });
745 assertLog(['A0', 'B0', 'C0', 'Effect: 0']);
746 expect(root).toMatchRenderedOutput('A0B0C0');
747
748 await act(async () => {
749 startTransition(() => {
750 root.render(<App step={1} />);
751 });
752 await waitFor(['A1']);
753
754 // Expire the update
755 Scheduler.unstable_advanceTime(10000);
756
757 // The update finishes without yielding. But it does not flush the effect.
758 await waitFor(['B1'], {
759 additionalLogsAfterAttemptingToYield: gate(
760 flags => flags.enableYieldingBeforePassive,
761 )
762 ? ['C1', 'Effect: 1']
763 : ['C1'],
764 });
765 });
766 if (!gate(flags => flags.enableYieldingBeforePassive)) {
767 // The effect flushes after paint.
768 assertLog(['Effect: 1']);
769 }
770 });
771 });