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

Support concurrent features React / React 18 #2526

Closed
mweststrate opened this issue Feb 11, 2019 · 58 comments
Closed

Support concurrent features React / React 18 #2526

mweststrate opened this issue Feb 11, 2019 · 58 comments
Labels
💬 discuss 🎁 mobx-react-lite Issue or PR related to mobx-react-lite package

Comments

@mweststrate
Copy link
Member

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: mobxjs/mobx-react-lite#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
Copy link
Member Author

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

@danielkcz
Copy link
Contributor

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.

@danielkcz danielkcz pinned this issue Jun 8, 2019
@mweststrate mweststrate unpinned this issue Jun 11, 2019
@bsmith-cycorp
Copy link

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

@danielkcz
Copy link
Contributor

danielkcz commented Oct 24, 2019

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
Copy link

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
Copy link
Contributor

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

@mweststrate
Copy link
Member Author

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

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

@mweststrate mweststrate changed the title [On hold] Support concurrent mode React Support concurrent mode React Nov 8, 2019
@danielkcz
Copy link
Contributor

@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
Copy link
Member Author

mweststrate commented Nov 8, 2019 via email

@danielkcz
Copy link
Contributor

@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
Copy link
Member Author

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
Copy link
Contributor

danielkcz commented Nov 9, 2019

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
Copy link
Member Author

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
Copy link
Member Author

@danielkcz danielkcz transferred this issue from mobxjs/mobx-react Oct 18, 2020
@danielkcz
Copy link
Contributor

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

@danielkcz danielkcz added 🎁 mobx-react-lite Issue or PR related to mobx-react-lite package 💬 discuss labels Oct 18, 2020
@mweststrate mweststrate reopened this Jun 24, 2021
@mweststrate mweststrate changed the title Support concurrent mode React Support concurrent mode React / React 18 Jun 24, 2021
@mweststrate mweststrate changed the title Support concurrent mode React / React 18 Support concurrent features React / React 18 Jun 24, 2021
@mweststrate
Copy link
Member Author

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
Copy link

franckchen commented Aug 17, 2021

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
Copy link

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
Copy link

jamieathans commented Dec 12, 2021

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
Copy link
Member Author

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 :)

@lucaslorentz
Copy link

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
Copy link

lucaslorentz commented Aug 24, 2022

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:

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<Observable, any> 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:

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
Copy link
Collaborator

@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
Copy link

lucaslorentz commented Aug 25, 2022

@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
Copy link

@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
Copy link
Collaborator

urugator commented Aug 29, 2022

@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
Copy link

lucaslorentz commented Aug 30, 2022

@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:

  • Start render cycle (already have snapshots as it decided what to re-render based on snapshot equality)
  • Render A
  • Http request finishes and updates state
  • Render B
  • Apply rendering to dom

@urugator
Copy link
Collaborator

urugator commented Aug 30, 2022

@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
Copy link

lucaslorentz commented Aug 30, 2022

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
Copy link
Collaborator

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
Copy link

lucaslorentz commented Aug 30, 2022

@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
Copy link

seivan commented Aug 30, 2022

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
Copy link

lucaslorentz commented Sep 2, 2022

@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
Copy link
Collaborator

urugator commented Sep 4, 2022

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
Copy link

k-ode commented Sep 15, 2022

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
Copy link
Collaborator

@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
Copy link

Are there any updates regarding mobx supporting React concurrent features?

@seivan
Copy link

seivan commented Nov 22, 2022

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 #2526 (comment)

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

@imjordanxd
Copy link

imjordanxd commented Dec 5, 2022

Hi, @seivan. I'm particularly interested Mobx's alignment with this repo. Mobx is blocking us from upgrading to React 18

@kubk
Copy link
Collaborator

kubk commented Dec 5, 2022

@imjordanxd It might be useful for you: dai-shi/will-this-react-global-state-work-in-concurrent-rendering#63

The reason why it's rejected is that implementation doesn't use a common reducer (which is required for this repo but isn't required for React)

@urugator
Copy link
Collaborator

urugator commented Dec 5, 2022

Just to let you know, I am working on some improvements. I've managed to rewrite observer for functional components, so it uses useExternalSyncStore. There aren't many tests, but it seems to be working fine. We have to do some things that I am not sure are completely cool, like calling onStoreChange during subscribe, but as I said it seems to be working atm.
What remains is concurrency support for class components, which I would like to include in this change as well. One thing I am thinking about right know is, that we won't be able to use FinalizationRegistry here, because the only thing we can register is this, but since it's accessible by user, I think it's too easy to leak it outside the component.

@urugator
Copy link
Collaborator

urugator commented Dec 18, 2022

The effort is here #3590, should be more or less complete.

@ericmasiello
Copy link

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.

@k-ode do you mind sharing the code you used to test MobX against these tests? I recall there is some scaffolding required

@k-ode
Copy link

k-ode commented Feb 20, 2023

@ericmasiello Sure, here it is (most likely out of date though).

src/mobx/index.js

import React, { useCallback } from 'react';
import { observable, runInAction } from 'mobx';
import { observer } from 'mobx-react';

import {
  reducer,
  initialState,
  selectCount,
  incrementAction,
  doubleAction,
  createApp,
} from '../common';

const state = observable(initialState);

const useCount = () => selectCount(state);

const useIncrement = () => useCallback(() => {
  const newState = reducer(state, incrementAction);
  runInAction(() => {
    Object.keys(newState).forEach((key) => {
      state[key] = newState[key];
    });
  });
}, []);

const useDouble = () => useCallback(() => {
  const newState = reducer(state, doubleAction);
  runInAction(() => {
    Object.keys(newState).forEach((key) => {
      state[key] = newState[key];
    });
  });
}, []);

export default createApp(useCount, useIncrement, useDouble, React.Fragment, observer);

src/common.js

diff --git a/src/common.js b/src/common.js
index 017acbf..ffe60a3 100644
--- a/src/common.js
+++ b/src/common.js
@@ -80,7 +80,7 @@ export const createApp = (
     return <div className="count">{count}</div>;
   });
 
-  const Main = () => {
+  const Main = componentWrapper(() => {
     const [isPending, startTransition] = useTransition();
     const [mode, setMode] = useState(null);
     const transitionHide = () => {
@@ -142,7 +142,7 @@ export const createApp = (
         <div id="mainCount" className="count">{mode === 'deferred' ? deferredCount : count}</div>
       </div>
     );
-  };
+  });
 
   const App = () => (
     <Root>

@penx
Copy link

penx commented Oct 6, 2023

Slightly related as there's a lot of useSyncExternalStore discussion here, I wonder if this would be a useful export from mobx-react/mobx-react-lite, so that observables can be used in hooks outside of an observer?

export const useSelector = <T>(selector: () => T): T => {
 return useSyncExternalStore(autorun, selector);
}

Discussion here #3589

@mweststrate
Copy link
Member Author

closing as landed.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
💬 discuss 🎁 mobx-react-lite Issue or PR related to mobx-react-lite package
Projects
None yet
Development

No branches or pull requests