-
Notifications
You must be signed in to change notification settings - Fork 47.6k
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
Bug: unexpected rerender when using useMutableSource
#19200
Comments
Note that this isn't actually an issue with the second state, specifically, so much as it is an issue with the second mutation against the shared source. (So if you reverse steps 1 and 2 in the repro, the unexpected behavior will happen for step 1.) |
I think the reason for the unexpected render of the label component is due to the It looks like React doesn't commit any mutations, or do any further rendering for sub-components (if the label component rendered children), so the overhead of this isn't large even though it's not ideal. Here's a reduced test case: const { createMutableSource, useCallback, useEffect } = React;
function getStoreVersion(store) {
return store.getState();
}
function subscribe(store, callback) {
return store.subscribe(callback);
}
function useSelector(selector) {
const getSnapshot = useCallback((store) => selector(store.getState()), [
selector,
]);
return useMutableSource(mutableSource, getSnapshot, subscribe);
}
function reducer(state, action) {
switch (action.type) {
case "INCREMENT_ONE":
return {
...state,
one: state.one + 1,
};
case "INCREMENT_TWO":
return {
...state,
two: state.two + 1,
};
default:
return state;
}
}
// Faux Redux store
const store = {
_listeners: [],
_value: {
one: 100,
two: 200,
},
getState: () => store._value,
dispatch: (action) => {
store._value = reducer(store._value, action);
store.version++;
store._listeners.forEach((listener) => listener());
},
subscribe: (listener) => {
store._listeners.push(listener);
return () => {
const index = store._listeners.indexOf(listener);
if (index >= 0) {
store._listeners.splice(index, 1);
}
};
},
};
const mutableSource = createMutableSource(store, getStoreVersion);
const selector1 = (state) => state.one;
const selector2 = (state) => state.two;
function Grandchild({ value }) {
Scheduler.unstable_yieldValue(`Grandchild render "${value}"`);
useEffect(() => {
Scheduler.unstable_yieldValue(`Grandchild useEffect "${value}"`);
});
return value;
}
function Child({ label, selector }) {
const value = useSelector(selector);
Scheduler.unstable_yieldValue(`Child "${label}" render "${value}"`);
useEffect(() => {
Scheduler.unstable_yieldValue(`Child "${label}" useEffect "${value}"`);
});
return <Grandchild value={value} />;
}
function App() {
Scheduler.unstable_yieldValue("App render");
return (
<>
<Child label="one" selector={selector1} />
{"|"}
<Child label="two" selector={selector2} />
</>
);
}
const root = ReactNoop.createRoot();
act(() => {
root.render(<App />);
expect(Scheduler).toFlushAndYield([
"App render",
'Child "one" render "100"',
'Grandchild render "100"',
'Child "two" render "200"',
'Grandchild render "200"',
'Grandchild useEffect "100"',
'Child "one" useEffect "100"',
'Grandchild useEffect "200"',
'Child "two" useEffect "200"',
]);
});
expect(root.getChildrenAsJSX()).toEqual("100|200");
act(() => {
store.dispatch({ type: "INCREMENT_TWO" });
expect(Scheduler).toFlushAndYield([
'Child "two" render "201"',
'Grandchild render "201"',
'Grandchild useEffect "201"',
'Child "two" useEffect "201"',
]);
});
expect(root.getChildrenAsJSX()).toEqual("100|201");
act(() => {
store.dispatch({ type: "INCREMENT_ONE" });
expect(Scheduler).toFlushAndYield([
'Child "one" render "101"',
'Grandchild render "101"',
'Child "two" render "201"', // Unexpected render
'Grandchild useEffect "101"',
'Child "one" useEffect "101"',
'Child "one" render "101"', // Unexpected render after commit
]);
});
expect(root.getChildrenAsJSX()).toEqual("101|201"); |
Confirmed, in that I can reproduce the same "unexpected" behavior with just the const setStateCallbacks = {};
function Grandchild({ value }) {
Scheduler.unstable_yieldValue(`Grandchild render "${value}"`);
React.useEffect(() => {
Scheduler.unstable_yieldValue(`Grandchild useEffect "${value}"`);
});
return value;
}
function Child({ label }) {
const [value, setValue] = React.useState(0);
setStateCallbacks[label] = setValue;
Scheduler.unstable_yieldValue(`Child "${label}" render "${value}"`);
React.useEffect(() => {
Scheduler.unstable_yieldValue(`Child "${label}" useEffect "${value}"`);
});
return <Grandchild value={value} />;
}
function App() {
Scheduler.unstable_yieldValue("App render");
return (
<>
<Child label="one" />
{"|"}
<Child label="two" />
</>
);
}
const root = ReactNoop.createRoot();
act(() => {
root.render(<App />);
expect(Scheduler).toFlushAndYield([
"App render",
'Child "one" render "0"',
'Grandchild render "0"',
'Child "two" render "0"',
'Grandchild render "0"',
'Grandchild useEffect "0"',
'Child "one" useEffect "0"',
'Grandchild useEffect "0"',
'Child "two" useEffect "0"',
]);
});
expect(root.getChildrenAsJSX()).toEqual("0|0");
act(() => {
setStateCallbacks.one(0);
setStateCallbacks.two(1);
expect(Scheduler).toFlushAndYield([
'Child "two" render "1"',
'Grandchild render "1"',
'Grandchild useEffect "1"',
'Child "two" useEffect "1"',
]);
});
expect(root.getChildrenAsJSX()).toEqual("0|1");
act(() => {
setStateCallbacks.one(1);
setStateCallbacks.two(1);
expect(Scheduler).toFlushAndYield([
'Child "one" render "1"',
'Grandchild render "1"',
'Child "two" render "1"', // Unexpected render
'Grandchild useEffect "1"',
'Child "one" useEffect "1"',
'Child "one" render "1"', // Unexpected render after commit
]);
});
expect(root.getChildrenAsJSX()).toEqual("1|1"); |
React has a fast-path bailout for state updates that don't actually change the current value- but we only eagerly compute and compare values if we're sure the update queue is empty (by checking that both the current and alternate Fibers This bailout optimization was added back in #14569. Looking at the original implementation notes from that PR, we see (emphasis added):
This is relevant to this issue because, in some cases¹ we don't reset ¹ The case this occurs for follows the pattern of:
Fortunately this doesn't seem to happen often. Unfortunately it does happen sometimes. Still digging in to see if it's a known/unavoidable thing or if it's something we can improve. |
After speaking with the team, this sounds like a known limitation of the bailout mechanism because of our current Fiber architecture. One positive clarification of my previous comment: this will only happen once per sub tree so the performance impact should be further minimized. I'm going to close this issue for now since it seems the mechanism is working as designed (with a known limitation that I was not aware of until digging in). Thank you so much for the helpful report with repro case ❤️ One quick note: We have some possible planned optimizations for |
React version: experimental
Steps To Reproduce
https://codesandbox.io/s/react-redux-usemutablesource-j1vgy
(modified from Brian Vaughn's fake react-redux demo)
state.s1
) for several times, only Label1 is rerendered, which is expected.state.s2
), both Label1 and Label2 is rerendered, which is unexpected.Since Label1 return the same
state.s1
fromgetSnapshot
, Label1 should not rerendered.This bug may lead to worse performance, because Components are rerendered for state fields that they don't need.
@bvaughn may be interested in this.
@dai-shi may also be interested in this because his use-context-selector need the correct behaviour to be efficient
The text was updated successfully, but these errors were encountered: