Skip to content

Migrate to TC39 Modern DecoratorsΒ #4321

@haynesjm42

Description

@haynesjm42

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

  1. Add accessor to all @observable and @bindable fields β€” this is the bulk of the work
  2. Remove all makeObservable(this) calls from constructors
  3. 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

  1. TypeScript: tsc --noEmit passes with no errors
  2. Unit tests: All existing model-level tests pass
  3. Toolbox: Full app starts, navigates, and renders correctly β€” test the todo and contact examples specifically
  4. Runtime check: Verify that @bindable setters (setXxx() methods) exist on model instances
  5. Runtime check: Verify that @observable fields are reactive (autorun fires on mutation)
  6. Enumerability audit: Verify the ~6 Object.keys() call sites still behave correctly, or update them to use explicit property names
  7. Dev tools: MobX devtools / trace() still show correct observable graphs

References

Metadata

Metadata

Assignees

Labels

No labels
No labels

Type

No type
No fields configured for issues without a type.

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions