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

Bug: is the current useSyncExternalStore batching & flushing behaviour intended? #25191

Closed
phryneas opened this issue Sep 5, 2022 · 22 comments
Closed
Labels
Status: Unconfirmed A potential issue that we haven't yet confirmed as a bug

Comments

@phryneas
Copy link
Contributor

phryneas commented Sep 5, 2022

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 a uSES value and then another local value like this:

function increment() {
    setState1((x) => x + 1);
    setUsesState((x) => x + 1);
    setState2((x) => x + 1);
  }

Now, there would be two ways this could behave that would be "intuitive for me":

  1. everything is batched: [state1, usesState, state2] goes from [0,0,0] to [1,1,1]
  2. it batches until the "sync update" flushes the current batch: [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.

  1. in an event handler, everything is batched [0,0,0] to [1,1,1] - no problem here
  2. outside an event handler, the uSES 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.
  3. even inside a manual call wrapped in 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 calling setState2 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 on react-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 the unstable prefix ;)

@phryneas phryneas added the Status: Unconfirmed A potential issue that we haven't yet confirmed as a bug label Sep 5, 2022
@dai-shi
Copy link
Contributor

dai-shi commented Sep 5, 2022

fwiw, startTransition seems to make the behavior more consistent.
https://codesandbox.io/s/uses-behaviour-react18-forked-pmq3o1
so, the onClick handler would be an exception with higher priority.

(btw, logMethod is a nice hack.)

@Andarist
Copy link
Contributor

Andarist commented Sep 5, 2022

Related issue: #24831

@phryneas
Copy link
Contributor Author

phryneas commented Sep 5, 2022

fwiw, startTransition seems to make the behavior more consistent. codesandbox.io/s/uses-behaviour-react18-forked-pmq3o1 so, the onClick handler would be an exception with higher priority.

Even with the onClick={() => startTransition(increment)}, I am seeing [0,0,0] -> [0,1,0] -> [1,1,1] - or am I missing something?

@dai-shi
Copy link
Contributor

dai-shi commented Sep 5, 2022

I meant only the in-consistent behavior is bare onClick. I didn't mean which is correct or expected.

@sebmarkbage
Copy link
Collaborator

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 flushSync(...) but then you're compromising by making all other updates not delayed neither.

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.

@sebmarkbage
Copy link
Collaborator

In React <=17 the default for setState was sync - you could opt-in to batched with batchedUpdates.

In React 18 the default is batched - you can opt-out to sync with flushSync.

useSyncExternalStore is always sync to preserve the consistency with other mutable data.

@Andarist
Copy link
Contributor

Andarist commented Sep 5, 2022

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 flushSync. In this case, it would be quite convenient to change the priority of the update with the default priority if there is already an ongoing "sync" update. Correct me if I'm wrong but the uSES update is still not exactly synchronous - all updates coming from that store are batched together and more often than not it is desirable to flush other updates with those. Part of the problem is that people usually won't even interact with uSES directly but rather through a library. In those situations, it's even harder to notice that one might deal with such a discrepancy in flushed updates.

@sebmarkbage
Copy link
Collaborator

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.

@Andarist
Copy link
Contributor

Andarist commented Sep 5, 2022

The interesting thing about these things is whether it's observable behavior or a perf consideration.

The first minimal~ repro case in this thread showcases this problem through an observable behavior (not a perf issue)

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.

Yeah, I was asking about this - not the other way around. It's good to know that this wouldn't break the model.

@rickhanlonii
Copy link
Member

all updates coming from that store are batched together and more often than not it is desirable to flush other updates with those

What types of updates are you thinking of? They would need to be updates that are not already marked with a priority, right?

@Andarist
Copy link
Contributor

Andarist commented Sep 6, 2022

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 useEffect in the parent couldn't "see" the updated DOM from the child because the child was dependent on that "default" update (the setState1 here) while the parent was depending on the "sync" update from the sync store (the setUsesState here).

So if I interpret the question correctly - then yes, that other update was not explicitly marked with any priority.

@phryneas
Copy link
Contributor Author

phryneas commented Sep 6, 2022

Even with only one component, this could lead to useEffect side effects for combinations of local state and global state that should - from a user's perspective - be impossible.

@rickhanlonii
Copy link
Member

rickhanlonii commented Sep 6, 2022

I mean, where was increment called?

What would you expect if it was called as:

startTransition(() => {
  increment();
})

@Andarist
Copy link
Contributor

Andarist commented Sep 6, 2022

One of the user reports that we have received can be found here. In this case, there is a handleFetch function that calls regular setState but it is itself called by XState when a promise resolves. XState also changes its state when that promise gets resolved and that state is using uSES.

What would you expect if it was called as:

I'd expect both updates to be flushed together at some point. I wouldn't expect the uSES update to be flushed immediately, despite it being "sync". Since both updates are wrapped with startTransition I don't expect the uSES update to be reflected on the screen immediately.

@sebmarkbage
Copy link
Collaborator

sebmarkbage commented Sep 13, 2022

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.

@markerikson
Copy link
Contributor

Thank you!

@nishantjoshtech
Copy link

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?

@sarimarton
Copy link

sarimarton commented Feb 20, 2023

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.

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?

Copy link

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!

@github-actions github-actions bot added the Resolution: Stale Automatically closed due to inactivity label Apr 10, 2024
@markerikson
Copy link
Contributor

Bump.

@sebmarkbage was this ever actually fixed?

@phryneas
Copy link
Contributor Author

phryneas commented Jul 3, 2024

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.

@sebmarkbage @gnoff will this end up in React 19?

@phryneas
Copy link
Contributor Author

Based on #24831 (comment) I believe I can close this 🤞

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
Status: Unconfirmed A potential issue that we haven't yet confirmed as a bug
Projects
None yet
Development

No branches or pull requests

8 participants