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 () => {