-
Notifications
You must be signed in to change notification settings - Fork 46.9k
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
[Concurrent] Safely disposing uncommitted objects #15317
Comments
No need to ping him, he knows about it. Either way, I wanted to ask for a more generic solution, not only related to a MobX. I can imagine similar problems will be happening all over the place once people start preparing for Concurrent. It feels like that React is not giving enough tools to tackle the problem gracefully. |
I’m tagging him precisely because “he knows about it” and because we’ve had conversations in the past about it. :-) Given that he knows constraints of both I thought his input could be valuable. As for your question — the design tradeoff we’re going for is that we don’t have a cleanup phase for renders that never committed. Rendering isn’t supposed to “allocate” resources that have to be cleaned up later. Instead we rely on the garbage collector. Resources that have to be disposed are only safe to allocate after the first commit. I don’t know for certain if tradeoffs chosen by MobX are compatible or not with this approach, as I don’t know enough about MobX. Can you explain what “reactions” are and why GC isn’t sufficient for them? Or why creating them can’t wait until after the first commit? |
Well, I don't know exact internals how it works, it's very entangled there, but my assumption is that Reaction kinda attaches itself to observable variables present in the callback function. So as long as those variables exist, the Reaction won't be GCed. Those variables can definitely exist outside the scope of the component, that's kinda point of the MobX. I think we have managed to figure out some way around it. Custom GC :) Basically, we globally keep the list of created reactions (in WeakMap) along with a time of their creation. If Reaction gets committed, all is fine and it gets removed from the list. A global timer takes care of cleaning up those that weren't committed in a given time. I am wondering if you have some idea for a good "timeout" period? I mean time from render to commit. For now, we are thinking like 1 second. I assume it should be a long enough period even on a really slow computer. Is it bad idea to rely on that? |
@RoystonS wrote a good explanation of that. We could end up in very ugly situations ... |
Mobx automatically manages subscriptions based on usage. const disposer = reaction(
() => {
// accesses observables, creating subscriptions
render()
},
() => {
// a side effect to invalidate the component
setState() // or forceUpdate()
}
)
The subscriptions are determined by rendering logic, so we would have to re-run the rendering logic in effect (double render).
While reactions come and go (together with react's component instances), the observable objects possibly stay for the whole app lifetime and they hold references to reactions (preventing them from being GCed). |
Basically: we're trying to avoid double-rendering all MobX-observer components. The only time we actually need to double-render is if StrictMode/ConcurrentMode began rendering our component but then never got to the commit phase with us, or if something we're observing changed between initial render and commit. Taking a double-render hit on every component to cope with those (hopefully edge) cases is something we'd like to avoid. |
@gaearon @urugator's description is pretty accurate. Whether it should be fixed through a React life-cycleish thing or by doing some additional administration on MobX side (timer + custom GC) have both it's merits. So let's leave that aside for now, and just focus on the use case: Basic conceptsBut the reason why immediate subscription is important to MobX (as opposed to a lifecycle hook that happens after render, such as useEffect) is because observables can be in 'hot' and 'cold' states (in RxJS terms). Suppose we have:
Now, the expression
MobX without concurrent modeWith that I mind, how
So what is the problem?In concurrent mode, phase 2, disposing the reaction, might no longer happen, as an initial render can happen without matching "unmount" phase. (For just suspense promise throwing this seems avoidable by using Possible solutionsSolution 1: Having an explicit hook that is called for never committed objects (e.g. Solution 2: Have global state that records all created Solution 3: Only subscribe after the component has been committed. This roughly looks like;
The downsides:
Hope that explains the problem succinctly! If not, some pictures will be added :). |
Does MobX have bigger problems in Concurrent Mode? How valuable is it to fix this in isolation without addressing e.g. rebasing? |
I think the later problem can be largely avoided by making a clear
distinction between component own state (use useState instead of MobX) and
state that lives outside the component tree (external domain state).
If I am not mistaken, my intuition is that rebasing only makes sense for
'volatile ui state' such as the state of pending requests, switching tabs /
pages, charts etc. But state that is domain specific (e.g. a collection of
todo's), typically doesn't need rebasing as that would lead to weird
behavior for the end-user?
…On Wed, Apr 10, 2019 at 4:36 PM Dan Abramov ***@***.***> wrote:
Does MobX have bigger problems in Concurrent Mode? How valuable is it to
fix this in isolation without addressing e.g. rebasing?
—
You are receiving this because you were mentioned.
Reply to this email directly, view it on GitHub
<#15317 (comment)>,
or mute the thread
<https://github.com/notifications/unsubscribe-auth/ABvGhOMREb3lbrEL4q9sccIXfNj5f5vfks5vffcHgaJpZM4ccUAP>
.
|
Any picture? Thanks~ :) |
We have a very similar issue to MobX's with 🍉 WatermelonDB, but even more tricky because of asynchronicity. When connecting the data source to components, we subscribe to the Observable. This has to happen directly in render, because:
|
We have a similar situation in Meteor, where we set up a computation and its observers the first time it's run. If we use What I ended up doing is something based on I originally set the timeout to 50ms, assuming commit would happen relatively quickly, but in testing in concurrent mode 50ms missed almost all commits. It often took longer than 500ms to commit. I've now set the timeout to 1000ms. I also saw that there are a lot of discarded renders for every commit, between 2-4 to 1. (I have no idea how to write tests for all this, this is based on some counter/console logging.) Since there is a lengthy gap between render and commit, I had to decide what to do for reactive changes that happened in renders which will never be committed. I could either run the user's reactive function for every change, knowing it might be run by 4 different handlers without doing anything, or block reactive updates until the component was committed. I chose the latter. I tried a workaround using BTW, one additional thing we found challenging was trying to leverage a |
A quick additional observation - one of the things I have in place is a check to make sure that if the timeout period elapsed (currently 1 second), it will make sure to restart our computation when/if the render is eventually committed in |
Using dual-phased subscriptions (phase 1: collect observed object/key pairs and their current values, phase 2: subscribe to them), you can avoid any timer-based GC and postpone subscribing until React commits. In phase 2, be sure to check for changed values before subscribing, so you can know if an immediate re-render is required. This is what I did with the |
We'd have to modify a bunch of stuff in Meteor core to convert that to a dual-phase subscription model like that (although we can fake it by just setting up the subscription, running the computation, then disposing of it immediately, to restart it again in |
The issue with Meteor is that its tracking system doesn't associate "observable dependencies" with state. In other words, the
Wouldn't you miss changes between render and commit if you did it that way?
Yep, that's the problem with not using observable proxies that track every property access. |
Another good use case for |
Yes. It's actually that way now even with the timer. If an update comes in before the render is committed (which takes about a half a second in my testing), I had to make a choice - run the user's reactive function for every uncommitted computation, or don't run any of them until the render is committed (I detect if an update happened, but don't run user code until later) - I chose the latter. In the example I gave in the other comment, I'm saying we could simply always rerun the computation and user function again in
I had thought it would be useful if we could get some kind of deterministic ID for whatever the current leaf is, which is consistent for all renders of current component, even those that would not be committed. Then I could use my own global store. I think someone explained that couldn't happen though, for various reasons. (Can |
This problem also exists without Concurrent mode enabled, because no-op state changes can trigger a render that bails out before effects are ever called. Source |
We've had to find a solution to this for The solution to this can be found here: https://github.com/kitten/react-wonka/blob/master/src/index.ts I'll leave some simplified / generic code here in case someone else has to implement it from scratch, since we haven't found a generic solution yet that could be shared (like an alternative to Interruptible Subscriptions implementation walkthroughOn mount we start the subscription if it hasn't started yet: const useObservable = observable => {
const subscription = useRef({
value: init,
onValue: value => {
subscription.current.value = value;
},
teardown: null,
task: null,
});
if (subscription.current.teardown === null) {
// Suppose we have a subscription that needs to be started somehow, with a teardown:
subscription.current.teardown = observable.subscribe(value => {
subscription.current.onValue(value)
});
}
return subscription.value;
}; Then we schedule an immedite teardown/cancellation of the subscription using import {
unstable_scheduleCallback,
unstable_cancelCallback,
unstable_getCurrentPriorityLevel,
} from 'scheduler';
const useObservable = observable => {
// ...
if (subscription.current.teardown === null) {
subscription.current.teardown = observable.subscribe(value => {
subscription.current.onValue(value)
});
subscription.task = unstable_scheduleCallback(
unstable_getCurrentPriorityLevel(),
subscription.teardown
);
}
return subscription.value;
}; On interrupted rendering we won't have any effects run ( const useObservable = observable => {
// ...
if (subscription.current.teardown === null) {
subscription.current.teardown = observable.subscribe(value => {
subscription.current.onValue(value)
});
subscription.task = unstable_scheduleCallback(
unstable_getCurrentPriorityLevel(),
subscription.teardown
);
}
useLayoutEffect(() => {
// Cancel the scheduled teardown
if (subscription.current.task !== null) {
unstable_cancelCallback(subscription.current.task);
}
// We also add the teardown for unmounting
return () => {
if (subscription.current.teardown !== null) {
subscription.current.teardown();
}
};
}, []);
return subscription.value;
}; Lastly we'd like a normal const useObservable = observable => {
// ...
// We add an updater that causes React to rerender
// There's of course different ways to do this
const [, setValue] = useReducer((x, value)) => {
subscription.current.value = value;
return x + 1;
}, 0);
if (subscription.current.teardown === null) {
// ...
}
useLayoutEffect(() => {
// ...
}, []);
useEffect(() => {
// In useEffect we "adopt" the subscription by swapping out the
// onValue callback with the setValue call from useReducer above
subscription.current.onValue = setValue;
// If we've cancelled the subscription due to a very long interrupt,
// we restart it here, which may give us a synchronous update again,
// ensuring that we don't miss any changes to our value
if (subscription.current.teardown === null) {
subscription.teardown = observable.subscribe(value => {
subscription.onValue(value)
});
}
// We'd typically also change dependencies and update a Subject,
// but that's missing in this example
}, []);
return subscription.value;
}; Lastly we need to update this to work on server-side rendering. const isServerSide = typeof window === 'undefined';
const useIsomorphicEffect = !isServerSide ? useLayoutEffect : useEffect; And add some code that immediately cancels the subscription on the server-side: const useObservable = observable => {
// ...
if (subscription.current.teardown === null) {
subscription.teardown = observable.subscribe(value => {
subscription.onValue(value)
});
if (isServerSide) {
// On SSR we immediately cancel the subscription so we're only using
// synchronous initial values from it
subscription.current.teardown();
} else {
subscription.current.task = unstable_scheduleCallback(
unstable_getCurrentPriorityLevel(),
subscription.current.teardown
);
}
}
// ...
}; Afterwards we added some code that handles our updates to an input value. tl;dr We start the subscription on mount synchronously, schedule the teardown using If note is that this only works as well as it does for us because we're already handling starting operations only once if necessary and avoiding duplicates, so in a lot of cases our subscriptions are already idempotent. I can totally see that this isn't the case for everyone, so a lot of subscriptions with heavy computations won't be as "safe" to run twice in rapid succession. This is a little inconvenient to say the least. I'd love if either |
@FredyC At a quick glance, the above might be cleaner than the current approach in lite 2. Thoughts? (Probably better to discuss further in the repo, just wanted to cc you in!) |
That's all fair enough. It's React team's library, and React team has the right to say how React is supposed to be used and does not need to support everyone else's alternative paradigms. That being said,
That may be the intent, but by providing ways to define method-like things (f.e. Actually, the "instance" is the cumulative set of scopes from every time that the FC has been called in its life time (a very important but subtle difference, sometimes a confusing one). The similarity of FCs to classes may be an unintentional invitation for code that creates state at the beginning. The render (f.e. the FC return value) is the only thing that an external dependency-tracking system can rely on for tracking dependencies used for rendering. Something like |
To solve this, what I did is to treat my own objects to have a dispose/cleanup logic, and created a hook similar to what @dai-shi created: function useDispose(dispose) {
// schedule a cleanup, 16 could be a good number.
const timeout = setTimeout(dispose, 64);
// cancel the scheduled cleanup if the side-effects went through.
useEffect(() => {
clearTimeout(timeout);
}, [timeout]);
} This worked, in my case, specially just to prevent an external source (e.g. an object that subscribes to multiple events) from subscribing twice. |
This mental model is not correct - some render calls can be freely dropped by React so it's a false assumption to think about an instance being a cumulative set of scopes from every time that FC has been called in its lifetime. If anything it's a cumulative set of committed scopes. This distinction is very important in understanding how to obey React rules. It has been mentioned here in the thread already - but those rules are old. They just start to be way more important now but breaking those rules probably has caused mem leaks and similar problems in the past already. I can't say about the community as a whole but it has always been quite clear for me that |
You are right that it would be easy to add such a hook. The reason we haven't done it is that it would have a significant negative impact on performance.
Unless I'm reading this comment wrong, I think it's a misconception. A component can already return
|
@bvaughn @gaearon Are you considering a system for push-based subscriptions that coöperate with the component lifecycle by any chance? I still find it to be a little odd that most systems are rather pull-based once they instantiate their state, i.e. outside of effects we prefer to be able to get either "snapshots" ( I think a lot of problems could be solved if there was a way to make push-based systems work better together with React's hooks & lifecycles. This would indeed be rather novel, but I think it'd fix this. This is especially important for derived event sources, where the state isn't pull-accessible. Currently in However, if state was tied to some kind of observable source from the start of a component lifecycle, similar to how |
Here's the issue in Meteor. Meteor uses a computation for setting up observers on "reactive" sources, based on accesses to those sources on the first run of the computation function. It does that in the render pass now, with some caveats. We could simply turn off the observer for the render pass, then run it again in The question is, if we have to run our setup code a second time on commit, is it really earning performance, or just moving the performance hit off to other parts of the app? Then again, in most cases in Meteor, it's probably not a big deal to run the computation again - most computations are relatively cheap, unless there is some huge complex mini mongo query or something I went down the rabbit hole of trying to pull that out in to a data source, as described in the "suspense for data" section in the React docs, but that has a number of problems. Primarily, it complicates what is otherwise a clear and easy API for us (with |
@CaptainN : fwiw, that "might need to run again because state could have changed before the effect" part sounds exactly like how the new |
I've also hit a few times the case of a component needing to instantiate objects tied to the WASM heap. In those cases there is no garbage collection to rely on for freeing uncommitted resources (not yet, at the very least), so the only choices seem to be to enter a world of double-renders with [edit] I made a search on "WASM" before sharing my experience, but I think the following comment is pretty much what the React team would answer to that: #15317 (comment)
Unfortunately it'll be years before WeakRef reaches general availability. I guess it can work if CM takes years too 😛 |
@markerikson I did look at that a few months back, but wasn't sure exactly what that does or how (or if it was ready for use) - is that finalized or still in RFC? Last time I looked at it, I thought it would drop any component using it in to a "deopt" mode, opting out of the benefits of concurrent mode for that component, and wanted to see if I could keep the benefits. It's on my list to pursue a |
@CaptainN : Not saying it's going to magically fix your problems, but I think you should go review the RFC and PR discussions around it and give it a shot to see if it does potentially address your use case. |
@gaearon Functions components that use hooks are not pure in the most general cases (i.e., unless you find a hook that is actually pure). I am not sure what is the render method you are referring to. I assume you are referring to the render method use in React's class API? That render method should indeed be a pure function of the parameters it is passed, as it is documented. But then there seems to be some confusion in your next assertion:
The component contract you are referring to is the class component contract. If you get rid of class components, you get rid of that contract. Then function components (if they use hooks) renounced to purity so you can't really refer there to a pure render here. Given that functional components and concurrent mode are a new thing, why not clarify the new contracts for the new thing? That takes possibly wrong assumptions out of the way together with the bugs they create. This especially applies to concurrent mode as it seriously changes what was assumed to be a contract in former React versions. Some people continue to think that it is React as usual apart from cosmetic changes but it is really a fundamental change. I am not sure this has been captured yet in the documentation. |
class ClassComponent extends React.Component() {
// ...
render() {
// This is a render method and should be pure.
}
}
function FunctionComponent(props) {
// This is also a render method and should be pure.
// Built-in hooks (e.g. React.useState) are an exception
// because they are explicitly managed by React
// and so are more like locally created variables.
} |
Can't MobX just use a FinalizationRegistry @mweststrate ? |
@bvaughn The second case (FunctionComponent) is a function. Methods usually refer to functions within classes (or by extension objects). Then, again, it is not accurate to say that function components should be pure when them being impure is far from being an edge case (due to hooks usage). There is no such thing like an impure function that is like a pure function except this or that. People get confused precisely because we use the same words to mean different things. In the context of computer science, functions that may be impure are usually referred to as procedures. So you may call a function component a render procedure if you will, but not a pure function in a large majority of cases (when they use hooks). Which is one of my points and the reason why people get confused by functional programming notions decaffeinated with exception and metaphors. |
I'm not looking to debate the semantics of "methods" or "functions", just addressing your comment that you were "not sure what is the render method". The answer is: Dan was referring to both class components and function components. |
I hear that the documentation does not do a good enough job defining “purity”. This is something we’re keeping in mind for the upcoming docs rewrite. On the other hand, if you take the technical argument that “function components are impure because of Hooks”, I don’t think you can claim that “the class render method is pure” either. After all, it reads |
@Gaeron you are indeed right. The render class method is often impure too. That escaped me. I thought it received props as an argument, but it is true that it gets it from But still, whatever it is that you mean by With concurrent mode, I believe that accuracy is going to be even more important. The hard part is going to have developers build the mental model that shortens debugging sessions and reduce the need for technical support. Of course, examples of usage will help a ton and you have good stuff already on the documentation site. But having consistency in words and meaning (we call that ubiquitous language in TDD) goes a long way too. It was for instance important that React introduced different words for rendering and for committing in order to explain the different operations that React does at different moments and it would be unfortunate to mix one with the other randomly. |
Random update: in mobx-react-lite we are now experimenting with using a FinalizationRegistery as suggested (and executed) by @Bnaya / @benjamingr. So far it looks quite promising and something that could be abstracted away in a generic hook (possibly with a timer based fallback). The two liner summary of the approach is that in the component some a local object is allocated and stored using useState: |
Interesting. So, it doesn't even check if it's committed with useEffect. Should we expect JS engine not to garbage collect objects very soon if there's enough free memory? |
It does useEffect to mark comitted, left all the implementation details out
😉
…On Tue, 3 Nov 2020, 22:31 Daishi Kato, ***@***.***> wrote:
Interesting. So, it doesn't even check if it's committed with useEffect.
Should we expect JS engine not to garbage collect objects very soon if
there's enough free memory?
—
You are receiving this because you were mentioned.
Reply to this email directly, view it on GitHub
<#15317 (comment)>,
or unsubscribe
<https://github.com/notifications/unsubscribe-auth/AAN4NBHHYJZDCAVPTSM4DBDSOCAEPANCNFSM4HDRIAHQ>
.
|
Just a small correction, I suggested using FinalizationRegistry (for MobX) - the execution is 100% @Bnaya :] (the idea came after discussing the idea ±2 years ago for MobX in some JSConf with Daniel Erhenberg in the context of "how should Node behave" and I figured "what other libraries am I involved with and may use this") Michel had an interesting idea (to make this into a generic hook - @gaearon I think it would be very useful if you can't commit to cleanup or when cleanup runs if React provided this sort of hook as part of React since the use case is pretty common. Is that something you'd be willing to explore or is that antithetical to the direction you want to see React take? In any case I think it's better to discuss this once @Bnaya is further along with the |
Cool. I was going to draft something like |
Is using If no consensus is found then I expect such hacks to be actually implemented and used, not because I feel like that they are needed that much but just because of human nature. We all have strong opinions about things and it's inevitable that something like this will exist in the community eventually, question is - can we prevent it? Or at least eliminate it from being needed for more use cases? Given the recent work on the docs - some of the use cases mentioned here should probably be documented there to present what's the React team's stance on them, what are proposed solutions. |
@Andarist fwiw, my take is that this might be a "hack" in the sense of "well, this really isn't how you ought to be writing your code in the first place based on the rules", but it may very well be a valid technical solution for MobX's use case and potentially other folks as well. Given that, I wouldn't expect the React team to specifically recommend this approach, but perhaps it can be published as an independent package by whoever's writing it, and the React team could maybe at least document it as a known workaround. |
Especially as an incremental upgrade approach can be a valid strategy. As new research is going into new patterns. However, there's one interesting case to think about. The main motivation for FinalizationRegistry to be added to the language now of all times, is because it allows building interop with WASM linear memory (aka array buffers). In a more general sense, it's how you build interop with a tracing GC language and a ref count or manual collection GC language. For any library implementing its backing store in a WASM world, this would be the primary way to interop with it. There are many patterns that might be a bad idea around this (like opening web socket connections without deduplication until the GC runs). The core principle of using FinalizationRegistry to interop with an external memory management library isn't though. That's the point of FinalizationRegistry in the first place. |
I've created |
And for anyone like me who ends up here because their jest test uses
Try: import { clearTimers } from "mobx-react-lite";
// at end of your test:
clearTimers(); This tells the timer-based cleanup to run immediately instead of waiting N seconds. |
How to safely keep a reference to uncommitted objects and dispose of them on unmount?
For a MobX world, we are trying to prepare for the Concurrent mode. In short, there is a Reaction object being created to track for observables and it is stored within
useRef
.The major problem is, that we can't just
useEffect
to create it in a safe way later. We need it to start tracking the observables on a first render otherwise we might miss some updates and cause inconsistent behavior.We do have a semi-working solution, basically, a custom made garbage collector based on
setTimeout
. However, it's unreliable as it can accidentally dispose of Reactions that are actually being used but weren't committed yet.Would love to hear we are overlooking some obvious solution there.
The text was updated successfully, but these errors were encountered: