Migrate to TC39 Modern Decorators
Summary
Migrate hoist-react from TypeScript's legacy experimentalDecorators to the TC39 2022.3 (Stage 3) modern decorator standard. This eliminates the makeObservable(this) constructor boilerplate across the entire framework and aligns with the direction of TypeScript, MobX, and the JavaScript ecosystem.
MobX 6 supports both legacy and modern decorators. The migration path is documented at https://mobx.js.org/enabling-decorators.html.
Motivation
experimentalDecorators is a legacy TypeScript-only feature that predates the TC39 standard. TypeScript 5.0+ supports TC39 decorators natively.
- The
makeObservable(this) call is required in every class constructor that introduces observable properties β ~150 files in hoist-react. This is error-prone boilerplate; forgetting it causes silent reactivity failures. TC39 decorators initialize observables at class-definition time, eliminating this requirement entirely.
- MobX's documentation recommends modern decorators going forward.
Current State
- TypeScript: 5.9.3 with
experimentalDecorators: true
- MobX: 6.15.0
- Build:
tsc only (emitDeclarationOnly: true) β no Babel. Consuming apps provide their own bundler (webpack/Vite + Babel).
makeObservable(this): Called in ~148 files
@observable: ~223 occurrences across ~124 files
@bindable: ~158 occurrences across ~93 files
- Custom decorators:
@debounced, @computeOnce, @logWithInfo, @logWithDebug, @enumerable, @abstract, @sharePendingPromise (in utils/js/Decorators.ts)
- Custom MobX decorators:
@bindable, @bindable.ref (in mobx/decorators.ts)
Implementation Steps
1. TypeScript Configuration
In tsconfig.json:
- Remove
"experimentalDecorators": true
- Remove
"emitDecoratorMetadata": true if present
- Keep
"useDefineForClassFields": true (already set, and irrelevant to TC39 decorator behavior)
TypeScript 5.0+ treats decorators as TC39 by default when experimentalDecorators is absent or false.
2. Rewrite @bindable / @bindable.ref (mobx/decorators.ts)
The legacy @bindable implementation uses the 3-argument (target, property, descriptor) API and defers observable creation to makeObservable(this) at construction time. With TC39 decorators, @bindable becomes an accessor decorator that wraps MobX's observable directly and installs the setter.
New signature: (value: ClassAccessorDecoratorTarget, context: ClassAccessorDecoratorContext) => ClassAccessorDecoratorResult
The new implementation should:
- Delegate to
observable(value, context) (or observable.ref(value, context) for @bindable.ref) to get MobX's accessor decorator result ({get, set, init})
- Use
context.addInitializer to install the auto-generated setPropertyName() method on the prototype
- The setter must be action-wrapped to satisfy
enforceActions: 'observed'
Reference implementation:
function createBindable(
value: ClassAccessorDecoratorTarget<any, any>,
context: ClassAccessorDecoratorContext,
isRef: boolean
): ClassAccessorDecoratorResult<any, any> {
const setterName = 'set' + upperFirst(String(context.name));
const accessorName = String(context.name);
const mobxResult = (isRef ? observable.ref : observable)(value, context);
context.addInitializer(function (this: any) {
const proto = Object.getPrototypeOf(this);
if (!proto.hasOwnProperty(setterName)) {
Object.defineProperty(proto, setterName, {
value: action(function (val: any) { this[accessorName] = val; }),
configurable: true,
writable: true
});
}
});
return mobxResult;
}
3. Rewrite Custom Decorators (utils/js/Decorators.ts)
All 7 custom decorators use the legacy 3-argument API and must be rewritten to the TC39 2-argument (value, context) signature.
| Decorator |
Legacy Type |
TC39 Type |
Notes |
@computeOnce |
Method/getter |
ClassMethodDecoratorContext | ClassGetterDecoratorContext |
Returns replacement function |
@debounced(ms) |
Method |
ClassMethodDecoratorContext |
Decorator factory, returns replacement |
@logWithInfo |
Method |
ClassMethodDecoratorContext |
Returns replacement function |
@logWithDebug |
Method |
ClassMethodDecoratorContext |
Returns replacement function |
@enumerable |
Getter |
ClassGetterDecoratorContext |
May need rethinking β TC39 accessor decorators have different enumerability semantics |
@abstract |
Method/getter |
ClassMethodDecoratorContext | ClassGetterDecoratorContext |
Returns function that throws |
@sharePendingPromise |
Method |
ClassMethodDecoratorContext |
Returns replacement function |
Key API difference: Legacy decorators return a property descriptor. TC39 method/getter decorators return a replacement function (or void to keep the original). The context object provides context.name, context.kind, context.addInitializer, etc.
4. Remove makeObservable(this) Calls
Remove from every constructor across the codebase (~148 files). This includes:
HoistModel.ts (base constructor)
LoadSupport.ts
InstanceManager.ts
TaskObserver.ts
- All model subclasses
After removal, constructors that only contained super() + makeObservable(this) can be deleted entirely (TypeScript generates an implicit super() constructor).
5. Remove checkMakeObservable and overrides.ts
- Delete the
checkMakeObservable function from mobx/overrides.ts
- Remove the
checkMakeObservable call from HoistBase.ts constructor
- The enhanced
makeObservable wrapper in overrides.ts exists solely to process @bindable properties and delegate to the native MobX makeObservable β both are now unnecessary. Delete the file.
- Remove
makeObservable and isObservableProp from the mobx/index.ts re-exports (or re-export native MobX versions if needed for backward compatibility)
6. Add accessor Keyword to Observable Fields
Every @observable and @bindable field declaration must add the accessor keyword:
// Before
@observable.ref users: User[] = [];
@bindable selectedFund: string = null;
// After
@observable.ref
accessor users: User[] = [];
@bindable
accessor selectedFund: string = null;
This applies to all variants: @observable, @observable.ref, @observable.shallow, @bindable, @bindable.ref.
@action, @computed, and @managed do not use accessor β their usage is unchanged.
7. Update mobx/index.ts Re-exports
- Remove
makeObservable from re-exports (or keep as a pass-through to native MobX for any external consumers)
- Remove
isObservableProp and checkMakeObservable
- Keep all other MobX re-exports unchanged
hoist-dev-utils Changes
hoist-dev-utils provides the webpack/Babel build configuration for all hoist-react apps. The Babel decorator plugin configuration must be updated in configureWebpack.js.
configureWebpack.js
The decorator plugin (currently at line 465) must change from legacy to TC39:
// Before
['@babel/plugin-proposal-decorators', {version: 'legacy'}]
// After
['@babel/plugin-proposal-decorators', {version: '2023-05'}]
The @babel/preset-env config includes 'transform-class-properties' in its include array β this should remain as-is. With version: '2023-05', Babel's decorator plugin handles the interaction between decorators and class fields correctly without needing any special class-properties configuration.
Note: The {version: 'legacy'} option is what currently causes Babel to compile field initializers as plain assignments rather than Object.defineProperty β this is why legacy MobX decorators work in hoist-react apps despite useDefineForClassFields: true in the tsconfig. Changing to {version: '2023-05'} switches to TC39 semantics where the accessor keyword controls field behavior explicitly, making the assignment-vs-defineProperty distinction irrelevant.
Release Coordination
hoist-dev-utils must be released before or simultaneously with the hoist-react release. If hoist-dev-utils ships the 2023-05 decorator version while apps still use legacy decorator syntax (no accessor keyword), all @observable and @bindable fields will silently break. Conversely, if hoist-react ships with accessor keywords while apps still build with {version: 'legacy'}, Babel won't understand the accessor keyword.
The safest approach: release hoist-dev-utils and hoist-react together, with a coordinated major or minor version bump that consuming apps upgrade atomically.
Application Migration Guide
Consuming applications (toolbox, etc.) will need the following changes:
tsconfig.json
Remove "experimentalDecorators": true (and "emitDecoratorMetadata": true if present).
Code Changes
- Add
accessor to all @observable and @bindable fields β this is the bulk of the work
- Remove all
makeObservable(this) calls from constructors
- Delete empty constructors that only contained
super() + makeObservable(this)
Toolbox Scope
Toolbox should be migrated simultaneously as the validation app. The examples in client-app/src/examples/todo/ and examples/contact/ are good canaries β they use the same HoistModel + hoistCmp patterns as real apps.
Gotchas and Breaking Changes
1. Accessor Properties Are Not Enumerable
This is the most impactful behavioral change. With legacy decorators, @observable fields are own data properties on the instance β they appear in Object.keys(), JSON.stringify(), spread syntax ({...model}), and for...in loops.
With TC39 decorators, accessor fields become getter/setter pairs and are not enumerable β they won't appear in any of the above. Any code that iterates model properties or serializes model instances will break silently.
Known risk areas in hoist-react (~6 Object.keys() calls on models):
admin/tabs/cluster/objects/DetailModel.ts
admin/tabs/cluster/objects/ClusterObjectsModel.ts
cmp/form/FormModel.ts
desktop/cmp/dash/canvas/impl/utils.ts
desktop/cmp/dash/canvas/widgetchooser/DashCanvasWidgetChooser.ts
How to find: Search for Object.keys, Object.entries, Object.values, JSON.stringify, {...someModel}, and for (const key in where the target is a model instance or any object with @observable/@bindable fields.
2. @action Override Behavior Change
With legacy decorators, a subclass overriding an @action field throws TypeError: Cannot redefine property. With modern decorators, the override is silently allowed but the subclass version is not an action unless also decorated with @action.
How to find: Search for methods decorated with @action in base classes, then check if any subclass overrides them without re-applying @action. With noImplicitOverride: true (already enabled), TypeScript requires the override keyword β grep for override methods whose base class version has @action and verify the override also has @action.
3. @enumerable Decorator May Need Rethinking
The existing @enumerable decorator makes getters enumerable. With TC39 decorators, the mechanism for controlling enumerability is different. This decorator's implementation will need to be re-examined in the context of TC39 accessor semantics.
4. Direct MobX Imports
Any consuming code that imports makeObservable from @xh/hoist/mobx will break at the import level if it's removed. Search consuming apps for makeObservable imports. These should be simply deletable (the calls aren't needed), but verify each one.
5. _xhBindableProperties Metadata
Any code that reads _xhBindableProperties directly (e.g., for tooling, serialization, or debugging) will need updating since the @bindable implementation changes entirely. Search for _xhBindableProperties in both hoist-react and consuming apps.
6. Decorator Placement Style
TC39 decorators work on the same line or on a separate line β but the hoist-react convention is decorators on their own line above the property. Ensure the migration follows this consistently, especially for @observable accessor / @bindable accessor fields where the decorator + accessor + type can get long.
Verification Plan
- TypeScript:
tsc --noEmit passes with no errors
- Unit tests: All existing model-level tests pass
- Toolbox: Full app starts, navigates, and renders correctly β test the todo and contact examples specifically
- Runtime check: Verify that
@bindable setters (setXxx() methods) exist on model instances
- Runtime check: Verify that
@observable fields are reactive (autorun fires on mutation)
- Enumerability audit: Verify the ~6
Object.keys() call sites still behave correctly, or update them to use explicit property names
- Dev tools: MobX devtools /
trace() still show correct observable graphs
References
Migrate to TC39 Modern Decorators
Summary
Migrate hoist-react from TypeScript's legacy
experimentalDecoratorsto the TC39 2022.3 (Stage 3) modern decorator standard. This eliminates themakeObservable(this)constructor boilerplate across the entire framework and aligns with the direction of TypeScript, MobX, and the JavaScript ecosystem.MobX 6 supports both legacy and modern decorators. The migration path is documented at https://mobx.js.org/enabling-decorators.html.
Motivation
experimentalDecoratorsis a legacy TypeScript-only feature that predates the TC39 standard. TypeScript 5.0+ supports TC39 decorators natively.makeObservable(this)call is required in every class constructor that introduces observable properties β ~150 files in hoist-react. This is error-prone boilerplate; forgetting it causes silent reactivity failures. TC39 decorators initialize observables at class-definition time, eliminating this requirement entirely.Current State
experimentalDecorators: truetsconly (emitDeclarationOnly: true) β no Babel. Consuming apps provide their own bundler (webpack/Vite + Babel).makeObservable(this): Called in ~148 files@observable: ~223 occurrences across ~124 files@bindable: ~158 occurrences across ~93 files@debounced,@computeOnce,@logWithInfo,@logWithDebug,@enumerable,@abstract,@sharePendingPromise(inutils/js/Decorators.ts)@bindable,@bindable.ref(inmobx/decorators.ts)Implementation Steps
1. TypeScript Configuration
In
tsconfig.json:"experimentalDecorators": true"emitDecoratorMetadata": trueif present"useDefineForClassFields": true(already set, and irrelevant to TC39 decorator behavior)TypeScript 5.0+ treats decorators as TC39 by default when
experimentalDecoratorsis absent orfalse.2. Rewrite
@bindable/@bindable.ref(mobx/decorators.ts)The legacy
@bindableimplementation uses the 3-argument(target, property, descriptor)API and defers observable creation tomakeObservable(this)at construction time. With TC39 decorators,@bindablebecomes an accessor decorator that wraps MobX'sobservabledirectly and installs the setter.New signature:
(value: ClassAccessorDecoratorTarget, context: ClassAccessorDecoratorContext) => ClassAccessorDecoratorResultThe new implementation should:
observable(value, context)(orobservable.ref(value, context)for@bindable.ref) to get MobX's accessor decorator result ({get, set, init})context.addInitializerto install the auto-generatedsetPropertyName()method on the prototypeenforceActions: 'observed'Reference implementation:
3. Rewrite Custom Decorators (
utils/js/Decorators.ts)All 7 custom decorators use the legacy 3-argument API and must be rewritten to the TC39 2-argument
(value, context)signature.@computeOnceClassMethodDecoratorContext | ClassGetterDecoratorContext@debounced(ms)ClassMethodDecoratorContext@logWithInfoClassMethodDecoratorContext@logWithDebugClassMethodDecoratorContext@enumerableClassGetterDecoratorContext@abstractClassMethodDecoratorContext | ClassGetterDecoratorContext@sharePendingPromiseClassMethodDecoratorContextKey API difference: Legacy decorators return a property descriptor. TC39 method/getter decorators return a replacement function (or
voidto keep the original). Thecontextobject providescontext.name,context.kind,context.addInitializer, etc.4. Remove
makeObservable(this)CallsRemove from every constructor across the codebase (~148 files). This includes:
HoistModel.ts(base constructor)LoadSupport.tsInstanceManager.tsTaskObserver.tsAfter removal, constructors that only contained
super()+makeObservable(this)can be deleted entirely (TypeScript generates an implicitsuper()constructor).5. Remove
checkMakeObservableandoverrides.tscheckMakeObservablefunction frommobx/overrides.tscheckMakeObservablecall fromHoistBase.tsconstructormakeObservablewrapper inoverrides.tsexists solely to process@bindableproperties and delegate to the native MobXmakeObservableβ both are now unnecessary. Delete the file.makeObservableandisObservablePropfrom themobx/index.tsre-exports (or re-export native MobX versions if needed for backward compatibility)6. Add
accessorKeyword to Observable FieldsEvery
@observableand@bindablefield declaration must add theaccessorkeyword:This applies to all variants:
@observable,@observable.ref,@observable.shallow,@bindable,@bindable.ref.@action,@computed, and@manageddo not useaccessorβ their usage is unchanged.7. Update
mobx/index.tsRe-exportsmakeObservablefrom re-exports (or keep as a pass-through to native MobX for any external consumers)isObservablePropandcheckMakeObservablehoist-dev-utils Changes
hoist-dev-utils provides the webpack/Babel build configuration for all hoist-react apps. The Babel decorator plugin configuration must be updated in
configureWebpack.js.configureWebpack.jsThe decorator plugin (currently at line 465) must change from legacy to TC39:
The
@babel/preset-envconfig includes'transform-class-properties'in itsincludearray β this should remain as-is. Withversion: '2023-05', Babel's decorator plugin handles the interaction between decorators and class fields correctly without needing any special class-properties configuration.Note: The
{version: 'legacy'}option is what currently causes Babel to compile field initializers as plain assignments rather thanObject.definePropertyβ this is why legacy MobX decorators work in hoist-react apps despiteuseDefineForClassFields: truein the tsconfig. Changing to{version: '2023-05'}switches to TC39 semantics where theaccessorkeyword controls field behavior explicitly, making the assignment-vs-defineProperty distinction irrelevant.Release Coordination
hoist-dev-utils must be released before or simultaneously with the hoist-react release. If hoist-dev-utils ships the
2023-05decorator version while apps still use legacy decorator syntax (noaccessorkeyword), all@observableand@bindablefields will silently break. Conversely, if hoist-react ships withaccessorkeywords while apps still build with{version: 'legacy'}, Babel won't understand theaccessorkeyword.The safest approach: release hoist-dev-utils and hoist-react together, with a coordinated major or minor version bump that consuming apps upgrade atomically.
Application Migration Guide
Consuming applications (toolbox, etc.) will need the following changes:
tsconfig.json
Remove
"experimentalDecorators": true(and"emitDecoratorMetadata": trueif present).Code Changes
accessorto all@observableand@bindablefields β this is the bulk of the workmakeObservable(this)calls from constructorssuper()+makeObservable(this)Toolbox Scope
Toolbox should be migrated simultaneously as the validation app. The examples in
client-app/src/examples/todo/andexamples/contact/are good canaries β they use the same HoistModel + hoistCmp patterns as real apps.Gotchas and Breaking Changes
1. Accessor Properties Are Not Enumerable
This is the most impactful behavioral change. With legacy decorators,
@observablefields are own data properties on the instance β they appear inObject.keys(),JSON.stringify(), spread syntax ({...model}), andfor...inloops.With TC39 decorators,
accessorfields become getter/setter pairs and are not enumerable β they won't appear in any of the above. Any code that iterates model properties or serializes model instances will break silently.Known risk areas in hoist-react (~6
Object.keys()calls on models):admin/tabs/cluster/objects/DetailModel.tsadmin/tabs/cluster/objects/ClusterObjectsModel.tscmp/form/FormModel.tsdesktop/cmp/dash/canvas/impl/utils.tsdesktop/cmp/dash/canvas/widgetchooser/DashCanvasWidgetChooser.tsHow to find: Search for
Object.keys,Object.entries,Object.values,JSON.stringify,{...someModel}, andfor (const key inwhere the target is a model instance or any object with@observable/@bindablefields.2.
@actionOverride Behavior ChangeWith legacy decorators, a subclass overriding an
@actionfield throwsTypeError: Cannot redefine property. With modern decorators, the override is silently allowed but the subclass version is not an action unless also decorated with@action.How to find: Search for methods decorated with
@actionin base classes, then check if any subclass overrides them without re-applying@action. WithnoImplicitOverride: true(already enabled), TypeScript requires theoverridekeyword β grep foroverridemethods whose base class version has@actionand verify the override also has@action.3.
@enumerableDecorator May Need RethinkingThe existing
@enumerabledecorator makes getters enumerable. With TC39 decorators, the mechanism for controlling enumerability is different. This decorator's implementation will need to be re-examined in the context of TC39 accessor semantics.4. Direct MobX Imports
Any consuming code that imports
makeObservablefrom@xh/hoist/mobxwill break at the import level if it's removed. Search consuming apps formakeObservableimports. These should be simply deletable (the calls aren't needed), but verify each one.5.
_xhBindablePropertiesMetadataAny code that reads
_xhBindablePropertiesdirectly (e.g., for tooling, serialization, or debugging) will need updating since the@bindableimplementation changes entirely. Search for_xhBindablePropertiesin both hoist-react and consuming apps.6. Decorator Placement Style
TC39 decorators work on the same line or on a separate line β but the hoist-react convention is decorators on their own line above the property. Ensure the migration follows this consistently, especially for
@observable accessor/@bindable accessorfields where the decorator + accessor + type can get long.Verification Plan
tsc --noEmitpasses with no errors@bindablesetters (setXxx()methods) exist on model instances@observablefields are reactive (autorun fires on mutation)Object.keys()call sites still behave correctly, or update them to use explicit property namestrace()still show correct observable graphsReferences