mobx: [breaking change] Get rid of field initializers (and legacy decorators)
Enabling useDefineForClassFields in TypeScript will prevent decorators from working (both transpiled and using mobx.decorate).
This flag will be enabled by default for ESNext once class fields go stage 4: https://github.com/microsoft/TypeScript/issues/34787
Possibly related to https://github.com/mobxjs/mobx/issues/1969
Intended outcome:
autorun using objectState is executed upon clicking on “increment” button.
autorun using classState is executed upon clicking on “increment” button.
autorun using classStateNoDecorator is executed upon clicking on “increment” button.
Actual outcome:
✅ autorun using objectState is executed upon clicking on “increment” button.
⛔️ autorun using classState is NOT executed upon clicking on “increment” button.
⛔️ autorun using classStateNoDecorator is NOT executed upon clicking on “increment” button.
How to reproduce the issue:
https://codesandbox.io/s/fragrant-frog-x2487
Versions
TypeScript 3.7+ MobX - all tested versions (4.x, 5.x)
About this issue
- Original URL
- State: closed
- Created 4 years ago
- Reactions: 3
- Comments: 65 (37 by maintainers)
Just for the record, I’d like to state that I’m against removing decorators from libraries just because TC39 cannot reach consensus or makes bad choices like field initializers. Continued use of decorators should send a clear message to TC39 that decorators are here to stay regardless of how implementers feel about them: MobX together with AngularJS and NestJS can certainly play a significant role in this.
TC39 used to operate in “pave the cowpaths” mode, where the ecosystem does something first and then they standardize it. This was much easier when Babel was the dominant compiler, as it was extremely easy to write extensions for it. It’s not so easy now that TypeScript is the dominant one, not just because the compiler isn’t extensible (it kinda is), but because extensions are a lot more difficult once types get involved. The only option we have right now is to continue using already implemented things that we think are useful.
Sorry for the philosophical rant - I hope I didn’t upset anyone! 😀
I vote for 3 with a little twist … it’s no longer opt-in, but an opt-out, so in 99% cases one will need just
decorate(this). Soeasy to forget members in large classesis no longer an issue. It’s simple, easy to follow, easy to implement, no hacks, no compilation required. De facto it’s currentobservable(object, decorators), but mutative with support for non-own/enumerable and function members (automatically a bound action unless specified otherwise). EDIT: It should probably replaceextendObservable?Nope, I protested heavily when TC 39 proposed this, for reasons like this, but to no avail… Field initializers will be non-interceptible once this finalizes, so the assignments will have to move to the constructor instead
On Sat, Feb 15, 2020 at 10:07 PM Daniel K. notifications@github.com wrote:
Ditching classes is not really an option for us, we’re using them extensively through our applications. They certainly have their shortcomings, but they also provide us with some limited reflection capabilities which come in handy. I think being forced to trigger initialization in constructor, but keeping
decorate/observableis better way to go. You could even makeObservableObjectto inherit from keeping the behavior pretty close to what it is now. People don’t like inheritance chains in JS, but it surely beats rewriting to not use classes or usingextendObservableduplicating field declarations.@mweststrate it looks the same as the non-proxy one, except there is a class decorator
vs
I’m pretty sure there are other ways to do it too, the main idea is that property decorators only provide metadata on which the class constructor acts - we modify the class constructor either via the decorator + proxy, or manually (by calling
tc39sucksInConstructor)Few thoughts:
Somekind of Runtime detection if the user have broken observables due to native class/useDefineForClassFields, and warn about it Also
reactionRequiresObservable: truewill help the user understand that something is wrong with his mobx setupeslint rules, in a similar manner to rules of hooks, plus auto-fixers. That’s can be somewhat of a balance between DX and not needing special transpile step.
It would be more interesting if it would transform the class directly (without the need to call
decorateat runtime) and also support@action async fn,@action constructorand@observer(in a way that you wouldn’t needmobx-react).I’m talking mostly about
instanceofand being able to use discriminated unions without hassle:observable(fn + decorator) Like now, applied by default to fields.computed(fn + decorator) Like now, applied by default to getters.action(fn + decorator) Applied by default to functions. Cannot be called from derivations (computed/reaction(first arg)/autorun) (unless insideeffect). Throws: Calling action from computed/reaction/observer is forbidden. If this is an utility function that doesn’t mutate state please useingore. Mutating state during derivation can lead to inifinite cycles, can break batching and can lead to unexpected behavior. Consider refactoring tocomputedor useeffect, see docs, blah blah.effect(fn + decorator) Like action, but can be called from derivations, allows mutating observables inside computed/reaction/autorun.ignore(decorator) For utility methods and fields that shouldn’t be observable for whatever reason. Also makes sure that if you (repeatedly) callextendObservableon already observable object it won’t attempt to make the field observable.Additionally: Mutating state from anywhere outside of
action/effectis not allowed. Reading observables from anywhere outside ofreaction/action/effectis not allowed.Therefore: We don’t need specific
viewdecorator (ignoreby itself doesn’t allow mutations). We can ditch enforceAction/requireReaction/requireObservable/etc, these are always on and cover your back. We can ditch _allowSomething as they can be replaced byeffect(maybe not always not sure…).Sure, but keep in mind it’s the first thing I tried with babel and it’s very unfinished. Personally I don’t need it for anything, just wanted to try something new: https://gist.github.com/urugator/ffc92893fafd70d6e52019f38594ece9
So I am still limping on two different lines of thoughts here,
.get()has to be called explicitly like in @kubk example. This is nice for purist caring about side-effectfull property reads / writes, but the main benefit is that it reduces the many ways of achieving the same thing in MobX, making learning easier, where ‘unboxing’ is just an incremental convenience step, rather than a totally different way of writing things, likeextendObservablevs. decorators vs. decorate.Edit: Some further realizations: option 2 doesn’t require any transpilation at all in modern JS. option 1 shares methods by default on the prototype (pro!) but 2 binds them by default (a different pro) (non-bound shared methods is still possible, by doing e.g.
Class.prototype.method = action(Class.prototype.method)after class definition)Edit: hypothesis: initializers that refer each other need to make sure to read ‘boxed’. (but at least that would result in tracked reads in contrast to when using decorators, which might be even more confusing. Anyway, lets make sure we cover that in tests in any case)
Edit: created a poll because the collective twitter wisdom can always be trusted: https://twitter.com/mweststrate/status/1235227165072822272
the idea is that
initializeobservables(this)could automatically unbox the observable boxes (observable objects already use an observable box per property behind the scenes). So techincally it would be pretty straight forward to have it behave the same:width = observable(20)creates an observable.box, then initializeObservables generates a getter and setter for width that redirect to the box’s getter and setter.On Wed, Mar 4, 2020 at 12:21 PM Egor Gorbachev notifications@github.com wrote:
@xaviergonz yeah, it is more babel I am worried about 😃 iirc babel-decorators-legacy is not compatible with modern field initializers, but above mentioned plugin (hopefully) is. So my goal is to have a simple decorator implementation that is compatible with babel, TS and define semantics. But which combination actually works has to be investigated still. Just want to figure out the general direction first
@FredyC for them it will remain the same; they can configure through
decorateas they currently already do (or by using using justinitializeObservables(this, decorators), I doubtdecoratehas any value if a constructor needs to be created anyway)Edit: I think babel macros are now supported, so that might be an escape to using decorators? not sure if macros can handle syntax extensions
@urugator I like your idea a lot for the proxyless implementation that still uses decorators 😀
Metadata based decorator:
Constructor based applicator:
Example model:
Demo at the same link: https://codesandbox.io/s/cool-wave-h4is4
The demo does have
useDefineForClassFieldsactivated, and if you switch@obsto@observableit will not work.I think it’s clean and minimal, easy to migrate to - and its also quite possible that having an “opt-out” version would be even nicer.
@mweststrate the proxy only applies to the constructor function:
I don’t think thats the case since the proxy only exists for the constructors, the objects themselves are not affected by proxies in any way - but let me know what you have in mind. AFAICT only class constructors will have additional proxy lines in their stack trace, should they throw
There are no instance proxies. This implementation just enhances the constructor function calling
decorate()on its result. I don’t see how it would affect handlers at all.Personally, if I would have to choose, I would go with
3. decorate in constructor. A messy constructor is probably subjective, it feels ok to me. And for the con of forgetting something, it could be possible to have DEV checks. In case there would be a member that’s not supposed to be MobX related, it could be added to the decorate map with somenoopnotation.Btw, when you say it’s strongly typed, what does that exactly mean? The resulting instances? Isn’t it somehow possible to type check that you have forgotten to decorate something or you have decorated something that doesn’t exist in the class? That would be certainly better than a runtime dev check.
For reference, this is how Ember does it (since basically two month, I guess we have been some inspiration 😃):
So if I understand this correctly to support classes with this we will need to resort to something like:
or maybe
That’s really unfortunate 😭
Oh wait, I got that all wrong (kinda sleepy). This isn’t about mobx building, but users running TS 3.8 will have this problem… Well that goes beyond my expertise why is it a problem.
Basically this…
Becomes…
And MobX cannot handle that for some reason.
TS Playground
/cc @mweststrate @urugator