Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Eager bailout optimization should always compare to latest reducer #15124

Merged
merged 3 commits into from Mar 16, 2019
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
30 changes: 15 additions & 15 deletions packages/react-reconciler/src/ReactFiberHooks.js
Expand Up @@ -89,8 +89,8 @@ type Update<S, A> = {
type UpdateQueue<S, A> = {
last: Update<S, A> | null,
dispatch: (A => mixed) | null,
eagerReducer: ((S, A) => S) | null,
eagerState: S | null,
lastRenderedReducer: ((S, A) => S) | null,
lastRenderedState: S | null,
};

export type HookType =
Expand Down Expand Up @@ -603,8 +603,8 @@ function mountReducer<S, I, A>(
const queue = (hook.queue = {
last: null,
dispatch: null,
eagerReducer: reducer,
eagerState: (initialState: any),
lastRenderedReducer: reducer,
lastRenderedState: (initialState: any),
});
const dispatch: Dispatch<A> = (queue.dispatch = (dispatchAction.bind(
null,
Expand All @@ -627,6 +627,8 @@ function updateReducer<S, I, A>(
'Should have a queue. This is likely a bug in React. Please file an issue.',
);

queue.lastRenderedReducer = reducer;

if (numberOfReRenders > 0) {
// This is a re-render. Apply the new render phase updates to the previous
// work-in-progress hook.
Expand Down Expand Up @@ -662,8 +664,7 @@ function updateReducer<S, I, A>(
hook.baseState = newState;
}

queue.eagerReducer = reducer;
queue.eagerState = newState;
queue.lastRenderedState = newState;

return [newState, dispatch];
}
Expand Down Expand Up @@ -742,8 +743,7 @@ function updateReducer<S, I, A>(
hook.baseUpdate = newBaseUpdate;
hook.baseState = newBaseState;

queue.eagerReducer = reducer;
queue.eagerState = newState;
queue.lastRenderedState = newState;
}

const dispatch: Dispatch<A> = (queue.dispatch: any);
Expand All @@ -761,8 +761,8 @@ function mountState<S>(
const queue = (hook.queue = {
last: null,
dispatch: null,
eagerReducer: basicStateReducer,
eagerState: (initialState: any),
lastRenderedReducer: basicStateReducer,
lastRenderedState: (initialState: any),
});
const dispatch: Dispatch<
BasicStateAction<S>,
Expand Down Expand Up @@ -1141,21 +1141,21 @@ function dispatchAction<S, A>(
// The queue is currently empty, which means we can eagerly compute the
// next state before entering the render phase. If the new state is the
// same as the current state, we may be able to bail out entirely.
const eagerReducer = queue.eagerReducer;
if (eagerReducer !== null) {
const lastRenderedReducer = queue.lastRenderedReducer;
if (lastRenderedReducer !== null) {
let prevDispatcher;
if (__DEV__) {
prevDispatcher = ReactCurrentDispatcher.current;
ReactCurrentDispatcher.current = InvalidNestedHooksDispatcherOnUpdateInDEV;
}
try {
const currentState: S = (queue.eagerState: any);
const eagerState = eagerReducer(currentState, action);
const currentState: S = (queue.lastRenderedState: any);
const eagerState = lastRenderedReducer(currentState, action);
// Stash the eagerly computed state, and the reducer used to compute
// it, on the update object. If the reducer hasn't changed by the
// time we enter the render phase, then the eager state can be used
// without calling the reducer again.
update.eagerReducer = eagerReducer;
update.eagerReducer = lastRenderedReducer;
update.eagerState = eagerState;
if (is(eagerState, currentState)) {
// Fast path. We can bail out without scheduling React to re-render.
Expand Down
Expand Up @@ -1963,4 +1963,88 @@ describe('ReactHooksWithNoopRenderer', () => {
// );
});
});

it('eager bailout optimization should always compare to latest rendered reducer', () => {
// Edge case based on a bug report
let setCounter;
function App() {
const [counter, _setCounter] = useState(1);
setCounter = _setCounter;
return <Component count={counter} />;
}

function Component({count}) {
const [state, dispatch] = useReducer(() => {
// This reducer closes over a value from props. If the reducer is not
// properly updated, the eager reducer will compare to an old value
// and bail out incorrectly.
Scheduler.yieldValue('Reducer: ' + count);
return count;
}, -1);
useEffect(
() => {
Scheduler.yieldValue('Effect: ' + count);
dispatch();
},
[count],
);
Scheduler.yieldValue('Render: ' + state);
return count;
}

ReactNoop.render(<App />);
expect(Scheduler).toFlushAndYield([
'Render: -1',
'Effect: 1',
'Reducer: 1',
'Reducer: 1',
'Render: 1',
]);
expect(ReactNoop).toMatchRenderedOutput('1');

act(() => {
setCounter(2);
});
expect(Scheduler).toFlushAndYield([
'Render: 1',
'Effect: 2',
'Reducer: 2',
'Reducer: 2',
'Render: 2',
]);
expect(ReactNoop).toMatchRenderedOutput('2');
});

it('should update latest rendered reducer when a preceding state receives a render phase update', () => {
// Similar to previous test, except using a preceding render phase update
// instead of new props.
let dispatch;
function App() {
const [step, setStep] = useState(0);
const [shadow, _dispatch] = useReducer(() => step, step);
dispatch = _dispatch;

if (step < 5) {
setStep(step + 1);
}

Scheduler.yieldValue(`Step: ${step}, Shadow: ${shadow}`);
return shadow;
}

ReactNoop.render(<App />);
expect(Scheduler).toFlushAndYield([
'Step: 0, Shadow: 0',
'Step: 1, Shadow: 0',
'Step: 2, Shadow: 0',
'Step: 3, Shadow: 0',
'Step: 4, Shadow: 0',
'Step: 5, Shadow: 0',
]);
expect(ReactNoop).toMatchRenderedOutput('0');

act(() => dispatch());
expect(Scheduler).toFlushAndYield(['Step: 5, Shadow: 5']);
expect(ReactNoop).toMatchRenderedOutput('5');
});
});