mobxjs / mobx

Simple, scalable state management.
http://mobx.js.org
MIT License
27.51k stars 1.77k forks source link

Support concurrent features React / React 18 #2526

Closed mweststrate closed 11 months ago

mweststrate commented 5 years ago

Support concurrent mode React. Primary open issue, don't leak reactions for component instances that are never committed.

A solution for that is described in: https://github.com/mobxjs/mobx-react-lite/issues/53

But let's first wait until concurrent mode is a bit further; as there are no docs yet etc, introducing a workaround in the code base seems to be premature at this point (11-feb-2019)

mweststrate commented 5 years ago

That is: assuming no one is using concurrent mode in production atm

danielkcz commented 5 years ago

For anyone interested mobx-react-lite@next has experimental support for Concurrent mode. Feel free to test it out.

Personally, I don't think it's worth the hassle supporting Concurrent mode in class components.

bsmith-cycorp commented 4 years ago

They just published a post getting further into details about Concurrent Mode: https://reactjs.org/docs/concurrent-mode-intro.html

danielkcz commented 4 years ago

I believe we are ready for Concurrent mode with mobx-react-lite@next. Of course, at some point, we shall try against "experimental" version if tests really do pass just to be sure. PR for that is surely welcome :)

bsmith-cycorp commented 4 years ago

I hadn't heard about mobx-react-lite.

Is it intended to supercede mobx-react? That would be hugely disappointing for me, as I take serious philosophical issue with hooks, especially given MobX's emphasis on (and synergy with) classes.

danielkcz commented 4 years ago

Um no, mobx-react@6 is built on top of mobx-react-lite :)

mweststrate commented 4 years ago

Ok, FWIW, using suspense with observables is pretty natural if you throw when(() => expr) :)

https://codesandbox.io/s/dry-microservice-qnle3

danielkcz commented 4 years ago

@mweststrate That's a certainly nice feat, although I never liked much to have fetching logic inside the stores. But I guess with Suspense it will make more sense to have it separated from components for a proper preloading.

I also love that it will re-render on further updates to those observables without any extra magic. That makes it really powerful.

That fetchingPosts extra state is awkward though, considering Suspense should kinda remove the worry about any sort of loading state, it shouldn't be needed. I do understand why it's there in this case, but it's not ideal for sure.

mweststrate commented 4 years ago

Suspense doesn't make any decisions on who triggers rendering. So I didn't do either in the example.

THe first is triggered 'externally', the second is triggered by rendering, just to demonstrate both approaches.

The loading state again is a design decision. Should every render trigger a new fetch? Only if not pending? Or only at mostly once? The loading state was introduced to demonstrate you can express either solution you want.

On Fri, Nov 8, 2019 at 10:57 PM Daniel K. notifications@github.com wrote:

@mweststrate https://github.com/mweststrate That's a certainly nice feat, although I never liked much to have fetching logic inside the stores. But I guess with Suspense it will make more sense to have it separated from components for a proper preloading.

I also love that it will re-render on further updates to those observables without any extra magic. That makes it really powerful.

That fetchingPosts extra state is awkward though, considering Suspense should kinda remove the worry about any sort of loading state, it shouldn't be needed. I do understand why it's there in this case, but it's not ideal for sure.

— You are receiving this because you were mentioned. Reply to this email directly, view it on GitHub https://github.com/mobxjs/mobx-react/issues/645?email_source=notifications&email_token=AAN4NBD6TCLMJWCTOFZSIXDQSXVFBA5CNFSM4GWQEUKKYY3PNVWWK3TUL52HS4DFVREXG43VMVBW63LNMVXHJKTDN5WW2ZLOORPWSZGOEDTTHCQ#issuecomment-552022922, or unsubscribe https://github.com/notifications/unsubscribe-auth/AAN4NBFSKUHEMVSFMNZL3WLQSXVFBANCNFSM4GWQEUKA .

danielkcz commented 4 years ago

@mweststrate It's certainly a big mind-shift for everyone.

I have modified your example to utilize the useLocalStore which drags along a scary story it won't work with Concurrent mode. In this contrived example, it seems to be working just fine. Can you perhaps show what you had in mind back then?

https://codesandbox.io/s/mobx-suspense-e3rz4

mweststrate commented 4 years ago

Yes, I'd expect useLocalStore not to work with suspense, at least the model would be very hard to reason about even if it worked. As stated before I recommend using Reacts primitives for local components to benefit from Suspense, and mobx for the external 'domain state' which should, unlike UI state, never be in flux (forked). I'll update the mobx.js.org observer documentation to reflect that make that more clear :) But that is one of the reason I really didn't emphasize the mobx-react hooks on https://mobx.js.org/refguide/observer-component.html

danielkcz commented 4 years ago

My wires must be crossed, but it's same old cryptic "it won't work" without explaining what is supposed to be broken :)

as this can theoretically lock you out of some features of React's Suspense mechanism.

And practically it means what? :)

For me the main reason to utilize useLocalStore is essentially so I can pass that store down the tree and have it coupled with actions/computeds. So yea, I don't use it strictly as the local state, but more like decentralized state management :) Is there some reason why should Concurrent/Suspense cause problems? I am failing to see it.

It would be great to have some real explanation in https://mobx-react.js.org/ as well since it's more future-oriented and I believe people are slowly using it more often as a source of information.

mweststrate commented 4 years ago

I never build anything to prove it, but if I understand suspense correctly, it will fork render trees when something is suspended, and something else isn't. That means that a component can exists twice at the same moment in time. That means that if you use useLocalStore, that store can be created twice, which can easily lead, at least, to a lot of confusion. Furthermore, when a tree is finished, suspense will (again, didn't see a concrete example of that) rebase the state updates (that is why the function version of setState is so important). However, rebasing observable updates is not really generically solvable. 

I think the interesting thing to test in your example is what would happen if that localStore is created somewhere in a component inside a suspense boundary, will you get two stores in that case (at least temporarily)? Now it is created outside, so I'd expect that case to work flawless indeed.

So, I expect, for useLocalStore to work properly, you'd need something that 'shares' the state across all instances of that component instance, but afaik React doesn't really offer a primitive for that (although it could maybe created by using a dedicated context for that an a special hook that stores them in there, if instances can be uniquely identified from within the component)

Beyond that, a very simple example where things get weird immediately is that useDeferredValue wouldn't work with a store created by useLocalStore, because both the deferred value and the current value would point to the same object (since mutations don't create new references to the store) 

mweststrate commented 4 years ago

Relevant thread: https://twitter.com/acemarke/status/1230687035045896192

danielkcz commented 4 years ago

Might be not relevant anymore too, so let's close it till there is someone who wants to investigate this.

mweststrate commented 3 years ago

Reopening this as we're getting closer to a React 18 release. Progress can be followed in #3005.

At this moment, as far as initial test projects point out, mobx-react(-lite) plays fine with React 18 in Strict mode. No warnings, errors and features like startTransaction seem to work correctly.

At this moment we don't offer MobX specific concurrent features yet. Some tearing could (theoretically) still happen. And MobX might put React in de-opt mode (theoretically) in cases as well.

That being said, there are no known problems at this moment that would block mobx-react(-lite) from upgrading or using specific React features.

That conclusion is based on some pretty flimsy research, but at least it is some status update :). No known limitations or blockers for a React 18 upgrade at this moment.

franckchen commented 3 years ago

Ok, FWIW, using suspense with observables is pretty natural if you throw when(() => expr) :)

https://codesandbox.io/s/dry-microservice-qnle3

I had read the code and tried the demo.

but i think its a little tricky. Not all scenarios are applicable.

any good idea for a common use utility functions or pattern?

ericmasiello commented 2 years ago

Reopening this as we're getting closer to a React 18 release. Progress can be followed in #3005.

At this moment, as far as initial test projects point out, mobx-react(-lite) plays fine with React 18 in Strict mode. No warnings, errors and features like startTransaction seem to work correctly.

At this moment we don't offer MobX specific concurrent features yet. Some tearing could (theoretically) still happen. And MobX might put React in de-opt mode (theoretically) in cases as well.

That being said, there are no known problems at this moment that would block mobx-react(-lite) from upgrading or using specific React features.

That conclusion is based on some pretty flimsy research, but at least it is some status update :). No known limitations or blockers for a React 18 upgrade at this moment.

Are there plans to further research and if necessary, improve mobx-react(-lite) to better support suspense features hopefully without de-opting? Or, at a minimum, a deeper write up that summarizes how mobx should and should not be used in React 18?

jamieathans commented 2 years ago

Reopening this as we're getting closer to a React 18 release. Progress can be followed in #3005.

At this moment, as far as initial test projects point out, mobx-react(-lite) plays fine with React 18 in Strict mode. No warnings, errors and features like startTransaction seem to work correctly.

At this moment we don't offer MobX specific concurrent features yet. Some tearing could (theoretically) still happen. And MobX might put React in de-opt mode (theoretically) in cases as well.

That being said, there are no known problems at this moment that would block mobx-react(-lite) from upgrading or using specific React features.

That conclusion is based on some pretty flimsy research, but at least it is some status update :). No known limitations or blockers for a React 18 upgrade at this moment.

@mweststrate @urugator Can you comment on the ramifications of the new React 18 API useSyncExternalStore on MobX?

mweststrate commented 2 years ago

Nope, I haven't been following the conversation for a long time and I don't expect to have the time to dive into the matter anywhere in the near time. If anyone wants to grok the subject be welcome :)

urugator commented 2 years ago

ramifications of the new React 18 API useSyncExternalStore on MobX

Afaik none. The result of useSyncExternalStore or previously propsed useMutableSource must be an immutable snapshot - making sure that component can re-render any time returning the same output. For a mutable state like Mobx, it requires creating deep copies, which is just incompatible with the design. The hook makes sure that all components read from snapshots that were created from the same version of the external state, so you don't see any temporal inconsistencies between components (tearing).

ericmasiello commented 2 years ago

ramifications of the new React 18 API useSyncExternalStore on MobX

Afaik none. The result of useSyncExternalStore or previously propsed useMutableSource must be an immutable snapshot - making sure that component can re-render any time returning the same output. For a mutable state like Mobx, it requires creating deep copies, which is just incompatible with the design. The hook makes sure that all components read from snapshots that were created from the same version of the external state, so you don't see any temporal inconsistencies between components (tearing).

Pardon the naive question. Does this implicitly mean MobX is incompatible with direction of React's concurrent rendering?

urugator commented 2 years ago

Pardon the naive question. Does this implicitly mean MobX is incompatible with direction of React's concurrent rendering?

In a sense that you can experience temporal inconsistencies, yes (afaik). Concurrency and shared mutable state never works together. What kind of problem this really is in practice, I dunno and from what I could find, until React 18 is out and concurrent features are being used in larger scale, nobody is absolutely sure. The question also is how these concurrent features are in general compatible with external shared state (immutable or not). I lack the knowledge, so I am speculating here, but eg. consider you have 10 components and they all access the same external state and you suspend one of them. Now you change the external state and notify these components to re-render. Ok, so what can now React do to show consistent result? Either 9 components will wait for the suspended one, so most of your UI is unresponsive or it will unsuspend the component and re-render all 10 components at once. Concurrency requires components can work independently of one another, but how do you do that if they depend on the same shared state - you either allow temporal inconsistencies or components must depend on unrelated pieces of the shared state (so technically there is no state sharing), but in such case there also can't be any inconsitencies (and therefore tearing). Please keep in mind I may be completely wrong or missing something.

mweststrate commented 2 years ago

See https://mobx.js.org/react-integration.html#you-might-not-need-locally-observable-state. TL,DR: for volatile state (selections, loading, active tabs, that kind of stuff), you might want to keep things in useState to leverage suspense features. For domain states (the thing your app is about, which gets stored on the server, etc etc); they should typically not be in some superstate across different renderings, but rather you want to render with the latest state possible (very much imho) I think MobX should be fine even in a concurrent leveraging application.

TotooriaHyperion commented 2 years ago

ramifications of the new React 18 API useSyncExternalStore on MobX

Afaik none. The result of useSyncExternalStore or previously propsed useMutableSource must be an immutable snapshot - making sure that component can re-render any time returning the same output. For a mutable state like Mobx, it requires creating deep copies, which is just incompatible with the design. The hook makes sure that all components read from snapshots that were created from the same version of the external state, so you don't see any temporal inconsistencies between components (tearing).

Pardon the naive question. Does this implicitly mean MobX is incompatible with direction of React's concurrent rendering?

If you only use mobx as a reactive wrapper(only use observable.ref instead of observable, and any update must be wrapped in action), while make sure the internal state are immutable, this is not a problem.

Always use observable.ref instead of observable is already a standard in my team. Thus we don't meet this problem.

For example:

class DomainStore {
  state = { a: 1 };
  constructor() { makeObservable(this, {
    state: observable.ref, // this.state is reactive while the data in this.state is immutable.
    act: action,
    a: computed,
  })}
  act() {
    this.state = produce(this.state, draft => {
      draft.a = 2;
    })
  }
  get a() {
    return this.state.a;
  }
}

We don't use [deep mutable state] with mobx, because it introduces more complexity and lost the advantage of immutability.

From my experience, mobx's core value is [a glitches free reactivity system(based on transaction)] that exactly matches the need of human-machine-interaction development. Deep mutable reactive state is not good enough for us to give up the advantage of immutable data.

urugator commented 2 years ago

this is not a problem

Sure it's still a problem. You need an immutable snapshot of your whole state not just it's individual parts. You can still see one immutable part of the state being inconsistent with a different immutable part. However I am not sure if immutability is strictly neccessary with useSyncExternalStore. You must provide getSnapshot function that must return a cached/memoized result. Dunno what exactly that means/implies. Nevertheless you still need to cosume the state through this hook and not access it directly as required by mobx. Maybe it can be solved somehow, dunno, available information around these things are quite limited atm.

Regarding the pattern, since I don't have a time to elaborate further and this isn't a good place to so anyway, let me just say that I do not recommend :)

mtsewrs commented 2 years ago

@urugator Do you not recommend mobx with react18 or mobx with useSyncExternalStore?

kris7t commented 2 years ago

So, basically, updates to shared state must be urgent (not in useTransition), and components must depend on the store immediately (not in useDeferredValue), otherwise tearing will occur temporarily when components with urgent updates read the non-urgently updated state from the store (but consistency should be restored by the first upcoming non-urgent re-render), right?

This is more or less what I'd expect from a shared store, anyways. In particular, I'd expect the value of the store not to change during the execution of the functional component (since it it neither async nor a generator), but change freely between (urgent) re-renders.

mweststrate commented 2 years ago

mobx-react@7.4.0 / mobx-react-lite@3.4.0 where released today, which are compatible with React 18, albeit with the constraints as mentioned in the changelog.

seivan commented 2 years ago

Not sure if this matters, but I did a test project for checking performance on how well observables jive with the new concurrency features and I must say, it wasn't bad at all. Maybe it's not heavily optimized like other libraries. But the DevUX benefits of far outweighs the compromises. You can still get great benefits from the double buffering approach they seem to be headed towards.

Assume:

A TodoList rendering a single input together with mapping over a list of todos to render<Todo todo=todo>

With 1000 <Todo todo=x /> each with their individual inputs. Writing in the TodoList input should update all the text on Todo items and that's what is being used for the value in <Todo todo=todo> input.

  const [state, setState] = React.useState(props.store.todos[0]?.text ?? "")
  const [isPending, startTransition] = React.useTransition()

      <input
        readOnly
        type="checkbox"
        checked={isPending}
      />
      <input onChange={(e) => {
        setState(e.currentTarget.value)
        props.store.todos.forEach(todo => {
          startTransition(() => {
            todo.setText(e.currentTarget.value)
          })
        })
      }
      } value={state} />

Feel free to remove startTransition to compare performance. It's a huge difference. Not to mention the isPending could inherently let you hide everything until they are done instead of showing inconsistent state. This is very very impressive, hell they've managed to build a scheduler.

guochengcheng04 commented 2 years ago

mobx@7.4.0 / mobx-react-lite@3.4.0 where released today, which are compatible with React 18, albeit with the constraints as mentioned in the changelog.

@mweststrate where can i find mobx@^7 and changelog, the latest version on npm is 6.6.0

urugator commented 2 years ago

@guochengcheng04 It was meant to be mobx-react@7.4.0 I will fix the comment.

lucaslorentz commented 2 years ago

I checked the constraints mentioned in the changelog and based on my understanding this approach would not work:

I think this is in theoretically addressable by using useSyncExternalStore and capturing the current values together with the dependency tree of every component instance.

useSyncExternalStore should return a snapshot of the data that will be used in the future/next render. That's how React basically solve the tearing problem. Before the rendering phase starts, react would take snapshots of all stores that changed, and later render the components using the snapshot data. That ensures all components are rendered with the same version of the store.

We could capture the current values of the last dependency tree to make a snapshot, but then if the next render reads a new dependency, it will be reading the current value. That would lead to an even worse problem, inconsistencies/tearing within the same component, instead of across components.

I don't think you can fully support React 18 concurrent rendering without full store snapshots.

lucaslorentz commented 2 years ago

I tested today the following snapshot mechanism in a POC reactive state library I'm doing. It should work for Mobx and React-Solid-State as well. @mweststrate @ryansolid

Snapshot concept The concept is basically using dynamic snapshots that starts empty, and are populated during any observable write. To read the snapshot, you read values stored in it, falling back to the current observable value. But we still need to solve some problems, information stored in snapshots will be redundant, and once you have a lot of snapshots, it will be costly to update all of them. To solve that, we can link previousSnapshot->nextSnapshot, and only update the latest snapshot during observable writes. That also avoids storing redundant information. To read from a snapshot now, you read the values stored in it, falling back to the next snapshots, falling back to the current observable value. That means, the older the snapshot the slowest it is to read it because of the hops. But that shouldn't be an issue, as React should only use relatively recent snapshots to render. Snapshots could even have an "optimize" function that reduces its hops by copying all values from the next snapshots to itself, and then updating its link to point to latest snapshot. But this API will not be used in React integration. Another advantage of linking that way is that as soon as there is no ref to an old snapshot, all changes that could be read from it will be garbage collected along with it. A sketch of what the API could look like: ```js const obs = observables({ foo: 1, bar: 1 }); obs.foo; // Reads 1 from observable let snapshot1 = createSnapshot(); // Creates an empty snapshot and point latestSnapshot weakRef to it snapshot1.read(() => obs.foo); // Read 1 from observable obs.foo = 2; // Writes to observable, and add oldValue (1) to latestSnapshot (snapshot1) snapshot1.read(() => obs.foo); // Read 1 from snapshot1 let snapshot2 = createSnapshot(); // Creates an empty snapshot, link snapshot1 to snapshot2, and update latestSnapshot weakRef obs.foo = 3; // Writes to observable and add oldValue (2) to snapshot2 obs.bar = 2; // Writes to observable and add oldValue (1) to snapshot2 snapshot1.read(() => obs.foo); // Read 1 from snapshot1 snapshot1.read(() => obs.bar); // Read 2 from snapshot2 snapshot1 = null; // Garbage collect snapshot1 and all it's values snapshot2.read(() => obs.bar); // Read 2 from snapshot2 snapshot2 = null; // Garbage collect snapshot2 and all it's values obs.bar = 3; // Write to observable, no need to update snapshots because latestSnapshot weakref is empty ``` Once a value is written to a snapshot, it cannot be overwritten in that same snapshot. First value wins. Use `WeakMap` to store the values inside snapshots. That way if an observable is not referenced anymore, it means it cannot be read anymore, and its data will be removed from snapshots. If browser doesn't support WeakMap, a Map will work well as well and will be GC once the snapshot is not referenced anymore. I think any read from a snapshot should work, including computeds. Write operations should be blocked within a snapshot read. Implement `createSnapshot()` so that it returns the latestSnapshot if it has no values yet, otherwise it creates a new snapshot. Once you have that, it should be easy to be fully compliant to React 18 concurrent rendering. Sketch: ```js function observer(renderFn) { return wrapperComponent(props) { const onStoreChangeRef = useRef(); const snapshot = useSyncExternalStore( useCallback((onStoreChange) => { onStoreChangeRef.current = onStoreChange; return () => (onStoreChangeRef.current = undefined); }, []), () => createSnapshot() ); const renderResult = snapshot.read(() => renderFn(props)); // Subscribe to all detected dependencies and call onStoreChangeRef.current when they change // That will inform react to take snapshots before the next renders return renderResult; }; } ``` For browsers that don't support weakref (IE?), you can just force a new snapshot after latestSnapshot reached a specific size. That will give the GC a chance to collect changes that don't belong to a referenced snapshot. I think the last problem to mitigate is that components that are not re-rendered often will hold a reference to a very old snapshot. So it's good to force all components to rerender every now and then to allow the snapshots to be GC. The re-render trigger could be: - a timer - how many snapshots were created after the component was rendered - how many observable writes happened after component was rendered This was super long :-) I hope it is useful
urugator commented 2 years ago

@lucaslorentz Do I understand correctly you don't create any shallow/deep copies. Eg obs in your example is the same object reference for all snapshots. How do you handle structural changes like:

snapshot1.read(() => obj.prop);
delete obj.prop

snapshot1.read(() => array[0]);
snapshot1.read(() => array.map(fn));
snapshot1.read(() => Object.keys(obj));
lucaslorentz commented 2 years ago

@urugator Good point. Every change would need to go to snapshots and be read from snapshots later, including Object keys, array lengths, and so on.

I'm not very familiar with Mobx internals, but if obj is a Proxy, every new key added to it should update latest snapshot with something like: obj, $keys, ['foo', 'bar']

Also, intercept ownKeys in Proxy and read from snapshots.

Edit: I'm doing observables only with proxies, so I can intercept Object.keys as well. In case of mobx, I don't think ObservableArray classes for example can intercept Object.keys, so, the approach might not be that compatible with Mobx in the end.

lucaslorentz commented 2 years ago

@urugator This is what I have so far as proof of concept: https://codesandbox.io/s/github/lucaslorentz/react-observable-test

Use the UI to monitor snapshots, insert todos, and then time travel by clicking on the snapshots.

As you can see in those tests object structure is captured in snapshots and proxy behaves well enough to pass jest assertions.

urugator commented 2 years ago

@lucaslorentz I have a generic question unrelated to the code you posted. It's not meant as a criticism, it's genuine question, something I honestly don't know the answer to and I would like to understand: How does taking snapshots help with react concurrent mode? Let me elaborate: It's all about tearing - tearing occurs when two (or more) components read different versions of the same state. So the solution is, that you never let it happen - if two useSyncExternalStore are notified in the same "tick" (therefore the Sync part), react forces all of them to use the latest snapshot. For a single global state this is always the case - if all components read from the same global state, then all concurrency is effectively disabled ... unless ... you use selectors + immutability: Selectors can slice up the state into idependent parts. A single state update can notify multiple useSyncExternalStore, but most of the selectors will return the same value as before, therefore react can keep using the previous snapshot. In our case we don't notify all useSyncExternalStore that depends on some observable, but only these that depedend on changed observables - if React suspended (freezed in time) other components, we don't have to worry about these getting out of sync, because they would read the same values anyway (since they weren't notified). So when or how can React use different snapshots of our shared mutable state, without causing tearing (inconsitencies in the output)?

lucaslorentz commented 2 years ago

@urugator My understanding is that a react render cycle now can be split across multiple ticks, yielding the CPU to other browser tasks in between. The simplest scenario I can think of is:

urugator commented 2 years ago

@lucaslorentz I think I am missing something obvious. Consider A and B depends on the same state (state.foo). A renders with state version 1. state.foo yields 1 Http request finishes and updates state. state.foo = 2 B renders with state version 2. state.foo yields 2 If you now apply rendering output to the DOM, you got a tear, so you have to first re-render A with the current snapshot.

So either you render different versions of the same state and you get a tear. Or you always render with the same version of the same state - then snapshots are useless, because they're always thrown away and only the "current" one is being used. Or A and B don't depend on the same state - then snapshots are useless, because you never read from different versions of the same state.

EDIT: Oh, perhaps B ignores the update and renders with previous version, flushes to DOM and at the same time a new update is scheduled? EDIT2: So basically it's not about one component being able to "lag" behind another, but about the whole tree lagging behind current external state - all useSyncExternalStore instances together are using the same snapshot version, but the version is lower then the current state version... ?

lucaslorentz commented 2 years ago

If you now apply rendering output to the DOM, you got a tear, so you have to first re-render A with the current snapshot.

@urugator That's right, if you re-render A before updating DOM it will not tear. But react will not render the same component twice in the same rendering cycle. If it goes that path, a fast changing state (clock for example), that changes quicker than a render cycle, would cause the rendering cycle to never finish and react would never update DOM.

Oh, perhaps B ignores the update and renders with previous version, flushes to DOM and at the same time a new update is scheduled?

Yes. That's what it should do. "Ignoring the update" is only possible if it has a snapshot of how the state was at the beginning of the rendering cycle. And yes, components renders with old values even though they're scheduled to be rendered again in the next cycle with new values.

urugator commented 2 years ago

fast changing state (clock for example), that changes quicker than a render cycle

Individual clock ticks aren't synchronous, yielding in between ticks to render is expected. The concern is that all components should always display the same time - even though they may not immediately re-render with every tick of the clocks. My point was, that all components must see the same version of the clock, therefore you don't need multiple snapshot versions (snapshot for each component) - you know - either they all lag together or they don't lag at all...

components renders with old values while they're scheduled to be rendered again

I am aware react can run render with outdated state/props, but afaik with synchronous updates it won't yield to browser as long as there are updates scheduled. That's what startTransition is for or not?

lucaslorentz commented 2 years ago

@urugator I think you're right. Looks like useSyncExternalStore already fixes the tearing (without snapshots). Please disregard my comments above. I will do some testing.

seivan commented 2 years ago

What are the performance impacts? As of now, Mobx integrates quite well with the new scheduler and its hooks for transient changes if you need to yield for other updates, how would the suggested changes add to that?

lucaslorentz commented 2 years ago

@seivan Didn't do any benchmarks. If ever added, snapshots should be optional. Depending on which async rendering features snapshots unlock, your application might actually feel more responsive even with snapshots overhead.

@urugator I completely misunderstood how useSyncExternalStore works. It is as you said above, it just forces a sync render of all affected components. test sandbox I thought useSyncExternalStore would keep data in sync (via snapshots) throughout an async rendering cycle. I'm a bit disappointed with that. 😄 Turns out I was the one missing something. No tearing risk with useSyncExternalStore.

Since useSyncExternalStore is inherently sync. In order to take advantage of react concurrency, it needs to be combined with useDeferredValue. And then snapshots come into play again whenever you want to defer the rendering of complex objects. StartTransition also integrates with UseDeferredValue. Is useDeferredValue already supported by Mobx? It would be nice to just wrap low-priority components with observer.deferred(renderFunction) in order to achieve this: test sandbox

urugator commented 2 years ago

Thank you for the investigation. My conclusion is that we can make MobX tearing free just by using useSyncExternalStore, where getSnapshot returns global state version (unique number/symbol/reference), which is updated on any state mutation. Can anyone confirm/refute? Would there be any side-effects (unnecessary re-renders)?

low-priority components with observer.deferred(renderFunction)

Still not sure about it: If you pass state (snapshot) from deferred component to it's non deferred child, what version will the child read? If you capture state (snapshot) in a callback that is invoked later/elsewhere (effect/event/render), what version will it read? Generally functions/methods might be tricky as they are stateful (unless pure) and may access whatever. I worry about the fact that same object reference can read from different snapshots, eg how does it impact memoization - if I pass different snapshot to memoized function it should invalidate...

k-ode commented 2 years ago

Fwiw, I did some experiments with mobx at this repo: https://github.com/dai-shi/will-this-react-global-state-work-in-concurrent-rendering

Regular, unpatched observer failed 4 tests and suffered some tearing issues for a couple of more basic tests.

I then patched observer and replaced forceUpdate with:

const reactionTrackingRef = React.useRef(null);
let forceUpdate;
if (!reactionTrackingRef.current) {
    const newReaction = new Reaction(observerComponentNameFor(baseComponentName), function () {
        if (trackingData.mounted) {
            version = version + 1;
            forceUpdate();
        }
        else {
             trackingData.changedBeforeMount = true
        }
    });
    const trackingData = addReactionToTrack(
        reactionTrackingRef,
        newReaction,
        objectRetainedByReact
   );
}
useSyncExternalStore(
    useCallback((onStoreChange) => {
        forceUpdate = () => {
            onStoreChange();
        }
    }, []),
    () => version
);

This only failed the two level 3 tests (5 & 6), which seems to be more in line with the current level support of some other major state libraries (see this table).

I did some quick manual tests and did not detect any extra renders.

urugator commented 2 years ago

@k-ode Thank you, that's promising. However I think version = version + 1; must happen on mutation (in observables), not as part of reaction's effect.
Firstly reaction's effect can be delayed by configured reactionScheduler, so in principle thare could be a render in between mutation and effect reading wrong version. Secondly you can mutate an observable before it's observed (therefore no version update), then non-observable state/props change causes render, some condition changes, observable is read and observed, but useSyncExternalStore reports old version.

imjordanxd commented 1 year ago

Are there any updates regarding mobx supporting React concurrent features?

seivan commented 1 year ago

Are there any updates regarding mobx supporting React concurrent features?

What particular updates are you looking for?

Mobx works in conjuction with the scheduler for rendering already https://github.com/mobxjs/mobx/issues/2526#issuecomment-1129927888

It will cancel obsolete states rendering/reductions and defer state changes.