@samitouri / QOS-React-2 / commits / 3aaab92a26

[flags] add enableEffectEventMutationPhase (#35548)

Small optimization for useEffectEvent. Not sure we even need a flag for it, but it will be a nice killswitch. As an added benefit, it fixes a bug when `enableViewTransition` is on, where we were not updating the useEffectEvent callback when a tree went from hidden to visible.

Ricky committed Feb 4, 2026 at 15:04 UTC 3aaab92a265ebeb43b15e7c30c2f1dfb9fcd5961
12 files changed +458 -6
packages/react-reconciler/src/ReactFiberCommitWork.js
+20 -1
@@ -47,6 +47,7 @@ import type {ViewTransitionState} from './ReactFiberViewTransitionComponent';
47 import {
48 alwaysThrottleRetries,
49 enableCreateEventHandleAPI,
50 + enableEffectEventMutationPhase,
51 enableHiddenSubtreeInsertionEffectCleanup,
52 enableProfilerTimer,
53 enableProfilerCommitHooks,
@@ -499,7 +500,7 @@ function commitBeforeMutationEffectsOnFiber(
500 case FunctionComponent:
501 case ForwardRef:
502 case SimpleMemoComponent: {
502 - if ((flags & Update) !== NoFlags) {
503 + if (!enableEffectEventMutationPhase && (flags & Update) !== NoFlags) {
504 const updateQueue: FunctionComponentUpdateQueue | null =
505 (finishedWork.updateQueue: any);
506 const eventPayloads = updateQueue !== null ? updateQueue.events : null;
@@ -2042,6 +2043,24 @@ function commitMutationEffectsOnFiber(
2043 case ForwardRef:
2044 case MemoComponent:
2045 case SimpleMemoComponent: {
2046 + // Mutate event effect callbacks on the way down, before mutation effects.
2047 + // This ensures that parent event effects are mutated before child effects.
2048 + // This isn't a supported use case, so we can re-consider it,
2049 + // but this was the behavior we originally shipped.
2050 + if (enableEffectEventMutationPhase) {
2051 + if (flags & Update) {
2052 + const updateQueue: FunctionComponentUpdateQueue | null =
2053 + (finishedWork.updateQueue: any);
2054 + const eventPayloads =
2055 + updateQueue !== null ? updateQueue.events : null;
2056 + if (eventPayloads !== null) {
2057 + for (let ii = 0; ii < eventPayloads.length; ii++) {
2058 + const {ref, nextImpl} = eventPayloads[ii];
2059 + ref.impl = nextImpl;
2060 + }
2061 + }
2062 + }
2063 + }
2064 recursivelyTraverseMutationEffects(root, finishedWork, lanes);
2065 commitReconciliationEffects(finishedWork, lanes);
2066
packages/react-reconciler/src/ReactFiberFlags.js
+9 -5
@@ -7,7 +7,10 @@
7 * @flow
8 */
9
10 -import {enableCreateEventHandleAPI} from 'shared/ReactFeatureFlags';
10 +import {
11 + enableCreateEventHandleAPI,
12 + enableEffectEventMutationPhase,
13 +} from 'shared/ReactFeatureFlags';
14
15 export type Flags = number;
16
@@ -99,10 +102,11 @@ export const BeforeMutationMask: number =
102 // TODO: Only need to visit Deletions during BeforeMutation phase if an
103 // element is focused.
104 Update | ChildDeletion | Visibility
102 - : // TODO: The useEffectEvent hook uses the snapshot phase for clean up but it
103 - // really should use the mutation phase for this or at least schedule an
104 - // explicit Snapshot phase flag for this.
105 - Update);
105 + : // useEffectEvent uses the snapshot phase,
106 + // but we're moving it to the mutation phase.
107 + enableEffectEventMutationPhase
108 + ? 0
109 + : Update);
110
111 // For View Transition support we use the snapshot phase to scan the tree for potentially
112 // affected ViewTransition components.
packages/react-reconciler/src/__tests__/useEffectEvent-test.js
+416
@@ -27,6 +27,7 @@ describe('useEffectEvent', () => {
27 let waitForAll;
28 let assertLog;
29 let waitForThrow;
30 + let waitFor;
31
32 beforeEach(() => {
33 React = require('react');
@@ -46,6 +47,7 @@ describe('useEffectEvent', () => {
47 waitForAll = InternalTestUtils.waitForAll;
48 assertLog = InternalTestUtils.assertLog;
49 waitForThrow = InternalTestUtils.waitForThrow;
50 + waitFor = InternalTestUtils.waitFor;
51 });
52
53 function Text(props) {
@@ -595,6 +597,358 @@ describe('useEffectEvent', () => {
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('enableHiddenSubtreeInsertionEffectCleanup')
942 + ? [
943 + gate('enableViewTransition') &&
944 + !gate('enableEffectEventMutationPhase')
945 + ? 'Child Insertion Destroy A 3 2 B 1 1 1'
946 + : 'Child Insertion Destroy A 3 2 B 3 2 2',
947 + ]
948 + : []),
949 + ]);
950 + });
951 +
952 it("doesn't provide a stable identity", async () => {
953 function Counter({shouldRender, value}) {
954 const onClick = useEffectEvent(() => {
@@ -916,4 +1270,66 @@ describe('useEffectEvent', () => {
1270 logContextValue();
1271 assertLog(['ContextReader (Effect event): second']);
1272 });
1273 +
1274 + // @gate enableActivity
1275 + it('effect events are fresh inside Activity', async () => {
1276 + function Child({value}) {
1277 + const getValue = useEffectEvent(() => {
1278 + return value;
1279 + });
1280 + useInsertionEffect(() => {
1281 + Scheduler.log('insertion create: ' + getValue());
1282 + return () => {
1283 + Scheduler.log('insertion destroy: ' + getValue());
1284 + };
1285 + });
1286 + useLayoutEffect(() => {
1287 + Scheduler.log('layout create: ' + getValue());
1288 + return () => {
1289 + Scheduler.log('layout destroy: ' + getValue());
1290 + };
1291 + });
1292 +
1293 + Scheduler.log('render: ' + value);
1294 + return null;
1295 + }
1296 +
1297 + function App({value, mode}) {
1298 + return (
1299 + <React.Activity mode={mode}>
1300 + <Child value={value} />
1301 + </React.Activity>
1302 + );
1303 + }
1304 +
1305 + const root = ReactNoop.createRoot();
1306 +
1307 + // Mount hidden
1308 + await act(async () => root.render(<App value={1} mode="hidden" />));
1309 + assertLog(['render: 1', 'insertion create: 1']);
1310 +
1311 + // Update, still hidden
1312 + await act(async () => root.render(<App value={2} mode="hidden" />));
1313 +
1314 + // Bug in enableViewTransition. Insertion and layout see stale closure.
1315 + assertLog([
1316 + 'render: 2',
1317 + ...(gate('enableViewTransition') &&
1318 + !gate('enableEffectEventMutationPhase')
1319 + ? ['insertion destroy: 1', 'insertion create: 1']
1320 + : ['insertion destroy: 2', 'insertion create: 2']),
1321 + ]);
1322 +
1323 + // Switch to visible
1324 + await act(async () => root.render(<App value={2} mode="visible" />));
1325 +
1326 + // Bug in enableViewTransition. Even when switching to visible, sees stale closure.
1327 + assertLog([
1328 + 'render: 2',
1329 + ...(gate('enableViewTransition') &&
1330 + !gate('enableEffectEventMutationPhase')
1331 + ? ['insertion destroy: 1', 'insertion create: 1', 'layout create: 1']
1332 + : ['insertion destroy: 2', 'insertion create: 2', 'layout create: 2']),
1333 + ]);
1334 + });
1335 });
packages/shared/ReactFeatureFlags.js
+4
@@ -123,6 +123,10 @@ export const enableFizzExternalRuntime = __EXPERIMENTAL__;
123
124 export const alwaysThrottleRetries: boolean = true;
125
126 +// Gate whether useEffectEvent uses the mutation phase (true) or before-mutation
127 +// phase (false) for updating event function references.
128 +export const enableEffectEventMutationPhase: boolean = false;
129 +
130 export const passChildrenWhenCloningPersistedNodes: boolean = false;
131
132 export const enableEagerAlternateStateNodeCleanup: boolean = true;
packages/shared/forks/ReactFeatureFlags.native-fb-dynamic.js
+1
@@ -25,4 +25,5 @@ export const passChildrenWhenCloningPersistedNodes = __VARIANT__;
25 export const enableFragmentRefs = __VARIANT__;
26 export const enableFragmentRefsScrollIntoView = __VARIANT__;
27 export const enableFragmentRefsInstanceHandles = __VARIANT__;
28 +export const enableEffectEventMutationPhase = __VARIANT__;
29 export const enableFragmentRefsTextNodes = __VARIANT__;
packages/shared/forks/ReactFeatureFlags.native-fb.js
+1
@@ -20,6 +20,7 @@ const dynamicFlags: DynamicExportsType = (dynamicFlagsUntyped: any);
20 // the exports object every time a flag is read.
21 export const {
22 alwaysThrottleRetries,
23 + enableEffectEventMutationPhase,
24 enableHiddenSubtreeInsertionEffectCleanup,
25 enableObjectFiber,
26 enableEagerAlternateStateNodeCleanup,
packages/shared/forks/ReactFeatureFlags.native-oss.js
+1
@@ -46,6 +46,7 @@ export const enableSchedulingProfiler: boolean =
46 !enableComponentPerformanceTrack && __PROFILE__;
47 export const enableScopeAPI: boolean = false;
48 export const enableEagerAlternateStateNodeCleanup: boolean = true;
49 +export const enableEffectEventMutationPhase: boolean = false;
50 export const enableSuspenseAvoidThisFallback: boolean = false;
51 export const enableSuspenseCallback: boolean = false;
52 export const enableTaint: boolean = true;
packages/shared/forks/ReactFeatureFlags.test-renderer.js
+1
@@ -56,6 +56,7 @@ export const disableClientCache: boolean = true;
56 export const enableInfiniteRenderLoopDetection: boolean = false;
57
58 export const enableEagerAlternateStateNodeCleanup: boolean = true;
59 +export const enableEffectEventMutationPhase: boolean = false;
60
61 export const enableYieldingBeforePassive: boolean = true;
62
packages/shared/forks/ReactFeatureFlags.test-renderer.native-fb.js
+1
@@ -43,6 +43,7 @@ export const enableComponentPerformanceTrack = false;
43 export const enablePerformanceIssueReporting = false;
44 export const enableScopeAPI = false;
45 export const enableEagerAlternateStateNodeCleanup = true;
46 +export const enableEffectEventMutationPhase = false;
47 export const enableSuspenseAvoidThisFallback = false;
48 export const enableSuspenseCallback = false;
49 export const enableTaint = true;
packages/shared/forks/ReactFeatureFlags.test-renderer.www.js
+1
@@ -62,6 +62,7 @@ export const disableLegacyMode: boolean = true;
62
63 export const enableObjectFiber: boolean = false;
64 export const enableEagerAlternateStateNodeCleanup: boolean = true;
65 +export const enableEffectEventMutationPhase: boolean = false;
66
67 export const enableYieldingBeforePassive: boolean = false;
68
packages/shared/forks/ReactFeatureFlags.www-dynamic.js
+2
@@ -39,6 +39,8 @@ export const enableInternalInstanceMap: boolean = __VARIANT__;
39 export const enableTrustedTypesIntegration: boolean = __VARIANT__;
40 export const enableParallelTransitions: boolean = __VARIANT__;
41
42 +export const enableEffectEventMutationPhase: boolean = __VARIANT__;
43 +
44 // TODO: These flags are hard-coded to the default values used in open source.
45 // Update the tests so that they pass in either mode, then set these
46 // to __VARIANT__.
packages/shared/forks/ReactFeatureFlags.www.js
+1
@@ -18,6 +18,7 @@ export const {
18 alwaysThrottleRetries,
19 disableLegacyContextForFunctionComponents,
20 disableSchedulerTimeoutInWorkLoop,
21 + enableEffectEventMutationPhase,
22 enableHiddenSubtreeInsertionEffectCleanup,
23 enableInfiniteRenderLoopDetection,
24 enableNoCloningMemoCache,