main
js 1,329 lines 36 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 import {useInsertionEffect} from 'react';
14
15 describe('useEffectEvent', () => {
16 let React;
17 let ReactNoop;
18 let Scheduler;
19 let act;
20 let createContext;
21 let useContext;
22 let useState;
23 let useEffectEvent;
24 let useEffect;
25 let useLayoutEffect;
26 let useMemo;
27 let waitForAll;
28 let assertLog;
29 let waitForThrow;
30 let waitFor;
31
32 beforeEach(() => {
33 React = require('react');
34 ReactNoop = require('react-noop-renderer');
35 Scheduler = require('scheduler');
36
37 act = require('internal-test-utils').act;
38 createContext = React.createContext;
39 useContext = React.useContext;
40 useState = React.useState;
41 useEffectEvent = React.useEffectEvent;
42 useEffect = React.useEffect;
43 useLayoutEffect = React.useLayoutEffect;
44 useMemo = React.useMemo;
45
46 const InternalTestUtils = require('internal-test-utils');
47 waitForAll = InternalTestUtils.waitForAll;
48 assertLog = InternalTestUtils.assertLog;
49 waitForThrow = InternalTestUtils.waitForThrow;
50 waitFor = InternalTestUtils.waitFor;
51 });
52
53 function Text(props) {
54 Scheduler.log(props.text);
55 return <span prop={props.text} />;
56 }
57
58 it('memoizes basic case correctly', async () => {
59 class IncrementButton extends React.PureComponent {
60 increment = () => {
61 this.props.onClick();
62 };
63 render() {
64 return <Text text="Increment" />;
65 }
66 }
67
68 function Counter({incrementBy}) {
69 const [count, updateCount] = useState(0);
70 const onClick = useEffectEvent(() => updateCount(c => c + incrementBy));
71
72 return (
73 <>
74 <IncrementButton onClick={() => onClick()} ref={button} />
75 <Text text={'Count: ' + count} />
76 </>
77 );
78 }
79
80 const button = React.createRef(null);
81 ReactNoop.render(<Counter incrementBy={1} />);
82 await waitForAll(['Increment', 'Count: 0']);
83 expect(ReactNoop).toMatchRenderedOutput(
84 <>
85 <span prop="Increment" />
86 <span prop="Count: 0" />
87 </>,
88 );
89
90 await act(() => button.current.increment());
91 assertLog(['Increment', 'Count: 1']);
92 expect(ReactNoop).toMatchRenderedOutput(
93 <>
94 <span prop="Increment" />
95 <span prop="Count: 1" />
96 </>,
97 );
98
99 await act(() => button.current.increment());
100 assertLog([
101 'Increment',
102 // Event should use the updated callback function closed over the new value.
103 'Count: 2',
104 ]);
105 expect(ReactNoop).toMatchRenderedOutput(
106 <>
107 <span prop="Increment" />
108 <span prop="Count: 2" />
109 </>,
110 );
111
112 // Increase the increment prop amount
113 ReactNoop.render(<Counter incrementBy={10} />);
114 await waitForAll(['Increment', 'Count: 2']);
115 expect(ReactNoop).toMatchRenderedOutput(
116 <>
117 <span prop="Increment" />
118 <span prop="Count: 2" />
119 </>,
120 );
121
122 // Event uses the new prop
123 await act(() => button.current.increment());
124 assertLog(['Increment', 'Count: 12']);
125 expect(ReactNoop).toMatchRenderedOutput(
126 <>
127 <span prop="Increment" />
128 <span prop="Count: 12" />
129 </>,
130 );
131 });
132
133 it('can be defined more than once', async () => {
134 class IncrementButton extends React.PureComponent {
135 increment = () => {
136 this.props.onClick();
137 };
138 multiply = () => {
139 this.props.onMouseEnter();
140 };
141 render() {
142 return <Text text="Increment" />;
143 }
144 }
145
146 function Counter({incrementBy}) {
147 const [count, updateCount] = useState(0);
148 const onClick = useEffectEvent(() => updateCount(c => c + incrementBy));
149 const onMouseEnter = useEffectEvent(() => {
150 updateCount(c => c * incrementBy);
151 });
152
153 return (
154 <>
155 <IncrementButton
156 onClick={() => onClick()}
157 onMouseEnter={() => onMouseEnter()}
158 ref={button}
159 />
160 <Text text={'Count: ' + count} />
161 </>
162 );
163 }
164
165 const button = React.createRef(null);
166 ReactNoop.render(<Counter incrementBy={5} />);
167 await waitForAll(['Increment', 'Count: 0']);
168 expect(ReactNoop).toMatchRenderedOutput(
169 <>
170 <span prop="Increment" />
171 <span prop="Count: 0" />
172 </>,
173 );
174
175 await act(() => button.current.increment());
176 assertLog(['Increment', 'Count: 5']);
177 expect(ReactNoop).toMatchRenderedOutput(
178 <>
179 <span prop="Increment" />
180 <span prop="Count: 5" />
181 </>,
182 );
183
184 await act(() => button.current.multiply());
185 assertLog(['Increment', 'Count: 25']);
186 expect(ReactNoop).toMatchRenderedOutput(
187 <>
188 <span prop="Increment" />
189 <span prop="Count: 25" />
190 </>,
191 );
192 });
193
194 it('does not preserve `this` in event functions', async () => {
195 class GreetButton extends React.PureComponent {
196 greet = () => {
197 this.props.onClick();
198 };
199 render() {
200 return <Text text={'Say ' + this.props.hello} />;
201 }
202 }
203 function Greeter({hello}) {
204 const person = {
205 toString() {
206 return 'Jane';
207 },
208 greet() {
209 return updateGreeting(this + ' says ' + hello);
210 },
211 };
212 const [greeting, updateGreeting] = useState('Seb says ' + hello);
213 const onClick = useEffectEvent(person.greet);
214
215 return (
216 <>
217 <GreetButton hello={hello} onClick={() => onClick()} ref={button} />
218 <Text text={'Greeting: ' + greeting} />
219 </>
220 );
221 }
222
223 const button = React.createRef(null);
224 ReactNoop.render(<Greeter hello={'hej'} />);
225 await waitForAll(['Say hej', 'Greeting: Seb says hej']);
226 expect(ReactNoop).toMatchRenderedOutput(
227 <>
228 <span prop="Say hej" />
229 <span prop="Greeting: Seb says hej" />
230 </>,
231 );
232
233 await act(() => button.current.greet());
234 assertLog(['Say hej', 'Greeting: undefined says hej']);
235 expect(ReactNoop).toMatchRenderedOutput(
236 <>
237 <span prop="Say hej" />
238 <span prop="Greeting: undefined says hej" />
239 </>,
240 );
241 });
242
243 it('throws when called in render', async () => {
244 class IncrementButton extends React.PureComponent {
245 increment = () => {
246 this.props.onClick();
247 };
248
249 render() {
250 // Will throw.
251 this.props.onClick();
252
253 return <Text text="Increment" />;
254 }
255 }
256
257 function Counter({incrementBy}) {
258 const [count, updateCount] = useState(0);
259 const onClick = useEffectEvent(() => updateCount(c => c + incrementBy));
260
261 return (
262 <>
263 <IncrementButton onClick={() => onClick()} />
264 <Text text={'Count: ' + count} />
265 </>
266 );
267 }
268
269 ReactNoop.render(<Counter incrementBy={1} />);
270 await waitForThrow(
271 "A function wrapped in useEffectEvent can't be called during rendering.",
272 );
273 assertLog([]);
274 });
275
276 it("useLayoutEffect shouldn't re-fire when event handlers change", async () => {
277 class IncrementButton extends React.PureComponent {
278 increment = () => {
279 this.props.onClick();
280 };
281 render() {
282 return <Text text="Increment" />;
283 }
284 }
285
286 function Counter({incrementBy}) {
287 const [count, updateCount] = useState(0);
288 const increment = useEffectEvent(amount =>
289 updateCount(c => c + (amount || incrementBy)),
290 );
291
292 useLayoutEffect(() => {
293 Scheduler.log('Effect: by ' + incrementBy * 2);
294 increment(incrementBy * 2);
295 }, [incrementBy]);
296
297 return (
298 <>
299 <IncrementButton onClick={() => increment()} ref={button} />
300 <Text text={'Count: ' + count} />
301 </>
302 );
303 }
304
305 const button = React.createRef(null);
306 ReactNoop.render(<Counter incrementBy={1} />);
307 assertLog([]);
308 await waitForAll([
309 'Increment',
310 'Count: 0',
311 'Effect: by 2',
312 'Increment',
313 'Count: 2',
314 ]);
315 expect(ReactNoop).toMatchRenderedOutput(
316 <>
317 <span prop="Increment" />
318 <span prop="Count: 2" />
319 </>,
320 );
321
322 await act(() => button.current.increment());
323 assertLog([
324 'Increment',
325 // Effect should not re-run because the dependency hasn't changed.
326 'Count: 3',
327 ]);
328 expect(ReactNoop).toMatchRenderedOutput(
329 <>
330 <span prop="Increment" />
331 <span prop="Count: 3" />
332 </>,
333 );
334
335 await act(() => button.current.increment());
336 assertLog([
337 'Increment',
338 // Event should use the updated callback function closed over the new value.
339 'Count: 4',
340 ]);
341 expect(ReactNoop).toMatchRenderedOutput(
342 <>
343 <span prop="Increment" />
344 <span prop="Count: 4" />
345 </>,
346 );
347
348 // Increase the increment prop amount
349 ReactNoop.render(<Counter incrementBy={10} />);
350 await waitForAll([
351 'Increment',
352 'Count: 4',
353 'Effect: by 20',
354 'Increment',
355 'Count: 24',
356 ]);
357 expect(ReactNoop).toMatchRenderedOutput(
358 <>
359 <span prop="Increment" />
360 <span prop="Count: 24" />
361 </>,
362 );
363
364 // Event uses the new prop
365 await act(() => button.current.increment());
366 assertLog(['Increment', 'Count: 34']);
367 expect(ReactNoop).toMatchRenderedOutput(
368 <>
369 <span prop="Increment" />
370 <span prop="Count: 34" />
371 </>,
372 );
373 });
374
375 it("useEffect shouldn't re-fire when event handlers change", async () => {
376 class IncrementButton extends React.PureComponent {
377 increment = () => {
378 this.props.onClick();
379 };
380 render() {
381 return <Text text="Increment" />;
382 }
383 }
384
385 function Counter({incrementBy}) {
386 const [count, updateCount] = useState(0);
387 const increment = useEffectEvent(amount =>
388 updateCount(c => c + (amount || incrementBy)),
389 );
390
391 useEffect(() => {
392 Scheduler.log('Effect: by ' + incrementBy * 2);
393 increment(incrementBy * 2);
394 }, [incrementBy]);
395
396 return (
397 <>
398 <IncrementButton onClick={() => increment()} ref={button} />
399 <Text text={'Count: ' + count} />
400 </>
401 );
402 }
403
404 const button = React.createRef(null);
405 ReactNoop.render(<Counter incrementBy={1} />);
406 await waitForAll([
407 'Increment',
408 'Count: 0',
409 'Effect: by 2',
410 'Increment',
411 'Count: 2',
412 ]);
413 expect(ReactNoop).toMatchRenderedOutput(
414 <>
415 <span prop="Increment" />
416 <span prop="Count: 2" />
417 </>,
418 );
419
420 await act(() => button.current.increment());
421 assertLog([
422 'Increment',
423 // Effect should not re-run because the dependency hasn't changed.
424 'Count: 3',
425 ]);
426 expect(ReactNoop).toMatchRenderedOutput(
427 <>
428 <span prop="Increment" />
429 <span prop="Count: 3" />
430 </>,
431 );
432
433 await act(() => button.current.increment());
434 assertLog([
435 'Increment',
436 // Event should use the updated callback function closed over the new value.
437 'Count: 4',
438 ]);
439 expect(ReactNoop).toMatchRenderedOutput(
440 <>
441 <span prop="Increment" />
442 <span prop="Count: 4" />
443 </>,
444 );
445
446 // Increase the increment prop amount
447 ReactNoop.render(<Counter incrementBy={10} />);
448 await waitForAll([
449 'Increment',
450 'Count: 4',
451 'Effect: by 20',
452 'Increment',
453 'Count: 24',
454 ]);
455 expect(ReactNoop).toMatchRenderedOutput(
456 <>
457 <span prop="Increment" />
458 <span prop="Count: 24" />
459 </>,
460 );
461
462 // Event uses the new prop
463 await act(() => button.current.increment());
464 assertLog(['Increment', 'Count: 34']);
465 expect(ReactNoop).toMatchRenderedOutput(
466 <>
467 <span prop="Increment" />
468 <span prop="Count: 34" />
469 </>,
470 );
471 });
472
473 it('is stable in a custom hook', async () => {
474 class IncrementButton extends React.PureComponent {
475 increment = () => {
476 this.props.onClick();
477 };
478 render() {
479 return <Text text="Increment" />;
480 }
481 }
482
483 function useCount(incrementBy) {
484 const [count, updateCount] = useState(0);
485 const increment = useEffectEvent(amount =>
486 updateCount(c => c + (amount || incrementBy)),
487 );
488
489 return [count, increment];
490 }
491
492 function Counter({incrementBy}) {
493 const [count, increment] = useCount(incrementBy);
494
495 useEffect(() => {
496 Scheduler.log('Effect: by ' + incrementBy * 2);
497 increment(incrementBy * 2);
498 }, [incrementBy]);
499
500 return (
501 <>
502 <IncrementButton onClick={() => increment()} ref={button} />
503 <Text text={'Count: ' + count} />
504 </>
505 );
506 }
507
508 const button = React.createRef(null);
509 ReactNoop.render(<Counter incrementBy={1} />);
510 await waitForAll([
511 'Increment',
512 'Count: 0',
513 'Effect: by 2',
514 'Increment',
515 'Count: 2',
516 ]);
517 expect(ReactNoop).toMatchRenderedOutput(
518 <>
519 <span prop="Increment" />
520 <span prop="Count: 2" />
521 </>,
522 );
523
524 await act(() => button.current.increment());
525 assertLog([
526 'Increment',
527 // Effect should not re-run because the dependency hasn't changed.
528 'Count: 3',
529 ]);
530 expect(ReactNoop).toMatchRenderedOutput(
531 <>
532 <span prop="Increment" />
533 <span prop="Count: 3" />
534 </>,
535 );
536
537 await act(() => button.current.increment());
538 assertLog([
539 'Increment',
540 // Event should use the updated callback function closed over the new value.
541 'Count: 4',
542 ]);
543 expect(ReactNoop).toMatchRenderedOutput(
544 <>
545 <span prop="Increment" />
546 <span prop="Count: 4" />
547 </>,
548 );
549
550 // Increase the increment prop amount
551 ReactNoop.render(<Counter incrementBy={10} />);
552 await waitForAll([
553 'Increment',
554 'Count: 4',
555 'Effect: by 20',
556 'Increment',
557 'Count: 24',
558 ]);
559 expect(ReactNoop).toMatchRenderedOutput(
560 <>
561 <span prop="Increment" />
562 <span prop="Count: 24" />
563 </>,
564 );
565
566 // Event uses the new prop
567 await act(() => button.current.increment());
568 assertLog(['Increment', 'Count: 34']);
569 expect(ReactNoop).toMatchRenderedOutput(
570 <>
571 <span prop="Increment" />
572 <span prop="Count: 34" />
573 </>,
574 );
575 });
576
577 it('is mutated before all other effects', async () => {
578 function Counter({value}) {
579 useInsertionEffect(() => {
580 Scheduler.log('Effect value: ' + value);
581 increment();
582 }, [value]);
583
584 // This is defined after the insertion effect, but it should
585 // update the event fn _before_ the insertion effect fires.
586 const increment = useEffectEvent(() => {
587 Scheduler.log('Event value: ' + value);
588 });
589
590 return <></>;
591 }
592
593 ReactNoop.render(<Counter value={1} />);
594 await waitForAll(['Effect value: 1', 'Event value: 1']);
595
596 await act(() => ReactNoop.render(<Counter value={2} />));
597 assertLog(['Effect value: 2', 'Event value: 2']);
598 });
599
600 it('fires all (interleaved) effects with useEffectEvent in correct order', async () => {
601 function CounterA({count}) {
602 const onEvent = useEffectEvent(() => {
603 return `A ${count}`;
604 });
605
606 useInsertionEffect(() => {
607 // Call the event function to verify it sees the latest value
608 Scheduler.log(`Parent Insertion Create: ${onEvent()}`);
609 return () => {
610 Scheduler.log(`Parent Insertion Create: ${onEvent()}`);
611 };
612 });
613
614 useLayoutEffect(() => {
615 Scheduler.log(`Parent Layout Create: ${onEvent()}`);
616 return () => {
617 Scheduler.log(`Parent Layout Cleanup: ${onEvent()}`);
618 };
619 });
620
621 useEffect(() => {
622 Scheduler.log(`Parent Passive Create: ${onEvent()}`);
623 return () => {
624 Scheduler.log(`Parent Passive Destroy ${onEvent()}`);
625 };
626 });
627
628 // this breaks the rules, but ensures the ordering is correct.
629 return <CounterB count={count} onEventParent={onEvent} />;
630 }
631
632 function CounterB({count, onEventParent}) {
633 const onEvent = useEffectEvent(() => {
634 return `${onEventParent()} B ${count}`;
635 });
636
637 useInsertionEffect(() => {
638 Scheduler.log(`Child Insertion Create ${onEvent()}`);
639 return () => {
640 Scheduler.log(`Child Insertion Destroy ${onEvent()}`);
641 };
642 });
643
644 useLayoutEffect(() => {
645 Scheduler.log(`Child Layout Create ${onEvent()}`);
646 return () => {
647 Scheduler.log(`Child Layout Destroy ${onEvent()}`);
648 };
649 });
650
651 useEffect(() => {
652 Scheduler.log(`Child Passive Create ${onEvent()}`);
653 return () => {
654 Scheduler.log(`Child Passive Destroy ${onEvent()}`);
655 };
656 });
657
658 return null;
659 }
660
661 await act(async () => {
662 ReactNoop.render(<CounterA count={1} />);
663 });
664
665 assertLog([
666 'Child Insertion Create A 1 B 1',
667 'Parent Insertion Create: A 1',
668 'Child Layout Create A 1 B 1',
669 'Parent Layout Create: A 1',
670 'Child Passive Create A 1 B 1',
671 'Parent Passive Create: A 1',
672 ]);
673
674 await act(async () => {
675 ReactNoop.render(<CounterA count={2} />);
676 });
677
678 assertLog([
679 'Child Insertion Destroy A 2 B 2',
680 'Child Insertion Create A 2 B 2',
681 'Child Layout Destroy A 2 B 2',
682 'Parent Insertion Create: A 2',
683 'Parent Insertion Create: A 2',
684 'Parent Layout Cleanup: A 2',
685 'Child Layout Create A 2 B 2',
686 'Parent Layout Create: A 2',
687 'Child Passive Destroy A 2 B 2',
688 'Parent Passive Destroy A 2',
689 'Child Passive Create A 2 B 2',
690 'Parent Passive Create: A 2',
691 ]);
692
693 // Unmount everything
694 await act(async () => {
695 ReactNoop.render(null);
696 });
697
698 assertLog([
699 'Parent Insertion Create: A 2',
700 'Parent Layout Cleanup: A 2',
701 'Child Insertion Destroy A 2 B 2',
702 'Child Layout Destroy A 2 B 2',
703 'Parent Passive Destroy A 2',
704 'Child Passive Destroy A 2 B 2',
705 ]);
706 });
707
708 it('correctly mutates effect event with Activity', async () => {
709 let setState;
710 let setChildState;
711 function CounterA({count, hideChild}) {
712 const [state, _setState] = useState(1);
713 setState = _setState;
714 const onEvent = useEffectEvent(() => {
715 return `A ${count} ${state}`;
716 });
717
718 useInsertionEffect(() => {
719 // Call the event function to verify it sees the latest value
720 Scheduler.log(`Parent Insertion Create: ${onEvent()}`);
721 return () => {
722 Scheduler.log(`Parent Insertion Create: ${onEvent()}`);
723 };
724 });
725
726 useLayoutEffect(() => {
727 Scheduler.log(`Parent Layout Create: ${onEvent()}`);
728 return () => {
729 Scheduler.log(`Parent Layout Cleanup: ${onEvent()}`);
730 };
731 });
732
733 // this breaks the rules, but ensures the ordering is correct.
734 return (
735 <React.Activity mode={hideChild ? 'hidden' : 'visible'}>
736 <CounterB count={count} state={state} onEventParent={onEvent} />
737 </React.Activity>
738 );
739 }
740
741 function CounterB({count, state, onEventParent}) {
742 const [childState, _setChildState] = useState(1);
743 setChildState = _setChildState;
744 const onEvent = useEffectEvent(() => {
745 return `${onEventParent()} B ${count} ${state} ${childState}`;
746 });
747
748 useInsertionEffect(() => {
749 Scheduler.log(`Child Insertion Create ${onEvent()}`);
750 return () => {
751 Scheduler.log(`Child Insertion Destroy ${onEvent()}`);
752 };
753 });
754
755 useLayoutEffect(() => {
756 Scheduler.log(`Child Layout Create ${onEvent()}`);
757 return () => {
758 Scheduler.log(`Child Layout Destroy ${onEvent()}`);
759 };
760 });
761
762 useEffect(() => {
763 Scheduler.log(`Child Passive Create ${onEvent()}`);
764 return () => {
765 Scheduler.log(`Child Passive Destroy ${onEvent()}`);
766 };
767 });
768
769 return null;
770 }
771
772 await act(async () => {
773 ReactNoop.render(<CounterA count={1} hideChild={true} />);
774 await waitFor([
775 'Parent Insertion Create: A 1 1',
776 'Parent Layout Create: A 1 1',
777 'Child Insertion Create A 1 1 B 1 1 1',
778 ]);
779 });
780
781 assertLog([]);
782
783 await act(async () => {
784 ReactNoop.render(<CounterA count={2} hideChild={true} />);
785
786 await waitFor([
787 'Parent Insertion Create: A 2 1',
788 'Parent Insertion Create: A 2 1',
789 'Parent Layout Cleanup: A 2 1',
790 'Parent Layout Create: A 2 1',
791 ...(gate('enableViewTransition') &&
792 !gate('enableEffectEventMutationPhase')
793 ? [
794 'Child Insertion Destroy A 2 1 B 1 1 1',
795 'Child Insertion Create A 2 1 B 1 1 1',
796 ]
797 : [
798 'Child Insertion Destroy A 2 1 B 2 1 1',
799 'Child Insertion Create A 2 1 B 2 1 1',
800 ]),
801 ]);
802 });
803
804 assertLog([]);
805
806 await act(async () => {
807 setState(2);
808
809 await waitFor([
810 'Parent Insertion Create: A 2 2',
811 'Parent Insertion Create: A 2 2',
812 'Parent Layout Cleanup: A 2 2',
813 'Parent Layout Create: A 2 2',
814 ...(gate('enableViewTransition') &&
815 !gate('enableEffectEventMutationPhase')
816 ? [
817 'Child Insertion Destroy A 2 2 B 1 1 1',
818 'Child Insertion Create A 2 2 B 1 1 1',
819 ]
820 : [
821 'Child Insertion Destroy A 2 2 B 2 2 1',
822 'Child Insertion Create A 2 2 B 2 2 1',
823 ]),
824 ]);
825 });
826
827 assertLog([]);
828
829 await act(async () => {
830 setChildState(2);
831
832 await waitFor(
833 gate('enableViewTransition') && !gate('enableEffectEventMutationPhase')
834 ? [
835 'Child Insertion Destroy A 2 2 B 1 1 1',
836 'Child Insertion Create A 2 2 B 1 1 1',
837 ]
838 : [
839 'Child Insertion Destroy A 2 2 B 2 2 2',
840 'Child Insertion Create A 2 2 B 2 2 2',
841 ],
842 );
843 });
844
845 assertLog([]);
846
847 await act(async () => {
848 ReactNoop.render(<CounterA count={3} hideChild={true} />);
849
850 await waitFor([
851 'Parent Insertion Create: A 3 2',
852 'Parent Insertion Create: A 3 2',
853 'Parent Layout Cleanup: A 3 2',
854 'Parent Layout Create: A 3 2',
855 ]);
856 });
857
858 assertLog(
859 gate('enableViewTransition') && !gate('enableEffectEventMutationPhase')
860 ? [
861 'Child Insertion Destroy A 3 2 B 1 1 1',
862 'Child Insertion Create A 3 2 B 1 1 1',
863 ]
864 : [
865 'Child Insertion Destroy A 3 2 B 3 2 2',
866 'Child Insertion Create A 3 2 B 3 2 2',
867 ],
868 );
869
870 await act(async () => {
871 ReactNoop.render(<CounterA count={3} hideChild={false} />);
872
873 await waitFor([
874 ...(gate('enableViewTransition') &&
875 !gate('enableEffectEventMutationPhase')
876 ? [
877 'Child Insertion Destroy A 3 2 B 1 1 1',
878 'Child Insertion Create A 3 2 B 1 1 1',
879 ]
880 : [
881 'Child Insertion Destroy A 3 2 B 3 2 2',
882 'Child Insertion Create A 3 2 B 3 2 2',
883 ]),
884 'Parent Insertion Create: A 3 2',
885 'Parent Insertion Create: A 3 2',
886 'Parent Layout Cleanup: A 3 2',
887 ...(gate('enableViewTransition') &&
888 !gate('enableEffectEventMutationPhase')
889 ? ['Child Layout Create A 3 2 B 1 1 1']
890 : ['Child Layout Create A 3 2 B 3 2 2']),
891
892 'Parent Layout Create: A 3 2',
893 ]);
894 });
895
896 assertLog(
897 gate('enableViewTransition') && !gate('enableEffectEventMutationPhase')
898 ? ['Child Passive Create A 3 2 B 1 1 1']
899 : ['Child Passive Create A 3 2 B 3 2 2'],
900 );
901
902 await act(async () => {
903 ReactNoop.render(<CounterA count={3} hideChild={true} />);
904
905 await waitFor([
906 ...(gate('enableViewTransition') &&
907 !gate('enableEffectEventMutationPhase')
908 ? ['Child Layout Destroy A 3 2 B 1 1 1']
909 : ['Child Layout Destroy A 3 2 B 3 2 2']),
910 'Parent Insertion Create: A 3 2',
911 'Parent Insertion Create: A 3 2',
912 'Parent Layout Cleanup: A 3 2',
913 'Parent Layout Create: A 3 2',
914 ...(gate('enableViewTransition') &&
915 !gate('enableEffectEventMutationPhase')
916 ? ['Child Passive Destroy A 3 2 B 1 1 1']
917 : ['Child Passive Destroy A 3 2 B 3 2 2']),
918 ]);
919 });
920
921 assertLog(
922 gate('enableViewTransition') && !gate('enableEffectEventMutationPhase')
923 ? [
924 'Child Insertion Destroy A 3 2 B 1 1 1',
925 'Child Insertion Create A 3 2 B 1 1 1',
926 ]
927 : [
928 'Child Insertion Destroy A 3 2 B 3 2 2',
929 'Child Insertion Create A 3 2 B 3 2 2',
930 ],
931 );
932
933 // Unmount everything
934 await act(async () => {
935 ReactNoop.render(null);
936 });
937
938 assertLog([
939 'Parent Insertion Create: A 3 2',
940 'Parent Layout Cleanup: A 3 2',
941 gate('enableViewTransition') && !gate('enableEffectEventMutationPhase')
942 ? 'Child Insertion Destroy A 3 2 B 1 1 1'
943 : 'Child Insertion Destroy A 3 2 B 3 2 2',
944 ]);
945 });
946
947 it("doesn't provide a stable identity", async () => {
948 function Counter({shouldRender, value}) {
949 const onClick = useEffectEvent(() => {
950 Scheduler.log(
951 'onClick, shouldRender=' + shouldRender + ', value=' + value,
952 );
953 });
954
955 // onClick doesn't have a stable function identity so this effect will fire on every render.
956 // In a real app useEffectEvent functions should *not* be passed as a dependency, this is for
957 // testing purposes only.
958 useEffect(() => {
959 onClick();
960 }, [onClick]);
961
962 useEffect(() => {
963 onClick();
964 }, [shouldRender]);
965
966 return <></>;
967 }
968
969 ReactNoop.render(<Counter shouldRender={true} value={0} />);
970 await waitForAll([
971 'onClick, shouldRender=true, value=0',
972 'onClick, shouldRender=true, value=0',
973 ]);
974
975 ReactNoop.render(<Counter shouldRender={true} value={1} />);
976 await waitForAll(['onClick, shouldRender=true, value=1']);
977
978 ReactNoop.render(<Counter shouldRender={false} value={2} />);
979 await waitForAll([
980 'onClick, shouldRender=false, value=2',
981 'onClick, shouldRender=false, value=2',
982 ]);
983 });
984
985 it('event handlers always see the latest committed value', async () => {
986 let committedEventHandler = null;
987
988 function App({value}) {
989 const event = useEffectEvent(() => {
990 return 'Value seen by useEffectEvent: ' + value;
991 });
992
993 // Set up an effect that registers the event handler with an external
994 // event system (e.g. addEventListener).
995 useEffect(
996 () => {
997 // Log when the effect fires. In the test below, we'll assert that this
998 // only happens during initial render, not during updates.
999 Scheduler.log('Commit new event handler');
1000 committedEventHandler = event;
1001 return () => {
1002 committedEventHandler = null;
1003 };
1004 },
1005 // Note that we've intentionally omitted the event from the dependency
1006 // array. But it will still be able to see the latest `value`. This is the
1007 // key feature of useEffectEvent that makes it different from a regular closure.
1008 [],
1009 );
1010 return 'Latest rendered value ' + value;
1011 }
1012
1013 // Initial render
1014 const root = ReactNoop.createRoot();
1015 await act(() => {
1016 root.render(<App value={1} />);
1017 });
1018 assertLog(['Commit new event handler']);
1019 expect(root).toMatchRenderedOutput('Latest rendered value 1');
1020 expect(committedEventHandler()).toBe('Value seen by useEffectEvent: 1');
1021
1022 // Update
1023 await act(() => {
1024 root.render(<App value={2} />);
1025 });
1026 // No new event handler should be committed, because it was omitted from
1027 // the dependency array.
1028 assertLog([]);
1029 // But the event handler should still be able to see the latest value.
1030 expect(root).toMatchRenderedOutput('Latest rendered value 2');
1031 expect(committedEventHandler()).toBe('Value seen by useEffectEvent: 2');
1032 });
1033
1034 it('integration: implements docs chat room example', async () => {
1035 function createConnection() {
1036 let connectedCallback;
1037 let timeout;
1038 return {
1039 connect() {
1040 timeout = setTimeout(() => {
1041 if (connectedCallback) {
1042 connectedCallback();
1043 }
1044 }, 100);
1045 },
1046 on(event, callback) {
1047 if (connectedCallback) {
1048 throw Error('Cannot add the handler twice.');
1049 }
1050 if (event !== 'connected') {
1051 throw Error('Only "connected" event is supported.');
1052 }
1053 connectedCallback = callback;
1054 },
1055 disconnect() {
1056 clearTimeout(timeout);
1057 },
1058 };
1059 }
1060
1061 function ChatRoom({roomId, theme}) {
1062 const onConnected = useEffectEvent(() => {
1063 Scheduler.log('Connected! theme: ' + theme);
1064 });
1065
1066 useEffect(() => {
1067 const connection = createConnection(roomId);
1068 connection.on('connected', () => {
1069 onConnected();
1070 });
1071 connection.connect();
1072 return () => connection.disconnect();
1073 }, [roomId]);
1074
1075 return <Text text={`Welcome to the ${roomId} room!`} />;
1076 }
1077
1078 await act(() =>
1079 ReactNoop.render(<ChatRoom roomId="general" theme="light" />),
1080 );
1081
1082 assertLog(['Welcome to the general room!', 'Connected! theme: light']);
1083 expect(ReactNoop).toMatchRenderedOutput(
1084 <span prop="Welcome to the general room!" />,
1085 );
1086
1087 // change roomId only
1088 await act(() =>
1089 ReactNoop.render(<ChatRoom roomId="music" theme="light" />),
1090 );
1091 assertLog([
1092 'Welcome to the music room!',
1093 // should trigger a reconnect
1094 'Connected! theme: light',
1095 ]);
1096 expect(ReactNoop).toMatchRenderedOutput(
1097 <span prop="Welcome to the music room!" />,
1098 );
1099
1100 // change theme only
1101 await act(() => ReactNoop.render(<ChatRoom roomId="music" theme="dark" />));
1102 // should not trigger a reconnect
1103 assertLog(['Welcome to the music room!']);
1104 expect(ReactNoop).toMatchRenderedOutput(
1105 <span prop="Welcome to the music room!" />,
1106 );
1107
1108 // change roomId only
1109 await act(() =>
1110 ReactNoop.render(<ChatRoom roomId="travel" theme="dark" />),
1111 );
1112 assertLog([
1113 'Welcome to the travel room!',
1114 // should trigger a reconnect
1115 'Connected! theme: dark',
1116 ]);
1117 expect(ReactNoop).toMatchRenderedOutput(
1118 <span prop="Welcome to the travel room!" />,
1119 );
1120 });
1121
1122 it('integration: implements the docs logVisit example', async () => {
1123 class AddToCartButton extends React.PureComponent {
1124 addToCart = () => {
1125 this.props.onClick();
1126 };
1127 render() {
1128 return <Text text="Add to cart" />;
1129 }
1130 }
1131 const ShoppingCartContext = createContext(null);
1132
1133 function AppShell({children}) {
1134 const [items, updateItems] = useState([]);
1135 const value = useMemo(() => ({items, updateItems}), [items, updateItems]);
1136
1137 return (
1138 <ShoppingCartContext.Provider value={value}>
1139 {children}
1140 </ShoppingCartContext.Provider>
1141 );
1142 }
1143
1144 function Page({url}) {
1145 const {items, updateItems} = useContext(ShoppingCartContext);
1146 const onClick = useEffectEvent(() => updateItems([...items, 1]));
1147 const numberOfItems = items.length;
1148
1149 const onVisit = useEffectEvent(visitedUrl => {
1150 Scheduler.log(
1151 'url: ' + visitedUrl + ', numberOfItems: ' + numberOfItems,
1152 );
1153 });
1154
1155 useEffect(() => {
1156 onVisit(url);
1157 }, [url]);
1158
1159 return (
1160 <AddToCartButton
1161 onClick={() => {
1162 onClick();
1163 }}
1164 ref={button}
1165 />
1166 );
1167 }
1168
1169 const button = React.createRef(null);
1170 await act(() =>
1171 ReactNoop.render(
1172 <AppShell>
1173 <Page url="/shop/1" />
1174 </AppShell>,
1175 ),
1176 );
1177 assertLog(['Add to cart', 'url: /shop/1, numberOfItems: 0']);
1178 await act(() => button.current.addToCart());
1179 assertLog(['Add to cart']);
1180
1181 await act(() =>
1182 ReactNoop.render(
1183 <AppShell>
1184 <Page url="/shop/2" />
1185 </AppShell>,
1186 ),
1187 );
1188 assertLog(['Add to cart', 'url: /shop/2, numberOfItems: 1']);
1189 });
1190
1191 it('reads the latest context value in memo Components', async () => {
1192 const MyContext = createContext('default');
1193
1194 let logContextValue;
1195 const ContextReader = React.memo(function ContextReader() {
1196 const value = useContext(MyContext);
1197 Scheduler.log('ContextReader: ' + value);
1198 const fireLogContextValue = useEffectEvent(() => {
1199 Scheduler.log('ContextReader (Effect event): ' + value);
1200 });
1201 useEffect(() => {
1202 logContextValue = fireLogContextValue;
1203 }, []);
1204 return null;
1205 });
1206
1207 function App({value}) {
1208 return (
1209 <MyContext.Provider value={value}>
1210 <ContextReader />
1211 </MyContext.Provider>
1212 );
1213 }
1214
1215 const root = ReactNoop.createRoot();
1216 await act(() => root.render(<App value="first" />));
1217 assertLog(['ContextReader: first']);
1218
1219 logContextValue();
1220
1221 assertLog(['ContextReader (Effect event): first']);
1222
1223 await act(() => root.render(<App value="second" />));
1224 assertLog(['ContextReader: second']);
1225
1226 logContextValue();
1227 assertLog(['ContextReader (Effect event): second']);
1228 });
1229
1230 it('reads the latest context value in forwardRef Components', async () => {
1231 const MyContext = createContext('default');
1232
1233 let logContextValue;
1234 const ContextReader = React.forwardRef(function ContextReader(props, ref) {
1235 const value = useContext(MyContext);
1236 Scheduler.log('ContextReader: ' + value);
1237 const fireLogContextValue = useEffectEvent(() => {
1238 Scheduler.log('ContextReader (Effect event): ' + value);
1239 });
1240 useEffect(() => {
1241 logContextValue = fireLogContextValue;
1242 }, []);
1243 return null;
1244 });
1245
1246 function App({value}) {
1247 return (
1248 <MyContext.Provider value={value}>
1249 <ContextReader />
1250 </MyContext.Provider>
1251 );
1252 }
1253
1254 const root = ReactNoop.createRoot();
1255 await act(() => root.render(<App value="first" />));
1256 assertLog(['ContextReader: first']);
1257
1258 logContextValue();
1259
1260 assertLog(['ContextReader (Effect event): first']);
1261
1262 await act(() => root.render(<App value="second" />));
1263 assertLog(['ContextReader: second']);
1264
1265 logContextValue();
1266 assertLog(['ContextReader (Effect event): second']);
1267 });
1268
1269 it('effect events are fresh inside Activity', async () => {
1270 function Child({value}) {
1271 const getValue = useEffectEvent(() => {
1272 return value;
1273 });
1274 useInsertionEffect(() => {
1275 Scheduler.log('insertion create: ' + getValue());
1276 return () => {
1277 Scheduler.log('insertion destroy: ' + getValue());
1278 };
1279 });
1280 useLayoutEffect(() => {
1281 Scheduler.log('layout create: ' + getValue());
1282 return () => {
1283 Scheduler.log('layout destroy: ' + getValue());
1284 };
1285 });
1286
1287 Scheduler.log('render: ' + value);
1288 return null;
1289 }
1290
1291 function App({value, mode}) {
1292 return (
1293 <React.Activity mode={mode}>
1294 <Child value={value} />
1295 </React.Activity>
1296 );
1297 }
1298
1299 const root = ReactNoop.createRoot();
1300
1301 // Mount hidden
1302 await act(async () => root.render(<App value={1} mode="hidden" />));
1303 assertLog(['render: 1', 'insertion create: 1']);
1304
1305 // Update, still hidden
1306 await act(async () => root.render(<App value={2} mode="hidden" />));
1307
1308 // Bug in enableViewTransition. Insertion and layout see stale closure.
1309 assertLog([
1310 'render: 2',
1311 ...(gate('enableViewTransition') &&
1312 !gate('enableEffectEventMutationPhase')
1313 ? ['insertion destroy: 1', 'insertion create: 1']
1314 : ['insertion destroy: 2', 'insertion create: 2']),
1315 ]);
1316
1317 // Switch to visible
1318 await act(async () => root.render(<App value={2} mode="visible" />));
1319
1320 // Bug in enableViewTransition. Even when switching to visible, sees stale closure.
1321 assertLog([
1322 'render: 2',
1323 ...(gate('enableViewTransition') &&
1324 !gate('enableEffectEventMutationPhase')
1325 ? ['insertion destroy: 1', 'insertion create: 1', 'layout create: 1']
1326 : ['insertion destroy: 2', 'insertion create: 2', 'layout create: 2']),
1327 ]);
1328 });
1329 });