-
Notifications
You must be signed in to change notification settings - Fork 47.4k
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
Fixed an issue with simple memo components being updated with new props during context change #14876
Conversation
Can you file an issue too pls? |
Done - #14878 |
const text = `Inner render count: ${++renderCountRef.current}`; | ||
return <Text text={text} />; | ||
}, [ | ||
props, |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Is this the only observable difference? Can you rewrite this test to check render count rather than whether props
are referentially equal? Why does this matter at all?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This checks the render count - see renderCountRef
. I can't just count renders directly inside Inner, because it has to render (it reads changed context).
Why this matters?
Because it's inconsistent with "regular" memo component. Conceptually from the user's perspective React.memo(Component)
& React.memo(Component, shallowEqual)
should behave exactly the same (but they dont).
It makes caching assumptions less predictable. While this test is ofc artificial I've encountered this problem when implementing some derived state caching (based on both context & props) in a real world application. It caught me completely off guard because I was relaying on the fact that React.memo
should always provide me same props (referentially equal) and I've only checked against that - skipping shallowEqual because I was trusting React to perform this one on my behalf.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Can you change the test to rely on props.something
rather than props
itself? Relying on referential identity of props
seems a bit like assuming too much to me
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Comparing against props referential identity isnt that unusual - https://github.com/reduxjs/react-redux/blob/fac9ad19d17000f5b9fdd52fc9843225b807845b/src/components/connectAdvanced.js#L142 .
I can't change this test to rely on props.something
because this issue is specifically about the fact that props are not referentially equal in this case (sorry if I havent made that clear before). Anything on the props will be referentially equal as it pass shallowEqual
test.
My point is that the current behaviour is inconsistent here and IMHO that's not a good thing because it makes the mental model about this a little bit unreliable.
Have you checked out my codesandbox which compares React.memo(Component)
& React.memo(Component, shallowEqual)
? I strongly believe that they should behave exactly the same and should not be distinguishable from user's perspective, as React.memo implementation conceptually looks like:
React.memo = (Component, compare = shallowEqual) => {
// ...
}
Therefore supplying the second argument implicitly (relaying on default params) and explicitly should result in the very same observable behaviour.
When I've encountered this issue in my code I've supplied shallowEqual as 2nd argument by hand, just so I could put a debugger statement quicker in it's implementation to see why my component is rerendering and to my surprise it has fixed "the issue" which was at very least really surprising (knowing that React.memo
uses shallowEqual
by default)
@aweary yeah, that's exactly my conclusion - simple memo's path is working differently |
It's certainly inconsistent, but I'm not sure React makes any guarantees about |
My main problem here is not even about props not being referentially equal between updates (although I still believe it's quite a valid assumption for anyone using React.memo / PureComponent) but about the inconsistency. As a developer I would chose a quirky, surprising behaviour (but stable & consistent) over inconsistent any day, because consistency helps me think - it reassures me about my knowledge and expectations, whereas inconsistent behaviour leads me to questioning things I shouldnt do. If a single behaviour is inconsistent how can I be sure that others truly are? Especially when debugging it's preferable to have a clear model in mind. So in my opinion referential equality here should be a guaranteed at all times or not guaranteed at all. Currently it's just too easy to rely on it, not being aware about this subtelty then throw a Just to reiterate on the importance of this as I'm not sure what your current stance on the matter is. The important question is if you would be OK with this (quirky behaviour, not props identity) knowing about it at the time of implementation? I suppose not - and that you would strive for implementing consistent behaviour. While thinking about it some more I was also thinking about how props correspond to state & context as those 3 are React's primitives for holding data. In hooks world And also going slightly further with this - wouldnt it be user's expected assumption that in case of |
I agree, the inconsistency isn't great. I don't think it's intentional either and it could represent a bug somewhere. The open question here is, which one is correct? Memoizing the
You can see that with So relying on
Your app's behavior shouldn't depend on
So even though this is inconsistent, it shouldn't affect your app's behavior. If it does, your app is likely to break again in the future. |
Agree on that 👍 Would be good at the very least to dig into this more to understand why exactly this happens to assess how much of a bug it might be.
Yeah - I know that. I understand both concepts and how they work. I was just stating that referential equality was kinda implied when using React.memo & React.PureComponent. I havent tested this thoroughly in the past, but this was always my mental model - that generally if any form of
That's good to know - gonna strive for this when writing component from now on, although I was using this merely as performance optimization anyway. I can imagine somebody using this in some other way though. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think you could technically fix this by changing https://github.com/facebook/react/blob/master/packages/react-reconciler/src/ReactFiberBeginWork.js#L455-L470 and https://github.com/facebook/react/blob/master/packages/react-reconciler/src/ReactFiberBeginWork.js#L391-L405 to set workInProgress.memoizedProps = current.memoizedProps
/ newChild.memoizedProps = currentChild.memoizedProps
.
But should it be? 🤔
|
||
function Inner(props) { | ||
const renderCountRef = React.useRef(0); | ||
readContext(CountContext); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
You can just use useContext
here, the readContext
is if you need to access the context from outside the render pass.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Oh, good to know! I was writing this test based on other test in this file and it has used this readContext
thingy inside the render 🤔
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Rather, I assume most of these tests predate hooks. The code for the useContext
hook is literally just this readContext
, the only difference is it creates a node in the memoizedState
linked list.
I was considering similar patch but it didnt sound right - I couldnt break the latter path anyhow though - it has worked fine. The whole thing happens because I'll dig into this further, but I still don't quite understand how fibers are being connected and how context change is being handled by React - I would appreciate any resources about those topics if you have any. |
Looking through experimental branch of react-redux it seems that props identity in memo case is something that might be expected by developers: This is not strong argument in favour of this behaviour, rather just pointing out that it isn't uncommon to assume this cc @markerikson (might be of interest to you) |
This pull request has been automatically marked as stale because it has not had recent activity. It will be closed if no further activity occurs. Thank you for your contribution. |
I still believe the issue is sound, and unless closed by React team member it shouldn't be treated as stale. |
This pull request has been automatically marked as stale. If this pull request is still relevant, please leave any comment (for example, "bump"), and we'll keep it open. We are sorry that we haven't been able to prioritize reviewing it yet. Your contribution is very much appreciated. |
|
Did you have a conclusion about what a fix could look like? I haven’t looked at this in depth yet. |
@Andarist Could you confirm this is still an issue? |
I think I've looked at the original repro and it still repro'd in 18. |
Yes - the issue is still there, I've prepared an updated repro case here (using
Sorry, I've missed this question from 2020. It's hard for me to tell because it's really depending on the internal implementation. For some reason the memo bailout doesn't happen here at all - maybe I can try to debug this again, since it might be way easier nowadays with Replay (❤️ ) |
…ps during context change
a9ce35f
to
4607937
Compare
Comparing: d63cd97...bd13426 Critical size changesIncludes critical production bundles, as well as any change greater than 2%:
Significant size changesIncludes any change greater than 0.2%: (No significant changes) |
60daa37
to
bd13426
Compare
@gaearon I've spent some time yesterday with Replay here and I got some conclusions about the situation here - note that my conclusions might be completely off because I have limited knowledge about React internals. From what I understand a "simple memo" is a memo that doesn't use a custom I think that the "simple memo" stuff is just an internal optimization and that it "flattens" the fiber tree (?). The whole problem~ here happens during a context update and, from what I understand, a context update marks all consuming fibers~ as "dirty". When we update a component we must reconcile its children and while we can bail out on props equality we might still want to rerender a child if it consumes a context (this is determined by So conceptually the component must rerender when either its props or context change in this part of the algorithm (I don't know why state change isn't handled here or where it is actually handled). And the Going back to the whole "flattening" thing though - when we deal with non-simple memo it's not that fiber that has a "pending" context update, it's probably its "child". So in this situation, prev props are preserved on the inner fiber as the non-simple memo just bails out and the new pendingProps (?) are not committed to it. The wrapped component still rerenders because React visits the next unit of work~ that is this wrapped component since it has a pending context update. In the case of a simple memo though it's the same fiber that does the memo check and that has a context update scheduled for it. So it ends up calling I totally agree with you that you make it pretty clear that memo stuff is not a semantic guarantee. However, I still believe that it is a conceptual problem (a small one) because conceptually, from the user perspective, both of those components should behave in the very same way. I agree that we can't rely on the I managed to fix this particular issue yesterday by recycling the It would be great if you (React team) could decide if this is a legitimate issue from your PoV or if you want to close it. I can investigate this further and maybe try some other approaches if you'd have any suggestions - but it would be great if this could get classified as an issue first. Also - even if this doesn't get classified as a bug it could still be seen as a potential perf improvement for some situations. |
Just to set expectations, this is pretty low in terms of priority since there's a bunch of semantic bugs we need to follow up on. But I'll add this to our triage list. |
No problem, it's good to know that it is still pending evaluation and that it might happen one day :P |
I agree with @gaearon that we don't have a strong guarantee that the props object is reused if there's a state or context change. Your code should be resilient either way. And as @aweary pointed out, the behavior is already inconsistent between class components ( However I also agree with @Andarist that wrapping a memoized function component in I have a slightly different preference for how to test this so I'll open a modified version of this PR where we can continue the discussion. |
Closed by #24421 |
This is not ready to be merged yet because it only provides a failing test, I'd like to work on fixing this issue - but I would really appreciate some guidance with this. I've tried to understand what exactly is happening (wanted to send a PR with a fix already in it 😉 ), but failed to do so - need to dig deeper.
My current conclusion is that this fails because of the "simple memo" optimization -
workLoop
doesnt see this memo element at all, it "jumps" from working on Outer directly to Inner which has freshpendingProps
which are used as argument for working on Inner as it correctly has to rerender itself because it reads changed context. So it doesnt bail out on finished work (correctly) and commits used new props as memoized ones later.Why this matters?
Because it's inconsistent with "regular" memo component. Conceptually from the user's perspective
React.memo(Component)
&React.memo(Component, shallowEqual)
should behave exactly the same (but they dont)I've prepared a demo to showcase the problem - https://codesandbox.io/s/jp21pwzrv9