-
Notifications
You must be signed in to change notification settings - Fork 47.2k
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: is the current useSyncExternalStore
batching & flushing behaviour intended?
#25191
Comments
fwiw, (btw, |
Related issue: #24831 |
Even with the |
I meant only the in-consistent behavior is bare onClick. I didn't mean which is correct or expected. |
It's because useSyncExternalStore always has to be flushed at the highest priority where as other updates can be delayed. It can't be delayed because of the "Sync" part which is a compromise to using this API since it trades preserving internal consistency with the external store by compromising the ability to delay it. So it can't be batched but it also can't be time sliced and deprioritized. You can regain consistency by flushing other updates together with this one by using If it was batched, which is basically what you'd get by manually subscribing without using it, you lose other types of consistency due to using a mutable backing store. |
In React <=17 the default for setState was sync - you could opt-in to batched with In React 18 the default is batched - you can opt-out to sync with
|
Hm, while priority-based updates are probably a powerful feature - it's really hard to grasp how this works in edge cases like this. It feels like there should be a way to somehow "join" those updates without resorting to |
The interesting thing about these things is whether it's observable behavior or a perf consideration. If the useEffects observing the rendering or painting by the browser, while still being implemented idiomatically, observe this difference in a negative way. From what I can tell by the reports, these are just a perf concern rather than a semantic concern. The perf concern might be valid though. I don't see it being possible to delay useSyncExternalStore updates beyond an event loop. It opens up a lot more complexity which this feature isn't really meant to address. However, grouping sync, discrete and default updates into a single lane and flushing them at the highest priority available opportunity is consistent with the model. In other words, flushing useSyncExternalStore and other updates in the same event loop together in the next tick would be valid. The downside of that approach is that it means that you might be relying on batching of many small updates. That's the idea that you shouldn't have to think about throttling and stuff. That should be automatic in the model. But just adding a call to something that uses sync external store might deopt the whole app and causing those to suddenly become a perf issue. That might not be a huge issue though. In particular setState in useEffect is tricky because you don't want to cause these multiple synchronous passes, but setState in useEffect is so trick. Ofc, on the flip side, rendering twice might also be an issue. It would certainly simplify the model if it was a single lane. |
The first minimal~ repro case in this thread showcases this problem through an observable behavior (not a perf issue)
Yeah, I was asking about this - not the other way around. It's good to know that this wouldn't break the model. |
What types of updates are you thinking of? They would need to be updates that are not already marked with a priority, right? |
A user has reported to us that something like this (conceptually) wasn't flushed together: // executed asynchronously somehow, not called from the event handler
function increment() {
setState1((x) => x + 1);
setUsesState((x) => x + 1);
} This led to the situation in which So if I interpret the question correctly - then yes, that other update was not explicitly marked with any priority. |
Even with only one component, this could lead to |
I mean, where was What would you expect if it was called as: startTransition(() => {
increment();
}) |
One of the user reports that we have received can be found here. In this case, there is a
I'd expect both updates to be flushed together at some point. I wouldn't expect the |
We discussed with the team and we agreed that it makes sense to change this. Now it's just a matter of implementing it and when someone can get around to it. While React treats these as separate lanes, the programming model only has "Sync" and "Transition" so it makes sense to treat these all as Sync and flush them all together at the last possible opportunity but no later than the earliest heuristic. If something is wrapped in startTransition only the setStates will be delayed, and any uSES will be flushed early. I thought that case was even a warning? Maybe we should add back the warning. |
Thank you! |
Hi @sebmarkbage, any updates on this issue? I can see a PR is merged. Can you please confirm if we can go ahead & update the library? |
I don't want to open a separate issue for this, but I'd love to know what kind of other types of consistency we lose here. In my understanding and experiments, a manual subscription model with a useState guarantees consistency after the first render. What uSES provides is a render-time subscription, but it can be worked around with a forced setState from a one-time useLayoutEffect (it can be coupled with a dirty-check). From this onward, this is basically a level 3 setup. How is uSES more advantageous than this? |
This issue has been automatically marked as stale. If this issue is still affecting you, please leave any comment (for example, "bump"), and we'll keep it open. We are sorry that we haven't been able to prioritize it yet. If you have any new additional information, please include it with your comment! |
Bump. @sebmarkbage was this ever actually fixed? |
@sebmarkbage @gnoff will this end up in React 19? |
Based on #24831 (comment) I believe I can close this 🤞 |
React version: 18
Link to code example:
CodeSandbox
The expected behavior
Let's assume we have a
increment
function that first increments a local value, then auSES
value and then another local value like this:Now, there would be two ways this could behave that would be "intuitive for me":
[state1, usesState, state2]
goes from[0,0,0]
to[1,1,1]
[state1, usesState, state2]
goes from[0,0,0]
to[1,1,0]
to[1,1,1]
The current behavior
Now, actual behaviour is different in React 18, depending on the "mode" React is currently in.
[0,0,0]
to[1,1,1]
- no problem hereuSES
setter is flushed first, then the local state changes are batched.[0,0,0]
becomes[0,1,0]
becomes[1,1,1]
- this is very unintuitive for me.unstable_batchedUpdates
, we go[0,0,0]
->[0,1,0]
->[1,1,1]
Point 3 means that there is actually no way to even manually batch an update by
uSES
- but looking at point 1, React sometimes does so internally.It seems that even in the non-batched situations, React does some batching: Calling
setUsesState
twice before callingsetState2
will not lead to a[0,0,0]
->[0,1,0]
->[0,2,0]
->[1,2,1]
situation, but to[0,0,0]
->[0,2,0]
->[1,2,1]
Up until now we had assumed that
uSES
would always behave like in 1., and we were only made aware of this by bug reports onreact-redux
.Is this intended behaviour or a bug?
There might be some high priority update thing with a transition that I am missing here though - but either way this feels very breaking from older behaviour to me - and yes, I know that
batchedUpdates
has theunstable
prefix ;)The text was updated successfully, but these errors were encountered: