@samitouri / QOS-React-2 / commits / 66388150ef

Remove usereducer eager bailout (#22445)

* Fork dispatchAction for useState/useReducer * Remove eager bailout from forked dispatchReducerAction, update tests * Update eager reducer/state logic to handle state case only * sync reconciler fork * rename test * test cases from #15198 * comments on new test cases * comments on new test cases * test case from #21419 * minor tweak to test name to kick CI

Joseph Savona committed Sep 27, 2021 at 16:25 UTC 66388150ef1dfef1388c634a2d2ce6760a92012f
3 files changed +408 -27
packages/react-reconciler/src/ReactFiberHooks.new.js
+124 -12
@@ -125,7 +125,7 @@ const {ReactCurrentDispatcher, ReactCurrentBatchConfig} = ReactSharedInternals;
125 type Update<S, A> = {|
126 lane: Lane,
127 action: A,
128 - eagerReducer: ((S, A) => S) | null,
128 + hasEagerState: boolean,
129 eagerState: S | null,
130 next: Update<S, A>,
131 |};
@@ -730,7 +730,7 @@ function mountReducer<S, I, A>(
730 lastRenderedState: (initialState: any),
731 };
732 hook.queue = queue;
733 - const dispatch: Dispatch<A> = (queue.dispatch = (dispatchAction.bind(
733 + const dispatch: Dispatch<A> = (queue.dispatch = (dispatchReducerAction.bind(
734 null,
735 currentlyRenderingFiber,
736 queue,
@@ -801,7 +801,7 @@ function updateReducer<S, I, A>(
801 const clone: Update<S, A> = {
802 lane: updateLane,
803 action: update.action,
804 - eagerReducer: update.eagerReducer,
804 + hasEagerState: update.hasEagerState,
805 eagerState: update.eagerState,
806 next: (null: any),
807 };
@@ -829,7 +829,7 @@ function updateReducer<S, I, A>(
829 // this will never be skipped by the check above.
830 lane: NoLane,
831 action: update.action,
832 - eagerReducer: update.eagerReducer,
832 + hasEagerState: update.hasEagerState,
833 eagerState: update.eagerState,
834 next: (null: any),
835 };
@@ -837,9 +837,9 @@ function updateReducer<S, I, A>(
837 }
838
839 // Process this update.
840 - if (update.eagerReducer === reducer) {
841 - // If this update was processed eagerly, and its reducer matches the
842 - // current reducer, we can use the eagerly computed state.
840 + if (update.hasEagerState) {
841 + // If this update is a state update (not a reducer) and was processed eagerly,
842 + // we can use the eagerly computed state
843 newState = ((update.eagerState: any): S);
844 } else {
845 const action = update.action;
@@ -1190,7 +1190,7 @@ function useMutableSource<Source, Snapshot>(
1190 lastRenderedReducer: basicStateReducer,
1191 lastRenderedState: snapshot,
1192 };
1193 - newQueue.dispatch = setSnapshot = (dispatchAction.bind(
1193 + newQueue.dispatch = setSnapshot = (dispatchSetState.bind(
1194 null,
1195 currentlyRenderingFiber,
1196 newQueue,
@@ -1481,7 +1481,7 @@ function mountState<S>(
1481 hook.queue = queue;
1482 const dispatch: Dispatch<
1483 BasicStateAction<S>,
1484 - > = (queue.dispatch = (dispatchAction.bind(
1484 + > = (queue.dispatch = (dispatchSetState.bind(
1485 null,
1486 currentlyRenderingFiber,
1487 queue,
@@ -2150,7 +2150,7 @@ function refreshCache<T>(fiber: Fiber, seedKey: ?() => T, seedValue: T) {
2150 // TODO: Warn if unmounted?
2151 }
2152
2153 -function dispatchAction<S, A>(
2153 +function dispatchReducerAction<S, A>(
2154 fiber: Fiber,
2155 queue: UpdateQueue<S, A>,
2156 action: A,
@@ -2171,7 +2171,119 @@ function dispatchAction<S, A>(
2171 const update: Update<S, A> = {
2172 lane,
2173 action,
2174 - eagerReducer: null,
2174 + hasEagerState: false,
2175 + eagerState: null,
2176 + next: (null: any),
2177 + };
2178 +
2179 + const alternate = fiber.alternate;
2180 + if (
2181 + fiber === currentlyRenderingFiber ||
2182 + (alternate !== null && alternate === currentlyRenderingFiber)
2183 + ) {
2184 + // This is a render phase update. Stash it in a lazily-created map of
2185 + // queue -> linked list of updates. After this render pass, we'll restart
2186 + // and apply the stashed updates on top of the work-in-progress hook.
2187 + didScheduleRenderPhaseUpdateDuringThisPass = didScheduleRenderPhaseUpdate = true;
2188 + const pending = queue.pending;
2189 + if (pending === null) {
2190 + // This is the first update. Create a circular list.
2191 + update.next = update;
2192 + } else {
2193 + update.next = pending.next;
2194 + pending.next = update;
2195 + }
2196 + queue.pending = update;
2197 + } else {
2198 + if (isInterleavedUpdate(fiber, lane)) {
2199 + const interleaved = queue.interleaved;
2200 + if (interleaved === null) {
2201 + // This is the first update. Create a circular list.
2202 + update.next = update;
2203 + // At the end of the current render, this queue's interleaved updates will
2204 + // be transferred to the pending queue.
2205 + pushInterleavedQueue(queue);
2206 + } else {
2207 + update.next = interleaved.next;
2208 + interleaved.next = update;
2209 + }
2210 + queue.interleaved = update;
2211 + } else {
2212 + const pending = queue.pending;
2213 + if (pending === null) {
2214 + // This is the first update. Create a circular list.
2215 + update.next = update;
2216 + } else {
2217 + update.next = pending.next;
2218 + pending.next = update;
2219 + }
2220 + queue.pending = update;
2221 + }
2222 +
2223 + if (__DEV__) {
2224 + // $FlowExpectedError - jest isn't a global, and isn't recognized outside of tests
2225 + if ('undefined' !== typeof jest) {
2226 + warnIfNotCurrentlyActingUpdatesInDev(fiber);
2227 + }
2228 + }
2229 + const root = scheduleUpdateOnFiber(fiber, lane, eventTime);
2230 +
2231 + if (isTransitionLane(lane) && root !== null) {
2232 + let queueLanes = queue.lanes;
2233 +
2234 + // If any entangled lanes are no longer pending on the root, then they
2235 + // must have finished. We can remove them from the shared queue, which
2236 + // represents a superset of the actually pending lanes. In some cases we
2237 + // may entangle more than we need to, but that's OK. In fact it's worse if
2238 + // we *don't* entangle when we should.
2239 + queueLanes = intersectLanes(queueLanes, root.pendingLanes);
2240 +
2241 + // Entangle the new transition lane with the other transition lanes.
2242 + const newQueueLanes = mergeLanes(queueLanes, lane);
2243 + queue.lanes = newQueueLanes;
2244 + // Even if queue.lanes already include lane, we don't know for certain if
2245 + // the lane finished since the last time we entangled it. So we need to
2246 + // entangle it again, just to be sure.
2247 + markRootEntangled(root, newQueueLanes);
2248 + }
2249 + }
2250 +
2251 + if (__DEV__) {
2252 + if (enableDebugTracing) {
2253 + if (fiber.mode & DebugTracingMode) {
2254 + const name = getComponentNameFromFiber(fiber) || 'Unknown';
2255 + logStateUpdateScheduled(name, lane, action);
2256 + }
2257 + }
2258 + }
2259 +
2260 + if (enableSchedulingProfiler) {
2261 + markStateUpdateScheduled(fiber, lane);
2262 + }
2263 +}
2264 +
2265 +function dispatchSetState<S, A>(
2266 + fiber: Fiber,
2267 + queue: UpdateQueue<S, A>,
2268 + action: A,
2269 +) {
2270 + if (__DEV__) {
2271 + if (typeof arguments[3] === 'function') {
2272 + console.error(
2273 + "State updates from the useState() and useReducer() Hooks don't support the " +
2274 + 'second callback argument. To execute a side effect after ' +
2275 + 'rendering, declare it in the component body with useEffect().',
2276 + );
2277 + }
2278 + }
2279 +
2280 + const eventTime = requestEventTime();
2281 + const lane = requestUpdateLane(fiber);
2282 +
2283 + const update: Update<S, A> = {
2284 + lane,
2285 + action,
2286 + hasEagerState: false,
2287 eagerState: null,
2288 next: (null: any),
2289 };
@@ -2241,7 +2353,7 @@ function dispatchAction<S, A>(
2353 // it, on the update object. If the reducer hasn't changed by the
2354 // time we enter the render phase, then the eager state can be used
2355 // without calling the reducer again.
2244 - update.eagerReducer = lastRenderedReducer;
2356 + update.hasEagerState = true;
2357 update.eagerState = eagerState;
2358 if (is(eagerState, currentState)) {
2359 // Fast path. We can bail out without scheduling React to re-render.
packages/react-reconciler/src/ReactFiberHooks.old.js
+124 -12
@@ -125,7 +125,7 @@ const {ReactCurrentDispatcher, ReactCurrentBatchConfig} = ReactSharedInternals;
125 type Update<S, A> = {|
126 lane: Lane,
127 action: A,
128 - eagerReducer: ((S, A) => S) | null,
128 + hasEagerState: boolean,
129 eagerState: S | null,
130 next: Update<S, A>,
131 |};
@@ -730,7 +730,7 @@ function mountReducer<S, I, A>(
730 lastRenderedState: (initialState: any),
731 };
732 hook.queue = queue;
733 - const dispatch: Dispatch<A> = (queue.dispatch = (dispatchAction.bind(
733 + const dispatch: Dispatch<A> = (queue.dispatch = (dispatchReducerAction.bind(
734 null,
735 currentlyRenderingFiber,
736 queue,
@@ -801,7 +801,7 @@ function updateReducer<S, I, A>(
801 const clone: Update<S, A> = {
802 lane: updateLane,
803 action: update.action,
804 - eagerReducer: update.eagerReducer,
804 + hasEagerState: update.hasEagerState,
805 eagerState: update.eagerState,
806 next: (null: any),
807 };
@@ -829,7 +829,7 @@ function updateReducer<S, I, A>(
829 // this will never be skipped by the check above.
830 lane: NoLane,
831 action: update.action,
832 - eagerReducer: update.eagerReducer,
832 + hasEagerState: update.hasEagerState,
833 eagerState: update.eagerState,
834 next: (null: any),
835 };
@@ -837,9 +837,9 @@ function updateReducer<S, I, A>(
837 }
838
839 // Process this update.
840 - if (update.eagerReducer === reducer) {
841 - // If this update was processed eagerly, and its reducer matches the
842 - // current reducer, we can use the eagerly computed state.
840 + if (update.hasEagerState) {
841 + // If this update is a state update (not a reducer) and was processed eagerly,
842 + // we can use the eagerly computed state
843 newState = ((update.eagerState: any): S);
844 } else {
845 const action = update.action;
@@ -1190,7 +1190,7 @@ function useMutableSource<Source, Snapshot>(
1190 lastRenderedReducer: basicStateReducer,
1191 lastRenderedState: snapshot,
1192 };
1193 - newQueue.dispatch = setSnapshot = (dispatchAction.bind(
1193 + newQueue.dispatch = setSnapshot = (dispatchSetState.bind(
1194 null,
1195 currentlyRenderingFiber,
1196 newQueue,
@@ -1481,7 +1481,7 @@ function mountState<S>(
1481 hook.queue = queue;
1482 const dispatch: Dispatch<
1483 BasicStateAction<S>,
1484 - > = (queue.dispatch = (dispatchAction.bind(
1484 + > = (queue.dispatch = (dispatchSetState.bind(
1485 null,
1486 currentlyRenderingFiber,
1487 queue,
@@ -2150,7 +2150,7 @@ function refreshCache<T>(fiber: Fiber, seedKey: ?() => T, seedValue: T) {
2150 // TODO: Warn if unmounted?
2151 }
2152
2153 -function dispatchAction<S, A>(
2153 +function dispatchReducerAction<S, A>(
2154 fiber: Fiber,
2155 queue: UpdateQueue<S, A>,
2156 action: A,
@@ -2171,7 +2171,119 @@ function dispatchAction<S, A>(
2171 const update: Update<S, A> = {
2172 lane,
2173 action,
2174 - eagerReducer: null,
2174 + hasEagerState: false,
2175 + eagerState: null,
2176 + next: (null: any),
2177 + };
2178 +
2179 + const alternate = fiber.alternate;
2180 + if (
2181 + fiber === currentlyRenderingFiber ||
2182 + (alternate !== null && alternate === currentlyRenderingFiber)
2183 + ) {
2184 + // This is a render phase update. Stash it in a lazily-created map of
2185 + // queue -> linked list of updates. After this render pass, we'll restart
2186 + // and apply the stashed updates on top of the work-in-progress hook.
2187 + didScheduleRenderPhaseUpdateDuringThisPass = didScheduleRenderPhaseUpdate = true;
2188 + const pending = queue.pending;
2189 + if (pending === null) {
2190 + // This is the first update. Create a circular list.
2191 + update.next = update;
2192 + } else {
2193 + update.next = pending.next;
2194 + pending.next = update;
2195 + }
2196 + queue.pending = update;
2197 + } else {
2198 + if (isInterleavedUpdate(fiber, lane)) {
2199 + const interleaved = queue.interleaved;
2200 + if (interleaved === null) {
2201 + // This is the first update. Create a circular list.
2202 + update.next = update;
2203 + // At the end of the current render, this queue's interleaved updates will
2204 + // be transferred to the pending queue.
2205 + pushInterleavedQueue(queue);
2206 + } else {
2207 + update.next = interleaved.next;
2208 + interleaved.next = update;
2209 + }
2210 + queue.interleaved = update;
2211 + } else {
2212 + const pending = queue.pending;
2213 + if (pending === null) {
2214 + // This is the first update. Create a circular list.
2215 + update.next = update;
2216 + } else {
2217 + update.next = pending.next;
2218 + pending.next = update;
2219 + }
2220 + queue.pending = update;
2221 + }
2222 +
2223 + if (__DEV__) {
2224 + // $FlowExpectedError - jest isn't a global, and isn't recognized outside of tests
2225 + if ('undefined' !== typeof jest) {
2226 + warnIfNotCurrentlyActingUpdatesInDev(fiber);
2227 + }
2228 + }
2229 + const root = scheduleUpdateOnFiber(fiber, lane, eventTime);
2230 +
2231 + if (isTransitionLane(lane) && root !== null) {
2232 + let queueLanes = queue.lanes;
2233 +
2234 + // If any entangled lanes are no longer pending on the root, then they
2235 + // must have finished. We can remove them from the shared queue, which
2236 + // represents a superset of the actually pending lanes. In some cases we
2237 + // may entangle more than we need to, but that's OK. In fact it's worse if
2238 + // we *don't* entangle when we should.
2239 + queueLanes = intersectLanes(queueLanes, root.pendingLanes);
2240 +
2241 + // Entangle the new transition lane with the other transition lanes.
2242 + const newQueueLanes = mergeLanes(queueLanes, lane);
2243 + queue.lanes = newQueueLanes;
2244 + // Even if queue.lanes already include lane, we don't know for certain if
2245 + // the lane finished since the last time we entangled it. So we need to
2246 + // entangle it again, just to be sure.
2247 + markRootEntangled(root, newQueueLanes);
2248 + }
2249 + }
2250 +
2251 + if (__DEV__) {
2252 + if (enableDebugTracing) {
2253 + if (fiber.mode & DebugTracingMode) {
2254 + const name = getComponentNameFromFiber(fiber) || 'Unknown';
2255 + logStateUpdateScheduled(name, lane, action);
2256 + }
2257 + }
2258 + }
2259 +
2260 + if (enableSchedulingProfiler) {
2261 + markStateUpdateScheduled(fiber, lane);
2262 + }
2263 +}
2264 +
2265 +function dispatchSetState<S, A>(
2266 + fiber: Fiber,
2267 + queue: UpdateQueue<S, A>,
2268 + action: A,
2269 +) {
2270 + if (__DEV__) {
2271 + if (typeof arguments[3] === 'function') {
2272 + console.error(
2273 + "State updates from the useState() and useReducer() Hooks don't support the " +
2274 + 'second callback argument. To execute a side effect after ' +
2275 + 'rendering, declare it in the component body with useEffect().',
2276 + );
2277 + }
2278 + }
2279 +
2280 + const eventTime = requestEventTime();
2281 + const lane = requestUpdateLane(fiber);
2282 +
2283 + const update: Update<S, A> = {
2284 + lane,
2285 + action,
2286 + hasEagerState: false,
2287 eagerState: null,
2288 next: (null: any),
2289 };
@@ -2241,7 +2353,7 @@ function dispatchAction<S, A>(
2353 // it, on the update object. If the reducer hasn't changed by the
2354 // time we enter the render phase, then the eager state can be used
2355 // without calling the reducer again.
2244 - update.eagerReducer = lastRenderedReducer;
2356 + update.hasEagerState = true;
2357 update.eagerState = eagerState;
2358 if (is(eagerState, currentState)) {
2359 // Fast path. We can bail out without scheduling React to re-render.
packages/react-reconciler/src/__tests__/ReactHooksWithNoopRenderer-test.js
+160 -3
@@ -3867,7 +3867,7 @@ describe('ReactHooksWithNoopRenderer', () => {
3867 });
3868 });
3869
3870 - it('eager bailout optimization should always compare to latest rendered reducer', () => {
3870 + it('useReducer does not eagerly bail out of state updates', () => {
3871 // Edge case based on a bug report
3872 let setCounter;
3873 function App() {
@@ -3898,7 +3898,6 @@ describe('ReactHooksWithNoopRenderer', () => {
3898 'Render: -1',
3899 'Effect: 1',
3900 'Reducer: 1',
3901 - 'Reducer: 1',
3901 'Render: 1',
3902 ]);
3903 expect(ReactNoop).toMatchRenderedOutput('1');
@@ -3911,12 +3910,170 @@ describe('ReactHooksWithNoopRenderer', () => {
3910 'Render: 1',
3911 'Effect: 2',
3912 'Reducer: 2',
3914 - 'Reducer: 2',
3913 'Render: 2',
3914 ]);
3915 expect(ReactNoop).toMatchRenderedOutput('2');
3916 });
3917
3918 + it('useReducer does not replay previous no-op actions when other state changes', () => {
3919 + let increment;
3920 + let setDisabled;
3921 +
3922 + function Counter() {
3923 + const [disabled, _setDisabled] = useState(true);
3924 + const [count, dispatch] = useReducer((state, action) => {
3925 + if (disabled) {
3926 + return state;
3927 + }
3928 + if (action.type === 'increment') {
3929 + return state + 1;
3930 + }
3931 + return state;
3932 + }, 0);
3933 +
3934 + increment = () => dispatch({type: 'increment'});
3935 + setDisabled = _setDisabled;
3936 +
3937 + Scheduler.unstable_yieldValue('Render disabled: ' + disabled);
3938 + Scheduler.unstable_yieldValue('Render count: ' + count);
3939 + return count;
3940 + }
3941 +
3942 + ReactNoop.render(<Counter />);
3943 + expect(Scheduler).toFlushAndYield([
3944 + 'Render disabled: true',
3945 + 'Render count: 0',
3946 + ]);
3947 + expect(ReactNoop).toMatchRenderedOutput('0');
3948 +
3949 + act(() => {
3950 + // These increments should have no effect, since disabled=true
3951 + increment();
3952 + increment();
3953 + increment();
3954 + });
3955 + expect(Scheduler).toHaveYielded([
3956 + 'Render disabled: true',
3957 + 'Render count: 0',
3958 + ]);
3959 + expect(ReactNoop).toMatchRenderedOutput('0');
3960 +
3961 + act(() => {
3962 + // Enabling the updater should *not* replay the previous increment() actions
3963 + setDisabled(false);
3964 + });
3965 + expect(Scheduler).toHaveYielded([
3966 + 'Render disabled: false',
3967 + 'Render count: 0',
3968 + ]);
3969 + expect(ReactNoop).toMatchRenderedOutput('0');
3970 + });
3971 +
3972 + it('useReducer does not replay previous no-op actions when props change', () => {
3973 + let setDisabled;
3974 + let increment;
3975 +
3976 + function Counter({disabled}) {
3977 + const [count, dispatch] = useReducer((state, action) => {
3978 + if (disabled) {
3979 + return state;
3980 + }
3981 + if (action.type === 'increment') {
3982 + return state + 1;
3983 + }
3984 + return state;
3985 + }, 0);
3986 +
3987 + increment = () => dispatch({type: 'increment'});
3988 +
3989 + Scheduler.unstable_yieldValue('Render count: ' + count);
3990 + return count;
3991 + }
3992 +
3993 + function App() {
3994 + const [disabled, _setDisabled] = useState(true);
3995 + setDisabled = _setDisabled;
3996 + Scheduler.unstable_yieldValue('Render disabled: ' + disabled);
3997 + return <Counter disabled={disabled} />;
3998 + }
3999 +
4000 + ReactNoop.render(<App />);
4001 + expect(Scheduler).toFlushAndYield([
4002 + 'Render disabled: true',
4003 + 'Render count: 0',
4004 + ]);
4005 + expect(ReactNoop).toMatchRenderedOutput('0');
4006 +
4007 + act(() => {
4008 + // These increments should have no effect, since disabled=true
4009 + increment();
4010 + increment();
4011 + increment();
4012 + });
4013 + expect(Scheduler).toHaveYielded(['Render count: 0']);
4014 + expect(ReactNoop).toMatchRenderedOutput('0');
4015 +
4016 + act(() => {
4017 + // Enabling the updater should *not* replay the previous increment() actions
4018 + setDisabled(false);
4019 + });
4020 + expect(Scheduler).toHaveYielded([
4021 + 'Render disabled: false',
4022 + 'Render count: 0',
4023 + ]);
4024 + expect(ReactNoop).toMatchRenderedOutput('0');
4025 + });
4026 +
4027 + it('useReducer applies potential no-op changes if made relevant by other updates in the batch', () => {
4028 + let setDisabled;
4029 + let increment;
4030 +
4031 + function Counter({disabled}) {
4032 + const [count, dispatch] = useReducer((state, action) => {
4033 + if (disabled) {
4034 + return state;
4035 + }
4036 + if (action.type === 'increment') {
4037 + return state + 1;
4038 + }
4039 + return state;
4040 + }, 0);
4041 +
4042 + increment = () => dispatch({type: 'increment'});
4043 +
4044 + Scheduler.unstable_yieldValue('Render count: ' + count);
4045 + return count;
4046 + }
4047 +
4048 + function App() {
4049 + const [disabled, _setDisabled] = useState(true);
4050 + setDisabled = _setDisabled;
4051 + Scheduler.unstable_yieldValue('Render disabled: ' + disabled);
4052 + return <Counter disabled={disabled} />;
4053 + }
4054 +
4055 + ReactNoop.render(<App />);
4056 + expect(Scheduler).toFlushAndYield([
4057 + 'Render disabled: true',
4058 + 'Render count: 0',
4059 + ]);
4060 + expect(ReactNoop).toMatchRenderedOutput('0');
4061 +
4062 + act(() => {
4063 + // Although the increment happens first (and would seem to do nothing since disabled=true),
4064 + // because these calls are in a batch the parent updates first. This should cause the child
4065 + // to re-render with disabled=false and *then* process the increment action, which now
4066 + // increments the count and causes the component output to change.
4067 + increment();
4068 + setDisabled(false);
4069 + });
4070 + expect(Scheduler).toHaveYielded([
4071 + 'Render disabled: false',
4072 + 'Render count: 1',
4073 + ]);
4074 + expect(ReactNoop).toMatchRenderedOutput('1');
4075 + });
4076 +
4077 // Regression test. Covers a case where an internal state variable
4078 // (`didReceiveUpdate`) is not reset properly.
4079 it('state bail out edge case (#16359)', async () => {