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

Fix #3728: observables initialization should not update state version #3732

Merged
merged 10 commits into from
Aug 26, 2023

Conversation

urugator
Copy link
Collaborator

@urugator urugator commented Jul 23, 2023

@changeset-bot
Copy link

changeset-bot bot commented Jul 23, 2023

🦋 Changeset detected

Latest commit: 6e81e91

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 2 packages
Name Type
mobx-react-lite Patch
mobx Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@@ -41,6 +44,8 @@ export function extendObservable<A extends Object, B extends Object>(
const descriptors = getOwnPropertyDescriptors(properties)

const adm: ObservableObjectAdministration = asObservableObject(target, options)[$mobx]
const allowStateChanges = allowStateChangesStart(true)
globalState.suppressReportChanged = true
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The plan is to change these into suppressReportChangedStart/End and probably move it to initObservableStart/End (together with unstrackedStart/End and allowStateChangesStart/End as noted in comments).

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yeah, some dedicated utility sounds great here to avoid copy / paste mistakes.

@urugator
Copy link
Collaborator Author

urugator commented Jul 23, 2023

When initializing observable structures, we should do 3 things:

  1. allowStateChanges so we don't violate enforceActions.
  2. untracked so we don't accidentaly subscribe to anything observable accessed during init in case the observable is created inside derivation.
  3. suppressReportChanged mainly to prevent version update, but also there can't be any observers yet, so there is nothing to report to, therefore we can skip the logic.

Do you agree? @mweststrate

@urugator
Copy link
Collaborator Author

urugator commented Jul 23, 2023

I have yet to investigate failing tests. From a quick first look there seems to be a reduction in render counts, which may actually be correct. I will look into it later, once you confirm that the general direction is fine.

@@ -65,6 +65,9 @@ export class Atom implements IAtom {
* Invoke this method _after_ this method has changed to signal mobx that all its observers should invalidate.
*/
public reportChanged() {
if (globalState.suppressReportChanged) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This feels like a potentially dangerous check, that silently might break reactivity. E.g. do the following cases behave correctly?

const x = observable({ a: 1 })
reaction(x => x.a, a => console.log("a", a))
extendObservable(x, { a: 2, b: 1 })
const x = observable({ a: 1 }, { proxy: true })
// react to new keys
reaction(x => Object.keys(x.a), a => console.log("a", a))

x.b = 1

I'm wondering if an alternative approach would be: only increase stateVersion if there are subscribers to the current atom?

Copy link
Collaborator Author

@urugator urugator Jul 25, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This feels like a potentially dangerous check, that silently might break reactivity

Good point. Imo the play here is to make sure it's suppressed only on creation and not on mutation like in this case.
Key changes should invoke reactions and therefore update state version, these two go hand in hand, a reaction must always see a new state version. Therefore you shouldn't extend observable during render.
Just for clarification: extendObservable does notify about key changes.

only increase stateVersion if there are subscribers to the current atom

Purely from semantic perspective, two different states should aways have different versions, doesn't matter if someone was observing some part of the state or not. That's quite easy to reason about.
Practically speaking, I don't know. I have a bad feeling about it, but I have a hard time to come up with a convincing example. My worry is, that you change some observable x, that nobody is observing, so version stays the same. Then something unrelated to mobx - props/state or other external sync store - changes in such a way, that the next render would read from x. Now if react would check whether mobx external state changed, it doesn't know about the change of x, even though it's about to use it together with rest of the world (other observables, state, props, other external state) to render. I am not sure if that alone is a problem. The goal here is to avoid inconsistencies and to have an inconsistency, the x would probably have to be somehow correlated with the rest of the world (props/state/other store). But then, if they're correlated, they had to change at the same moment and therefore something, somewhere had to already signal a new version? It's hard for me to contemplate about this, especially when I don't know what react exactly does under the hood and what it's assumptions are. If version always changes, I don't have to think about this...

Copy link
Member

@mweststrate mweststrate Jul 25, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Purely from semantic perspective, two different states should aways have different versions, doesn't matter if someone was observing some part of the state or not. That's quite easy to reason about

If version always changes, I don't have to think about this...

Agreed, I think these principles weighs stronger than my concerns. Let's continue this direction.

Copy link
Collaborator Author

@urugator urugator Aug 5, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There is a fundamental problem with subclasses - new Subclass() always counts as a state update, because the subclass's constructor calls makeObservable on an already observable object (this).
Strictly speaking it's correct behavior, because constructors can insert any arbitrary logic between individual makeObservable calls and there is no guarantee new Subclass is called from transaction, so a reaction invoked in between these makeObservable calls should see new state version.

What we would need is to treat all modifications of the same atom inside the same batch it was created as part of initialization. To do that we would need to introduce globalState.batchId, assign this batchId to every atom when created and suppress reportChanged when atom.batchId === globalState.batchId. Now you don't have to manually demark initialization sections - they span from atom creation until the end of the current batch. If you call new Subclass() from action as a good citizen, the state version stays same, no matter how many times you called makeObservable. Thoughts?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ok, that sounds smart! It might serve in the future as well to better handle any weird edge cases around reaction creations in actions, recursive updates etc etc, although I believe we handle those all consistently now.

@urugator urugator marked this pull request as ready for review August 12, 2023 14:55
Copy link
Member

@mweststrate mweststrate left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Really awesome work @urugator, great fixes and clear to follow! Probably good to run the perf tests. Left two comments, but beyond that looks fine to merge by me!

packages/mobx/src/types/type-utils.ts Show resolved Hide resolved
data_: Map<K, ObservableValue<V>>
hasMap_: Map<K, ObservableValue<boolean>> // hasMap, not hashMap >-).
keysAtom_: IAtom
data_!: Map<K, ObservableValue<V>>
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The initObservable method now lifts all initializers outside the constructor body as direct statements, so it might be needed to initialize every field now to undefined here at property declaration, to make sure we don't miss inner class optimization that takes place when V8 scans the constructor body for unconditional field assignments (sorry, can't find the authoritive source on that anymore, so might be outdated, but probably doesn't harm)

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Will take a look at that. I wonder whether "useDefineForClassFields": true has any effect on this, since it replaces assigments with the Object.defineProperty calls.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actually

class Foo {
 prop!;
}
// transpiles to
"use strict";
class Foo {
    constructor() {
        Object.defineProperty(this, "prop", {
            enumerable: true,
            configurable: true,
            writable: true,
            value: void 0
        });
    }
}

So I quess it doesn't matter?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes you're right, I still have 7 year old micro-optimizations against ES5 in my head when I wrote first versions 😅. Feel free to merge!

@urugator urugator merged commit 3ceeb86 into mobxjs:main Aug 26, 2023
1 check passed
@github-actions github-actions bot mentioned this pull request Aug 26, 2023
@Egor-Chib
Copy link

We are using MOBX 6.10.2
and it looks like the issue persists: if I create children component, which do "new Something()" and "something" has in constructor "makeObservable" - this will cause re-render of parent.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
3 participants