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

PoC annotations + subclassing rework #2641

Merged
merged 39 commits into from
Jan 27, 2021
Merged

PoC annotations + subclassing rework #2641

merged 39 commits into from
Jan 27, 2021

Conversation

urugator
Copy link
Collaborator

@urugator urugator commented Nov 24, 2020

The problem

We can't tell which prototype belongs to which makeObservable call, therefore:
In case of annotation, we don't know which annotations apply to which prototype.
In case of decorators, we don't know which decorators belong to which makeObservable call.

The solution

Make it so that it doesn't matter where and which annotation is applied. In order to do that we need 2 guarantees:

  1. Annotation and it's configuration cannot change in subclass - subclass can't switch action to flow, but also action("1") to action("2") etc.
    One way to handle this would be to compare newly provided annotation with the one already applied, however that's generally not possible, because annotation are not easily comparable, eg: @computed({ equals: (a,b) => a === b })
    So another way, the one used in this PR, is to make it impossible to annotate the same field twice.
    This means that when you override something in subclass, you must not annotate/decorate it again - the annotation is sort of automatically inherited. Actually you don't even need to call makeObservable again unless you add new fields.
    The requirement to leave some fields un-annotated could lead to actually forgetting an annotation. Therefore I introduced a new annotation/decorator override, that denotes the intention and checks if the field is truly annotated in superclass.

  2. The field definition cannot change.
    Redefining the field in subclass would mean that we have to re-apply the annotation. However detecting such situation is not possible. At the same time the user cannot re-annotate re-defined field explicitely, because re-annotating the same field multiple times is forbidden.
    Therefore we forbid redefining the field by making it non-configurable and additionally non-writable for action/flow.
    As a consequence these are not possible:

class Superclass {
  @observable
  observable = 5; 

  @action
  action = () => {}  
  
  // workaround (don't annotate)
  action = action(() =>{})
}

class Subclass extends Superclass {
  // throws field is not configurable
  observable = 5; 

  // throws field is not configurable
  action = () => {}    

  constructor() {
    // throws field is not writable
    this.action = () => {}
 
    // workaround (don't annotate)
    this.action = action(() =>{})
  }

  // workaround (don't annotate)
  action = action(() =>{})
}

Alternative solution with writable actions (but still not configurable)

Additional changes/notes/controversies:

Most of the safety checks are done on devel only.

Everything that's not supported should throw (let me know if I missed something).
It's not always possible to provide meaningful/instructive error, but I think it's better than breaking app silently.

Enumerability of all members is respected (BC), therefore:
[this.]action = () => {} is by default enumerable
This change is strictly not necessary, but I aim for: #2586 (comment)
See also #2629, #2637
Same goes for observable()/extendObservable() - even explicit action won't make the field non-enumerable, if it's enumerable on source object.
We could do this only for autoconverted members, but I think it would be nice to have the same behavior across the board.

extendObservable and makeObservable has now slightly different semantics:
makeObservable is intended mainly for classes and assumes static object shape (non-configurable fields/non-writable methods). Stubbing is therefore impossible (at least on devel) 🙁
extendObservable is usable with dynamic objects - field can be deleted, methods can be rewritten
However, I think we may reconsider extendObservable behavior as well. It doesn't makes sense to make non-observable fields (action/flow) writable - if the field is writable, then it's statefull and therefore should be observable, otherwise we are risking staleness.
So either make everything observable, or make non-observable non-writable.

If you extend 3rd party class and annotate inherited method, it will annotate the 3rd party class as well (eg turns it's method into an action if it's annotated as such in subclass)

Overriding computed is not supported as before, but now it throws.

Fixed/renamed/commented a few things here and there.

@changeset-bot
Copy link

changeset-bot bot commented Nov 24, 2020

🦋 Changeset detected

Latest commit: 8cb03b5

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

This PR includes changesets to release 3 packages
Name Type
mobx Minor
mobx-react-lite Major
mobx-react Major

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

@mweststrate
Copy link
Member

@urugator thanks for the awesome work! For the class approach, I want to check if I understand everything correctly, so here a summary in my own examples:

observable

on instance, getter/setter, non configurable. So inheritance works like:

class A {
  x = 1
  constructor() {
     makeObservable(this, { x: observable })
  }
}

class B extends A {
  x = 2 // FAIL
  constructor() {
     this.x = 2 // CORRECT
  }
}

action on method

on proto, configurable, non writeable

class A {
  method() {

  }
  constructor() {
     makeObservable(this, { method: action })
  }
}

class B extends A {
  method() {

 }
  constructor() {
      // works, as it wraps B.method, not A.method
      makeObservable(this, { method: action })
  }
}

action.bound on method

on instance, non configurable, non-writable
(maybe we should just kill this in the future, since instance fns are standardized now)

class A {
  method() {

  }
  constructor() {
     makeObservable(this, { method: action.bound })
  }
}

class B extends A {
  method() {

 }
  constructor() {
      // NOT SUPPORTED
  }
}

// Aternative pattern

class A {
  method() {

  }
  constructor() {
     this.method = action(this.method.bind(this))
  }
  // OR
  method = action(() => { } )
}

class B extends A {
  method() {

 }
  constructor() {
     this.method = action(this.method.bind(this))
  }
}

action field

on instance, non-configurable, non writeable

class A {
  method =() => {  }
  constructor() {
     makeObservable(this, { method: action })
  }
}

class B extends A {
  method = () => { } // FAIL, see above work around
  constructor() {  }
}

computed

on instance, getter/setter, non configurable

class A {
  get x() { return 1 }
  constructor() {
     makeObservable(this, { x: computed })
  }
}

class B extends A {
  get x() { return 2 } // FAIL
}

// work around
class A {
  get x() { return this.getXHelper() }

  getXHelper() {
      return 1
  }

  constructor() {
     makeObservable(this, { x: computed })
  }
}

class B extends A {
  getXHelper() {
      return 2
  }
}

I think it deviates from the current PR in the sense that action on the prototype are configurable (could even be writeable?). The reason is that non-configurability doesn't make a practical difference from our perspective(?) (since the subclass implementation lives on a different prototype), but this approach does allow for stubbing still.

I'm not sure if I see the value of override versus just applying action again? I think forgetting action on parent, but having one on child is fine, as it doesn't harm anyone and still has action applied to the final instance. The other way around, having an annotation on parent, but forgetting it on child, isn't detectable (?) by us, so forgetting @override is the same problem as forgetting `@action?

Other question, not sure if this is a patch, or a major release. It is a pretty breaking change, on the other hand, it mostly fixing things that didn't work correctly before, or accidentally, and brings some things back in line with MobX 5, like enumerability of methods, which was a non intentional change of 6 IIRC?

@urugator
Copy link
Collaborator Author

urugator commented Nov 25, 2020

observable

Correct

action on method

Correct, but you must not re-annotate in B - it will throw ... you can optionally use override as annotation in B, but it's not required (it's advised though).
Both A.method, B.method are actions defined on prototype.
The way it works: because we know the annotation can't change I simply traverse the proto chain and patch every proto - if it's already patched, it's ignored.
Method override examples

action.bound on method

Overriding action.bound is supported as long as it's defined on prototype:

class A {
  method() {

  }
  constructor() {
     super()
     makeObservable(this, { method: action.bound })
  }
}

class B extends A {
  method() {

 }
  constructor() {
     makeObservable(this, { method: "override" }) // optionall
  }
}

The way it works

action field

Correct

computed

Correct

prototype are configurable

I use the same rules for prototypes, more or less to keep the code simple. I mean modifying a prototype is problematic in the same way, so the non-configurable/non-writable does make sense. Whether users actually ever attempt to modify prototype, I dunno, probably not, therefore this safety measure may not be strictly required.
Would configurable/writable prototype help anything? Can't you inherit the proto in test and modify this inherited non-annotated configurable class? Isn't the actual problem here the instance itself?

I'm not sure if I see the value of override

If I allow applying action again, then I am unable to ensure 1 Annotation and it's configuration cannot change in subclass. I can't tell if this new subclass annotation is exactly the same annotation as the one in parent.
These 2 mentioned guarantees make the impl relatively easy to reason about, this would be a step back. It would also introduce inconsistency from user perspective - you could re-annotate action, but nothing else.
The main reason for override is: Imagine you open a class definition and everything, but a single method is annotated, so you say "Ha, somebody forget an action here", so you put an @action there and it will throw -
"Ah, I see it's intentional". Two days later it happens again to you or to your collegue. At some point you will start ignoring missing annotations. That's a problem because as you pointed out, we don't have a way to detect missing annotation.
So I simply can't allow re-annotating, but at the same time I don't want to teach user that missing annotation is fine.

forgetting action on parent, but having one on child is fine

Only if the parent is abstract.

Other question, not sure if this is a patch, or a major release.

I don't know either :) I tried to come up with something that makes sense as whole and fits together. I may went too far, but I don't think there are many possible solutions.
It seems a lot of ppl haven't migrated yet or are strugling with migration due to these issues. I don't know if we can still introduce some breaking changes or if it makes sense to create new major so soon.

BC 1 - non-configurable/non-writable:

It's just a safety measure, but unfortunatelly quite important in the context of the solution: We tell the user: "You can't re-annotate, the annotation is inherited implicitly" - except it's no longer true, if he changes the field definition in subclass...
The stubbing probably has to be resolved somehow.

BC 2 - re-annotating is forbidden

That's something the solution relies on. The docs have to be changed and users must adapt the code.
But since it's related to subclassing only, which is currently quite broken, it could be an acceptable change ?
It may also affect mobx-undecorate, dunno.

BC 3 - Enumerability

Currently (in main branch) all actions are non-enumerable, same as in previous versions.
What changed in v6 is that observable/makeAutoObservable turns functions (that were enumerable before) into autoAction, so they're non-enumerable.
So a fix would be to firstly allow specifying enumerability on action fields and secondly change the "autoconversion" logic so it respects enumerability.
However this PR goes further than that. It states that enumerability is always respected, not just during "autoconversion". The reasons are: keep rules simple, keep impl simple, don't surprise user by deviating from ES.
That being said, the enumerability of computed is untouched atm (non-enumerable) and needs to be discussed. I think non-enumerable computed is more practicall for serialization purposes, but I recall some ppl were suprised by this and IIRC they were actually using JSON.stringify. I think I would lean towards respecting enumerability here as well...

@urugator
Copy link
Collaborator Author

Few more Q&A to clarify reasoning/decisions:

Can we support re-annotating on prototypes?

No, because we don't know which annotation belongs to which prototype

Can we support re-annotating on instance?

In theory yes, when new annotation is provided we would completely destroy and recreated the field. User must provide new field definition/value - he can't change the annotation only (no easy way to ensure this).

Does it even make sense to support re-annotating?

It doesn't make sense to change the field type and therefore it's annotation+configuration neither in subclass or over time.
It does make sense to support "refreshing" the annotation, when user provides new field definition.

Is field redefinition detectable?

No, we would have to keep a copy of all descriptors and compare them with current descriptors in every makeObservable call.

Can't we give up on prototypes and define everything on instance?

No, super.foo() wouldn't work as expected.

Can we detect missing override?

No, because we don't know which prototype belongs to which override

Can't we use override to support redefinition of observable/action?

In theory yes, problem is we don't know which annotation was applied to which field. Since the field was redefined, we can't even use some reflection to find out. So we would have to keep a list of applied annotations on instance.

@mweststrate
Copy link
Member

mweststrate commented Dec 2, 2020

override

Ok, I was writing a long story about why I am not convinced of override, and then in the end I was, so thanks for the long elaboration :-P.

It seems a lot of ppl haven't migrated yet or are struggling with migration due to these issues.

I think (hope) subclassing a lot of stuff in general is pretty rare even with MobX :)

To summarise my current line of thinking:

decorator target with inheritance
observable field cannot be redeclared, to avoid (accidental) shadowing we mark it non-configurable
action method can be redefined in subclass, but not redecorated, except with override
action.bound method same
computed getter / setter same as action (so can be subclassed, but not re-decorated except for override)
action / action.bound field own field, but non-configurable / writable to avoid accidental shadowing, work around: manual action wrapping

Enumerability

For background, the reason why getters are made to be non-enumerable, is that they aren't enumerable if part of a class definition either. The change between MobX 5 and 6 was not so much that actions have become enumerable, but more that the default decorator for methods in an object literal was observable, rather than action. So the reasoning so far was that observable({ field, method, get computed }) behaves practically the same (except for ownership) than new class { @obervable field @action method @computed get computed }). So I think it is kinda of a binary choice now where we have to determine the philosophy, we make observable either behave like as close to a cloning function as possible, or as close to creating a class instance as possible, where the first arg is basically a model for the new instance.

I think personally I lean towards the second philosophy, which I think works very well in practice (not a lot of issues reported on it so far), but I could live with the first one as well. In that case we should address the enumerability of computed indeed as well; they should be enumerable if defined on an object, but non-enumerable if defined on a class playgroud

@urugator
Copy link
Collaborator Author

urugator commented Dec 2, 2020

To summarise my current line of thinking:

Not sure it it's meant to be a summary of how you understand the PR or proposal for changes:

action - method - re-annotating is allowed, reannoating with a different config will be ignored

In current PR: Re-annotation is simply never allowed for anything, except for override (which states the annotation for this one is provided by parent).
The check is very simple - on devel I just keep a list of already annotated fields and if a second annotation (no matter which) is provided I throw.
I don't see a point in allowing this, unless you want to keep current code working (users wouldn't be forced to change action to override in subclasses).

computed

I was convinced that computed can't be overriden, but atm I am not sure, probably it can be done the same way as action.bound - using the closest descriptor (meaning super.aComputed calls a plain getter).
Computed is tricky, because it's a field in disguise. In current PR it's forbidden - once I see a second definition of computed field on proto chain I throw.

default decorator for methods in an object literal was observable, rather than action

As already discussed on gitter, that's one thing I would like to change as well (not part of the PR atm). I want to revert this to use observable as in Mobx5/6, but observable(fn) <=> autoAction(fn), so the field can represent state/action/view.

close to a cloning function as possible, or as close to creating a class instance as possible,

Imo it's not so binary, because in one case (close to class instance) we take away the choice from user. If we respect the descriptor, the user can change it however he wants before passing the object to our non-opinionated/non-surprising function.

@mweststrate
Copy link
Member

mweststrate commented Dec 2, 2020

Not sure it it's meant

Sorry, forgot to update that line while reading. Will edit. But yes, my summary ought to be: 'can be redefined in subclass, but not redecorated, except with override'

Computed is tricky, because it's a field in disguise. In current PR it's forbidden - once I see a second definition of computed field on proto chain I throw.

I thought initially it to be tricky as well, but than I figured it probably isn't: in the first makeObservable (the one of the superclass), find the closest definition on the prototype chain (which will be the one of the subclass) and create a computedValue on it. Just like with action, it shouldn't be marked again in the makeObservable of the subclass. But that is easy to detect, since computeds are stored on the instances, so a redecoration can be detected immediately.

the user can change it (enumerability) however he wants

In practice most people don't know how to do that, and even if they do, it is terribly verbose to write, using uncommon api's, so I think in practice people will then correct for it in another place (for example during json.stringify / iteration). I'd rather have the opposite: by default stringify / iteration does sane things, and if you really want to see those members, update the iteration code (e.g. using getOwnPropertyNames or something alike). Having this preconfigured by default also more closely aligns with the distinction we make in the docs: there is state, and separately there are methods and derivations that operate on the state. But in reflection cases, you are typically mostly interested in the state.

I think this also addresses the other problems: not making all methods observable (which is expensive by default, and a bit suprising as well, computeds aren't reassignable members either). In the case where you do care about logic changing over time, for example a filter predicate that is being stored, marking it as observable rather than action makes that explicit, and makes it clear that the function is 'state' rather than 'logic'. So all state is mutable and enumerable, logic and derivations is non-mutable and non-enumerable.

I think it is a pretty clear model, even if it deviates from how plain objects work. But these are not plain objects, these are objects within a framework that has a lot of concepts that javascript doesn't have, like the explicit distinction between state -> actions -> derivations, and I think the current behavior reflects this philosophy behind the library. The more I think about it, the more I'm convinced we just need to make sure it is properly documented.

In either solution we'll get surprises, but I rather have the suprise when people deviate from the mental model. If a suprise occurs: 'hey, these methods aren't enumerable', the follow up question should be: 'why do I need those to be enuemrable'?
The only cases favoring enumerabality linked now in #2629 are counter examples: yes they break, but these are incorrect usage pattern; objects are spreaded into another component rather than being passed by ref which is the recommendation in our docs and much more efficient (and imho prop spreading is an anti pattern in React in any case, but that is a different discussion).

So between 'stringify doesn't work, but object spreading does. But hey, don't do that, it is probably not what you want!', and 'stringify is pretty neat ootb. Ok, spreading won't work, but you shouldn't do that anyway', I think the latter is the better choice :)

@urugator
Copy link
Collaborator Author

urugator commented Dec 2, 2020

The idea was that they can easily introduce own makeObservable/createModel/whatever that does exactly what they need, not to configure every object.
Keep in mind we are talking about auto behavior, which imo isn't intended for defining rich models/stores. The goal here shouldn't be to find out the most reasonable defaults for model/stores. There is no need to do that, user is already in control of these, he can be as specific and make it as optimal as he wants.
The question simply is how you make an arbitrary object observable, that's all. So instead of guessing (which we have never done btw, we have always converted only things, which we knew they're safe to convert), we just do what we can - what can be observable is observable, what can be batched is batched, what can be computed is computed.

not making all methods observable

Note we are not making observable something that wasn't observable before. Previously these functions were observables, now they would be additionally batched - so now they would be actually usable as methods (they wasn't before)

Things explicitely annotated by action (as opposed to autoAction) could be always non-enumarable and non-writable (and therefore non-observable). I've actually touched this already in OP:

"However, I think we may reconsider extendObservable behavior as well. It doesn't makes sense to make non-observable fields (action/flow) writable - if the field is writable, then it's statefull and therefore should be observable, otherwise we are risking staleness.
So either make everything observable, or make non-observable non-writable."

What I don't like about it is it simply deviates, you need a rule for that and it can get weird:

// it doesn't have to be in class
this.field = action(() => {}); // enumerable
// but 
this.field = () => {}
makeObservable(this, { field: action }) // non-enumerable

I just don't feel like that this implicit opinionated "convinence" we would provide (and again user can create a function that does this for him) is worth of complicating the impl and the rules.

@mweststrate
Copy link
Member

So instead of guessing (which we have never done btw

observable(object) has always been a guess, it breaks any non-purely derived getter by caching were it shouldn't, leading to potentially incorrect results. And deep observability isn't a safe guess either, it is the most convenient one. The safe guess would be ref.

The idea was that they can easily introduce own makeObservable/createModel/whatever that does exactly what they need

Disagree, a utility that makes something non-enumerable is much harder (you can't create a util: object.action = nonEnumerableAction(fn)), but you have to get into ugly meta programming stuff (makeNonEnumerable(object, 'action')). While on the other hand, if you want action to be enumerable, the work around is really trivial as you suggested already: object.action = action(fn) works perfectly fine.

JavaScript itself picks already different defaults in different scenarios as well, classes make getters and methods non-enumerable by default for instances, in contrast to plain objects, even though you didn't specify that anywhere, so it isn't that weird. Following the same consistency reasoning we shouldn't make anything non-configurable, as that deviates definitely from what the language does ootb. We do it nonetheless in this PR, because it provides a better DX in the end.

I think the same holds for enumerability. Converting enumerability is just as arbitrary as converting configurability, or fields to getter/setter pairs. These are all just as inconsistent. I would care for consistency if it was like a 30% (enumerability would be great in this case) / 70% case (non-enumerability is more convenient here). However, I still don't see any good use case yet justifying why we want actions to be enumerable from a practical perspective, and as long as that isn't the case, I don't see how consistency with plain JS objects even matters.

And personally, I'd argue non-enumerability is actually more consistent: regardless whether you use object or class style MobX, you get pretty similar instances in the end. It is less consistent on the axis of objects in JS, but more consistent on the axis of making objects observable with MobX.

So tl,dr, I think we should move ahead with the inheritance changes in this PR including override, but leave enumerability as is.

@urugator
Copy link
Collaborator Author

urugator commented Dec 2, 2020

Should I make action non-writable for observable/extendObservable as well (since it's non-observable)?
You can still delete and redefine the field.

Fields in observable/extendObservable stays configurable because it must support delete obj.key/delete(obj, key) (but reconfiguring them is still unsupported - it's supportable with proxies though).
Meaning we don't support key deletion with makeObservable - we assume stable objects.

What about that testing/stubbing?

Btw, it's probably not of any use, but I've just noticed one can do:

class A {
  o = 5;
  constructor() {
    return new Proxy(this, {
	defineProperty() {
          throw new Error('Cannot redefine')
        }
    });
  }
}
class B extends A {
  o = 10; 
}
new B();

@mweststrate
Copy link
Member

Should I make action non-writable

Sounds good!

Meaning we don't support key deletion with makeObservable - we assume stable objects.

Correct. I think we can assume the same for extendObservable

Fields in observable/extendObservable stays configurable because it must support

For observable, I'm not sure how hard it would be, but the consistent behavior would be I think: not proxying -> non-configurable, proxying -> configurable. As without proxy observable assumes a stable shape as well, except (sigh) when using the object utilities (me introducing them was a mistake, but alas, here we are).

@urugator
Copy link
Collaborator Author

urugator commented Dec 2, 2020

We could also make fields non-configurable only when annotating non-plain object. That way extendObservable/makeObservable could work the same way. I think that deleting keys makes sense only with object literal anyway.

@urugator
Copy link
Collaborator Author

urugator commented Dec 7, 2020

I am currently thinking about how to reconcile makeObservable/extendObservable and dynamic objects.
Currently there are some weird situations with not well defined behavior like:

const o = observable({ foo: 1 as any })
o.foo = () => {}

makeObservable(o, {
  foo: action
})

isAction(o.foo) // false
isObservableProp(o, "foo") // true

I would like to solve this by preventing re-annotating in general, so the only way to re-annotate would be to delete and reintroduce the prop. Ideally I would like to have this check on a single place. So basically I need to move addProperty logic directly to ObservableObjectAdministration so it becomes annotation aware.
Therefore eg
adm.addObservableProp_(key, value, adm.defaultEnhancer_) in dynamic-api.js
would become:
adm.addProperty_(key, value, observable)
What do you think?

@mweststrate
Copy link
Member

Yes feel free to limit these kind possibilities, and think so far the behavior of such constructions has been undefined.

For enumerability feel free to do what you feel is best for object literals. Idealistically speaking I like non-enumerability, but I see that people might expect enumerability, especially since that was (kinda unintentionally) what we did before. So one could consider that that ship sailed a long time ago already. So I think either approach is defendable.

@urugator
Copy link
Collaborator Author

urugator commented Dec 8, 2020

Should makeObservable/extendObservable notify about prop addition?

Eg docs states: "It is possible to use extendObservable to add observable fields to an existing object after instantiation, but be careful that adding an observable property this way is in itself not a fact that can be observed."
However if I follow the code correctly it calls adm.addObservableProp_, which notifies interceptors/listeners/observers.
On the other hand it won't notify anything in case of computed/non-observable/action/etc

Should observable object notify key observers/listeners/interceptors when non-observable prop is added/removed/modified?

It seems that current idea is that only addition/removal/modification of observable props should notify these.
If you add/remove anything non-observable (including action etc), it won't notify anything, which is weird because it modifies the object structure and these listeners/observers are interested about the object, rather than individual props.
The removal of computed prop was non-functional so far, therefore the behavior was probably undefined. With the fix I provided, it notifies observers/listeners/interceptors, but it's inconsistent with adm.addComputedProp_ that doesn't notify.

So to resolve this and make it somehow consistent/simple I propose to:
The interceptors, listeners and key observers are defined on the level of observable object, not on the level of props. Therefore we should call all of these when any (not just observable) property is added/removed/modified.
The makeObservable/extendObservable delegates to adm.addProp in the same way as object-api so the behavior is same - notify on addition of any new prop.

@mweststrate
Copy link
Member

Should makeObservable/extendObservable notify about prop addition?

No, as the docs suggested, this isn't necessarily observable. I think it was in the past primarily by coincidence as the set api was using the same logic as extendObervable.

Should observable object notify key observers/listeners/interceptors when non-observable prop is added/removed/modified?

No strong opinion on this one so far. The discussion behind this one is probably a bit similar to the one behind enumerability; the primary use case for having observable object extension (I actually don't know another one), is using an object as a collection (in other words a Map) of other observables. For that reason I don't think there is much value in having notifs about new computeds / actions (one could even wonder if an object is used as collection, whether it should have computeds / actions at all). However, if it simplifies things, without slowing them down much, I think it could be ok. But what would worry me is what would this mean for things like intercept. Does it mean that adding an action can be intercepted as well? That might make things fairly complicated.

@urugator
Copy link
Collaborator Author

urugator commented Dec 8, 2020

if it simplifies things,

That's the main goal. Currently the whole thing is quite quirky ...
Eg object-api (that is also used by proxy) delegates everything to ObservableObjectAdministration, but ObservableObjectAdministration kinda pretends it's dealing with observables only, therefore:
delete observableObject.nonObservableField is probably noop because adm.remove_ shortcircuits when field is not observable...
observableObject.nonObservableField = 5 probably makes the field observable because set calls adm.addObservableProp_ when the field is not observable
has(observableObject, 'nonObservableField') probably always yields false, because again it doesn't care about non-observables

Does it mean that adding an action can be intercepted as well?

Well the plan was to really interecept everything, because I assumed that everything goes through object-api/extendObservable/makeObservable. But I realized in case of assigments (update event) it must be done via getter/setter - that's not an issue for action, because it's not writable, but it's problem for other non-observables.
So I think I won't push this idea in case of interceptors/observers, however I think it shouldn't be a problem to notify key observers (reportKeysChanged).
What I am not sure about are these up-ahead subscriptions ... we don't know if the eventually added key will be observable ... eg you subscribe for object.later (using object-api.has) and later makeObservable(object, { later: action }) ... probably it should notify later subscribers...
It almost seems like we need 2 types of objects - static and dynamic - and they should have own API - so you can't eg use object-api on static objects or define non-observables on dynamic object etc.

Many of these situations may be unlikely to happen or of any use, but I want to tackle them somehow as they make the code hard to reason about.

EDIT:
I think I will leave it working like now in regards to observers/listeners/interceptors - only addition/removal/modification of observable prop is detecable. I will just add support for addition/removal/modification of non-observable props:
remove will simply remove prop from target
set will simply set value without making existing field observable
has will return true for non-observable
Addition of non-observable is only possible via extend/makeObservable, so it stays the same, but it will remove an entry from pendingKeys (without reporting), to avoid leaks.

@mweststrate
Copy link
Member

@urugator has has a close relationship with listening to key additions. The goal is not so much to directly replace JavaScripts's in / hasOwnProperty, but rather to be able to be notified about the future addition of a very specific key (as counterpart for ObservableMap.has). So if keys(object) wouldn't react to a specific assignment (like adding action y), has(object, "y") shouldn't react either.

Supporting non observables in remove or set are ok if that cleans things up

mweststrate added a commit that referenced this pull request Jan 23, 2021
* docs: Document enumerability as discussed in #2641

* docs: Document `proxy` option as discussed in #2717

* docs: use absolute urls in Readme, fixes #2685

* Add changeset

* Name fix
@AoDev
Copy link

AoDev commented Jan 25, 2021

Hi,I am coming from an error thrown when extending classes with makeAutoObservable. "makeAutoObservable cannot be used on classes that have super or are subclassed."

Sorry if I didn't follow all the messages in this thread, but my question is simple: will makeAutoObservable work with super() / subclasses after all these changes?

Context: I have a couple of projects where there is a "BaseStore" class that is used everywhere. It encapsulates a couple of extras available to any store, to keep the code DRY, and many of my stores work just fine with makeAutoObservable.

So this pattern would be used a lot without the error:

import BaseStore from 'app-lib/BaseStore'

export default SomeStore extends BaseStore {
  constructor() {
    super()
    makeAutoObservable(this, ...)
  }
}

@urugator
Copy link
Collaborator Author

urugator commented Jan 26, 2021

will makeAutoObservable work with super() / subclasses after all these changes

Atm it will throw as well. But the way it's implemented in the PR, I think it could actually work as long as you call makeAutoObservable only once - inside the subclass constructor. Meaning you couldn't customize anything in parent.
Generally speaking the ability to provide overrides (second param) makes it hard to support. Eg. if you exclude a field in subclass
{ prop: false }, the parent constructor would make it observable anyway, because it doesn't know it will be excluded later in subclass.

@AoDev
Copy link

AoDev commented Jan 26, 2021

I understand thanks. Except for rare cases, all my stores are configured in the same way.

  constructor() {
    // This just works fine 99% of the time
    mobx.makeAutoObservable(this, undefined, { autoBind: true, deep: false })
  }

I only use custom config if there is some issue with makeAutoObservable.

@jeremy-coleman
Copy link
Contributor

could makeAutoObservable add a symbol / hidden property to its target then check for that property before applying itself, then if the hidden prop exists, just do nothing? basically like:

class Dog extends Animal {
  constructor() {
  super()
  if(!this.prototype.__@@autoobservable__) makeAutoObservable(this)
 }
}

@urugator
Copy link
Collaborator Author

urugator commented Jan 26, 2021

No, because it would ignore fields defined by subclass. However there are other problems. Eg we always annotate whole proto chain (because we don't know which proto belongs to which call), so if you extend 3rd party class like React.Component, it would mess with it's prototype.
Or, once you allow calling makeAutoObservable on already observable object, you have to deal with interoperability with makeObservable/observable/extendObservable (so you can't assume everything is already observable or to know what was ignored etc)

@jeremy-coleman
Copy link
Contributor

Bah. Maybe the first makeAutoObservable call could create an undecorated copy of the class and store it either on itself or in a external map so subclasses could reference it? kind of like a "class Dog extends preconstructed Animal" equivalent.

class Dog extends Animal {
  constructor() {
  super()
  if(this.prototype.__@@mobxcopy__) { //<- copy of Animal before makeAutoObservable ran
    // delete things if needed here
    Object.assign(this, 
     makeAutoObservable(Object.assign(this, this.prototype.__@@mobxcopy__)
   )
  }
 }
}

@urugator urugator marked this pull request as ready for review January 26, 2021 15:40
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

4 participants