main
js 688 lines 18.7 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 'use strict';
12
13 let React;
14 let ReactNoop;
15 let Scheduler;
16 let ContinuousEventPriority;
17 let act;
18 let waitForAll;
19 let waitFor;
20 let assertLog;
21 let assertConsoleErrorDev;
22
23 describe('ReactIncrementalUpdates', () => {
24 beforeEach(() => {
25 jest.resetModules();
26
27 React = require('react');
28 ReactNoop = require('react-noop-renderer');
29 Scheduler = require('scheduler');
30 act = require('internal-test-utils').act;
31 ContinuousEventPriority =
32 require('react-reconciler/constants').ContinuousEventPriority;
33
34 const InternalTestUtils = require('internal-test-utils');
35 waitForAll = InternalTestUtils.waitForAll;
36 waitFor = InternalTestUtils.waitFor;
37 assertLog = InternalTestUtils.assertLog;
38 assertConsoleErrorDev = InternalTestUtils.assertConsoleErrorDev;
39 });
40
41 function Text({text}) {
42 Scheduler.log(text);
43 return text;
44 }
45
46 it('applies updates in order of priority', async () => {
47 let state;
48 class Foo extends React.Component {
49 state = {};
50 componentDidMount() {
51 Scheduler.log('commit');
52 React.startTransition(() => {
53 // Has low priority
54 this.setState({b: 'b'});
55 this.setState({c: 'c'});
56 });
57 // Has Task priority
58 this.setState({a: 'a'});
59 }
60 render() {
61 state = this.state;
62 return <div />;
63 }
64 }
65
66 ReactNoop.render(<Foo />);
67 await waitFor(['commit']);
68
69 expect(state).toEqual({a: 'a'});
70 await waitForAll([]);
71 expect(state).toEqual({a: 'a', b: 'b', c: 'c'});
72 });
73
74 it('applies updates with equal priority in insertion order', async () => {
75 let state;
76 class Foo extends React.Component {
77 state = {};
78 componentDidMount() {
79 // All have Task priority
80 this.setState({a: 'a'});
81 this.setState({b: 'b'});
82 this.setState({c: 'c'});
83 }
84 render() {
85 state = this.state;
86 return <div />;
87 }
88 }
89
90 ReactNoop.render(<Foo />);
91 await waitForAll([]);
92 expect(state).toEqual({a: 'a', b: 'b', c: 'c'});
93 });
94
95 it('only drops updates with equal or lesser priority when replaceState is called', async () => {
96 let instance;
97 class Foo extends React.Component {
98 state = {};
99 componentDidMount() {
100 Scheduler.log('componentDidMount');
101 }
102 componentDidUpdate() {
103 Scheduler.log('componentDidUpdate');
104 }
105 render() {
106 Scheduler.log('render');
107 instance = this;
108 return <div />;
109 }
110 }
111
112 ReactNoop.render(<Foo />);
113 await waitForAll(['render', 'componentDidMount']);
114
115 ReactNoop.flushSync(() => {
116 React.startTransition(() => {
117 instance.setState({x: 'x'});
118 instance.setState({y: 'y'});
119 });
120 instance.setState({a: 'a'});
121 instance.setState({b: 'b'});
122 React.startTransition(() => {
123 instance.updater.enqueueReplaceState(instance, {c: 'c'});
124 instance.setState({d: 'd'});
125 });
126 });
127
128 // Even though a replaceState has been already scheduled, it hasn't been
129 // flushed yet because it has async priority.
130 expect(instance.state).toEqual({a: 'a', b: 'b'});
131 assertLog(['render', 'componentDidUpdate']);
132
133 await waitForAll(['render', 'componentDidUpdate']);
134 // Now the rest of the updates are flushed, including the replaceState.
135 expect(instance.state).toEqual({c: 'c', d: 'd'});
136 });
137
138 it('can abort an update, schedule additional updates, and resume', async () => {
139 let instance;
140 class Foo extends React.Component {
141 state = {};
142 render() {
143 instance = this;
144 return <span prop={Object.keys(this.state).sort().join('')} />;
145 }
146 }
147
148 ReactNoop.render(<Foo />);
149 await waitForAll([]);
150
151 function createUpdate(letter) {
152 return () => {
153 Scheduler.log(letter);
154 return {
155 [letter]: letter,
156 };
157 };
158 }
159
160 // Schedule some async updates
161 React.startTransition(() => {
162 instance.setState(createUpdate('a'));
163 instance.setState(createUpdate('b'));
164 instance.setState(createUpdate('c'));
165 });
166
167 // Begin the updates but don't flush them yet
168 await waitFor(['a', 'b', 'c']);
169 expect(ReactNoop).toMatchRenderedOutput(<span prop="" />);
170
171 // Schedule some more updates at different priorities
172 instance.setState(createUpdate('d'));
173 ReactNoop.flushSync(() => {
174 instance.setState(createUpdate('e'));
175 instance.setState(createUpdate('f'));
176 });
177 React.startTransition(() => {
178 instance.setState(createUpdate('g'));
179 });
180
181 // The sync updates should have flushed, but not the async ones.
182 assertLog(['d', 'e', 'f']);
183 expect(ReactNoop).toMatchRenderedOutput(<span prop="def" />);
184
185 // Now flush the remaining work. Even though e and f were already processed,
186 // they should be processed again, to ensure that the terminal state
187 // is deterministic.
188 await waitForAll([
189 // Then we'll re-process everything for 'g'.
190 'a',
191 'b',
192 'c',
193 'd',
194 'e',
195 'f',
196 'g',
197 ]);
198 expect(ReactNoop).toMatchRenderedOutput(<span prop="abcdefg" />);
199 });
200
201 it('can abort an update, schedule a replaceState, and resume', async () => {
202 let instance;
203 class Foo extends React.Component {
204 state = {};
205 render() {
206 instance = this;
207 return <span prop={Object.keys(this.state).sort().join('')} />;
208 }
209 }
210
211 ReactNoop.render(<Foo />);
212 await waitForAll([]);
213
214 function createUpdate(letter) {
215 return () => {
216 Scheduler.log(letter);
217 return {
218 [letter]: letter,
219 };
220 };
221 }
222
223 // Schedule some async updates
224 React.startTransition(() => {
225 instance.setState(createUpdate('a'));
226 instance.setState(createUpdate('b'));
227 instance.setState(createUpdate('c'));
228 });
229
230 // Begin the updates but don't flush them yet
231 await waitFor(['a', 'b', 'c']);
232 expect(ReactNoop).toMatchRenderedOutput(<span prop="" />);
233
234 // Schedule some more updates at different priorities
235 instance.setState(createUpdate('d'));
236
237 ReactNoop.flushSync(() => {
238 instance.setState(createUpdate('e'));
239 // No longer a public API, but we can test that it works internally by
240 // reaching into the updater.
241 instance.updater.enqueueReplaceState(instance, createUpdate('f'));
242 });
243 React.startTransition(() => {
244 instance.setState(createUpdate('g'));
245 });
246
247 // The sync updates should have flushed, but not the async ones.
248 assertLog(['d', 'e', 'f']);
249 expect(ReactNoop).toMatchRenderedOutput(<span prop="f" />);
250
251 // Now flush the remaining work. Even though e and f were already processed,
252 // they should be processed again, to ensure that the terminal state
253 // is deterministic.
254 await waitForAll([
255 // Then we'll re-process everything for 'g'.
256 'a',
257 'b',
258 'c',
259 'd',
260 'e',
261 'f',
262 'g',
263 ]);
264 expect(ReactNoop).toMatchRenderedOutput(<span prop="fg" />);
265 });
266
267 it('passes accumulation of previous updates to replaceState updater function', async () => {
268 let instance;
269 class Foo extends React.Component {
270 state = {};
271 render() {
272 instance = this;
273 return <span />;
274 }
275 }
276 ReactNoop.render(<Foo />);
277 await waitForAll([]);
278
279 instance.setState({a: 'a'});
280 instance.setState({b: 'b'});
281 // No longer a public API, but we can test that it works internally by
282 // reaching into the updater.
283 instance.updater.enqueueReplaceState(instance, previousState => ({
284 previousState,
285 }));
286 await waitForAll([]);
287 expect(instance.state).toEqual({previousState: {a: 'a', b: 'b'}});
288 });
289
290 it('does not call callbacks that are scheduled by another callback until a later commit', async () => {
291 class Foo extends React.Component {
292 state = {};
293 componentDidMount() {
294 Scheduler.log('did mount');
295 this.setState({a: 'a'}, () => {
296 Scheduler.log('callback a');
297 this.setState({b: 'b'}, () => {
298 Scheduler.log('callback b');
299 });
300 });
301 }
302 render() {
303 Scheduler.log('render');
304 return <div />;
305 }
306 }
307
308 ReactNoop.render(<Foo />);
309 await waitForAll([
310 'render',
311 'did mount',
312 'render',
313 'callback a',
314 'render',
315 'callback b',
316 ]);
317 });
318
319 it('gives setState during reconciliation the same priority as whatever level is currently reconciling', async () => {
320 let instance;
321
322 class Foo extends React.Component {
323 state = {};
324 UNSAFE_componentWillReceiveProps() {
325 Scheduler.log('componentWillReceiveProps');
326 this.setState({b: 'b'});
327 }
328 render() {
329 Scheduler.log('render');
330 instance = this;
331 return <div />;
332 }
333 }
334 ReactNoop.render(<Foo />);
335 await waitForAll(['render']);
336
337 ReactNoop.flushSync(() => {
338 instance.setState({a: 'a'});
339
340 ReactNoop.render(<Foo />); // Trigger componentWillReceiveProps
341 });
342
343 expect(instance.state).toEqual({a: 'a', b: 'b'});
344
345 assertLog(['componentWillReceiveProps', 'render']);
346 });
347
348 it('updates triggered from inside a class setState updater', async () => {
349 let instance;
350 class Foo extends React.Component {
351 state = {};
352 render() {
353 Scheduler.log('render');
354 instance = this;
355 return <div />;
356 }
357 }
358
359 ReactNoop.render(<Foo />);
360 await waitForAll([
361 // Initial render
362 'render',
363 ]);
364
365 instance.setState(function a() {
366 Scheduler.log('setState updater');
367 this.setState({b: 'b'});
368 return {a: 'a'};
369 });
370
371 await waitForAll([
372 'setState updater',
373 // Updates in the render phase receive the currently rendering
374 // lane, so the update flushes immediately in the same render.
375 'render',
376 ]);
377 assertConsoleErrorDev([
378 'An update (setState, replaceState, or forceUpdate) was scheduled ' +
379 'from inside an update function. Update functions should be pure, ' +
380 'with zero side-effects. Consider using componentDidUpdate or a ' +
381 'callback.\n' +
382 '\n' +
383 'Please update the following component: Foo\n' +
384 ' in Foo (at **)',
385 ]);
386 expect(instance.state).toEqual({a: 'a', b: 'b'});
387
388 // Test deduplication (no additional warnings expected)
389 instance.setState(function a() {
390 this.setState({a: 'a'});
391 return {b: 'b'};
392 });
393 await waitForAll(
394 gate(flags =>
395 // Updates in the render phase receive the currently rendering
396 // lane, so the update flushes immediately in the same render.
397 ['render'],
398 ),
399 );
400 });
401
402 it('getDerivedStateFromProps should update base state of updateQueue (based on product bug)', () => {
403 // Based on real-world bug.
404
405 let foo;
406 class Foo extends React.Component {
407 state = {value: 'initial state'};
408 static getDerivedStateFromProps() {
409 return {value: 'derived state'};
410 }
411 render() {
412 foo = this;
413 return (
414 <>
415 <span prop={this.state.value} />
416 <Bar />
417 </>
418 );
419 }
420 }
421
422 let bar;
423 class Bar extends React.Component {
424 render() {
425 bar = this;
426 return null;
427 }
428 }
429
430 ReactNoop.flushSync(() => {
431 ReactNoop.render(<Foo />);
432 });
433 expect(ReactNoop).toMatchRenderedOutput(<span prop="derived state" />);
434
435 ReactNoop.flushSync(() => {
436 // Triggers getDerivedStateFromProps again
437 ReactNoop.render(<Foo />);
438 // The noop callback is needed to trigger the specific internal path that
439 // led to this bug. Removing it causes it to "accidentally" work.
440 foo.setState({value: 'update state'}, function noop() {});
441 });
442 expect(ReactNoop).toMatchRenderedOutput(<span prop="derived state" />);
443
444 ReactNoop.flushSync(() => {
445 bar.setState({});
446 });
447 expect(ReactNoop).toMatchRenderedOutput(<span prop="derived state" />);
448 });
449
450 it('regression: does not expire soon due to layout effects in the last batch', async () => {
451 const {useState, useLayoutEffect} = React;
452
453 let setCount;
454 function App() {
455 const [count, _setCount] = useState(0);
456 setCount = _setCount;
457 Scheduler.log('Render: ' + count);
458 useLayoutEffect(() => {
459 setCount(1);
460 Scheduler.log('Commit: ' + count);
461 }, []);
462 return <Text text="Child" />;
463 }
464
465 await act(async () => {
466 React.startTransition(() => {
467 ReactNoop.render(<App />);
468 });
469 assertLog([]);
470 await waitForAll([
471 'Render: 0',
472 'Child',
473 'Commit: 0',
474 'Render: 1',
475 'Child',
476 ]);
477
478 Scheduler.unstable_advanceTime(10000);
479 React.startTransition(() => {
480 setCount(2);
481 });
482 // The transition should not have expired, so we should be able to
483 // partially render it.
484 await waitFor(['Render: 2']);
485 // Now do the rest
486 await waitForAll(['Child']);
487 });
488 });
489
490 it('regression: does not expire soon due to previous flushSync', async () => {
491 ReactNoop.flushSync(() => {
492 ReactNoop.render(<Text text="A" />);
493 });
494 assertLog(['A']);
495
496 Scheduler.unstable_advanceTime(10000);
497
498 React.startTransition(() => {
499 ReactNoop.render(
500 <>
501 <Text text="A" />
502 <Text text="B" />
503 <Text text="C" />
504 <Text text="D" />
505 </>,
506 );
507 });
508 // The transition should not have expired, so we should be able to
509 // partially render it.
510 await waitFor(['A']);
511 await waitFor(['B']);
512 await waitForAll(['C', 'D']);
513 });
514
515 it('regression: does not expire soon due to previous expired work', async () => {
516 React.startTransition(() => {
517 ReactNoop.render(
518 <>
519 <Text text="A" />
520 <Text text="B" />
521 <Text text="C" />
522 <Text text="D" />
523 </>,
524 );
525 });
526
527 await waitFor(['A']);
528 // This will expire the rest of the update
529 Scheduler.unstable_advanceTime(10000);
530 await waitFor(['B'], {
531 additionalLogsAfterAttemptingToYield: ['C', 'D'],
532 });
533
534 Scheduler.unstable_advanceTime(10000);
535
536 // Now do another transition. This one should not expire.
537 React.startTransition(() => {
538 ReactNoop.render(
539 <>
540 <Text text="A" />
541 <Text text="B" />
542 <Text text="C" />
543 <Text text="D" />
544 </>,
545 );
546 });
547
548 // The transition should not have expired, so we should be able to
549 // partially render it.
550 await waitFor(['A']);
551 await waitFor(['B']);
552 await waitForAll(['C', 'D']);
553 });
554
555 it('when rebasing, does not exclude updates that were already committed, regardless of priority', async () => {
556 const {useState, useLayoutEffect} = React;
557
558 let pushToLog;
559 function App() {
560 const [log, setLog] = useState('');
561 pushToLog = msg => {
562 setLog(prevLog => prevLog + msg);
563 };
564
565 useLayoutEffect(() => {
566 Scheduler.log('Committed: ' + log);
567 if (log === 'B') {
568 // Right after B commits, schedule additional updates.
569 ReactNoop.unstable_runWithPriority(ContinuousEventPriority, () =>
570 pushToLog('C'),
571 );
572 setLog(prevLog => prevLog + 'D');
573 }
574 }, [log]);
575
576 return log;
577 }
578
579 const root = ReactNoop.createRoot();
580 await act(() => {
581 root.render(<App />);
582 });
583 assertLog(['Committed: ']);
584 expect(root).toMatchRenderedOutput(null);
585
586 await act(() => {
587 React.startTransition(() => {
588 pushToLog('A');
589 });
590
591 ReactNoop.unstable_runWithPriority(ContinuousEventPriority, () =>
592 pushToLog('B'),
593 );
594 });
595 assertLog(['Committed: B', 'Committed: BCD', 'Committed: ABCD']);
596 expect(root).toMatchRenderedOutput('ABCD');
597 });
598
599 it('when rebasing, does not exclude updates that were already committed, regardless of priority (classes)', async () => {
600 let pushToLog;
601 class App extends React.Component {
602 state = {log: ''};
603 pushToLog = msg => {
604 this.setState(prevState => ({log: prevState.log + msg}));
605 };
606 componentDidUpdate() {
607 Scheduler.log('Committed: ' + this.state.log);
608 if (this.state.log === 'B') {
609 // Right after B commits, schedule additional updates.
610 ReactNoop.unstable_runWithPriority(ContinuousEventPriority, () =>
611 this.pushToLog('C'),
612 );
613 this.pushToLog('D');
614 }
615 }
616 render() {
617 pushToLog = this.pushToLog;
618 return this.state.log;
619 }
620 }
621
622 const root = ReactNoop.createRoot();
623 await act(() => {
624 root.render(<App />);
625 });
626 assertLog([]);
627 expect(root).toMatchRenderedOutput(null);
628
629 await act(() => {
630 React.startTransition(() => {
631 pushToLog('A');
632 });
633 ReactNoop.unstable_runWithPriority(ContinuousEventPriority, () =>
634 pushToLog('B'),
635 );
636 });
637 assertLog(['Committed: B', 'Committed: BCD', 'Committed: ABCD']);
638 expect(root).toMatchRenderedOutput('ABCD');
639 });
640
641 it("base state of update queue is initialized to its fiber's memoized state", async () => {
642 // This test is very weird because it tests an implementation detail but
643 // is tested in terms of public APIs. When it was originally written, the
644 // test failed because the update queue was initialized to the state of
645 // the alternate fiber.
646 let app;
647 class App extends React.Component {
648 state = {prevProp: 'A', count: 0};
649 static getDerivedStateFromProps(props, state) {
650 // Add 100 whenever the label prop changes. The prev label is stored
651 // in state. If the state is dropped incorrectly, we'll fail to detect
652 // prop changes.
653 if (props.prop !== state.prevProp) {
654 return {
655 prevProp: props.prop,
656 count: state.count + 100,
657 };
658 }
659 return null;
660 }
661 render() {
662 app = this;
663 return this.state.count;
664 }
665 }
666
667 const root = ReactNoop.createRoot();
668 await act(() => {
669 root.render(<App prop="A" />);
670 });
671 expect(root).toMatchRenderedOutput('0');
672
673 // Changing the prop causes the count to increase by 100
674 await act(() => {
675 root.render(<App prop="B" />);
676 });
677 expect(root).toMatchRenderedOutput('100');
678
679 // Now increment the count by 1 with a state update. And, in the same
680 // batch, change the prop back to its original value.
681 await act(() => {
682 root.render(<App prop="A" />);
683 app.setState(state => ({count: state.count + 1}));
684 });
685 // There were two total prop changes, plus an increment.
686 expect(root).toMatchRenderedOutput('201');
687 });
688 });