Skip to content

Migrate to TC39 Stage 3 modern decorators#4333

Open
lbwexler wants to merge 18 commits into
developfrom
tc39-decorators
Open

Migrate to TC39 Stage 3 modern decorators#4333
lbwexler wants to merge 18 commits into
developfrom
tc39-decorators

Conversation

@lbwexler

@lbwexler lbwexler commented Apr 15, 2026

Copy link
Copy Markdown
Member

Summary

Eliminates the ~150-file makeObservable(this) boilerplate and aligns hoist-react with the direction of TypeScript, MobX, and the broader ecosystem. Closes #4321.

Requires coordinated release with @xh/hoist-dev-utils v14.0 — mixing versions silently breaks all @observable / @bindable fields.

Commits

  • 46407448e Migrate to TC39 Stage 3 modern decorators (v85) — atomic commit covering phases 1–6: rewrite @bindable / @bindable.ref for TC39, port 6 custom decorators in utils/js/Decorators.ts, codemod to add accessor keywords (342 sites across 131 files), codemod to remove makeObservable(this) (141 calls, 48 empty constructors, 139 import cleanups), delete mobx/overrides.ts + checkMakeObservable, flip tsconfig.json. Bumps to 85.0.0-SNAPSHOT.
  • d68ffdfc2 Fix TC39 decorator issues surfaced by toolbox smoke test — follow-up for plan gaps found at runtime: port @managed / @persist / @lookup (plan's §Out-of-Scope was wrong about these), replace MobX @override with @action at 9 sites (risk R2), wrap @bindable setter in runInAction to preserve direct-assignment semantics, rewrite ModelLookup.findChildModelMatching to walk the prototype chain so uses() resolves accessor-backed child-model references (risk R1), extend codemod for @decorator @observable.ref foo pre-decorator patterns.
  • 1ff2396c3 Bug fix — stops ModelLookup prototype walk at HoistModel.prototype to avoid side-effectful framework getters.

See CHANGELOG.md for the user-facing notes and the Phase 0 sandbox at docs/planning/tc39-decorators/spike/ that validated the approach before commit.

Test plan

  • tsc --build clean, yarn lint:code clean (pre-commit hook enforces both).
  • Phase 0 spike passes all 22 runtime gates (see spike runner).
  • Toolbox runs end-to-end against the linked branches — sign-off from the companion toolbox PR.

lbwexler and others added 4 commits April 15, 2026 16:12
Eliminates the ~148-file `makeObservable(this)` boilerplate and aligns hoist-react with the direction of TypeScript, MobX, and the broader ecosystem (#4321). Bumps to 85.0.0-SNAPSHOT; requires @xh/hoist-dev-utils >= 14.0 for the matching Babel flip.

All phases land in a single atomic commit because intermediate states don't compile — the TC39 decorator syntax requires `experimentalDecorators` off, while legacy syntax requires it on.

Phase 1 — rewrote `@bindable` / `@bindable.ref` in mobx/decorators.ts as TC39 accessor decorators that delegate to MobX `observable(value, context)` / `observable.ref(value, context)` and install the action-wrapped `setXxx()` on the prototype via `context.addInitializer`. Dropped the `_xhBindableProperties` metadata path.

Phase 2 — ported the 6 custom decorators in utils/js/Decorators.ts to the 2-arg `(value, context)` TC39 signature (`@debounced`, `@computeOnce`, `@logWithInfo`, `@logWithDebug`, `@abstract`, `@sharePendingPromise`). Deleted `@enumerable` — zero call sites in hoist-react or toolbox.

Phase 3 — codemod inserted the `accessor` keyword at 342 `@observable` / `@bindable` field declarations across 131 files. Handles inline and stacked-decorator forms (e.g. `@bindable @persist`). Script retained at docs/planning/tc39-decorators/codemod-add-accessor.mjs for audit.

Phase 4 — removed all 141 `makeObservable(this)` calls, 48 newly-empty constructors, and 139 now-unused `makeObservable` imports. Script at docs/planning/tc39-decorators/codemod-remove-makeObservable.mjs.

Phase 5 — deleted mobx/overrides.ts, removed `checkMakeObservable(this)` from HoistBase constructor, updated mobx/index.ts to re-export native MobX `makeObservable` / `isObservableProp` as a pass-through for external consumers.

Phase 6 — removed `experimentalDecorators: true` from tsconfig.json. `tsc --build` clean, `yarn lint:code` clean.

Phase 0 validation sandbox lives at docs/planning/tc39-decorators/spike/ — exercises all decorator patterns (`@observable accessor`, `@bindable accessor`, `@bindable.ref accessor`, subclass inheritance, enumerability semantics) against MobX 6.15.

Breaking-change notes captured in CHANGELOG.md §85.0.0-SNAPSHOT. App-side upgrade guide to follow in docs/upgrade-notes/.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Follow-up to the v85 migration commit (4640744) for issues the initial plan did not cover. All of these were discovered by actually running the migrated stack end-to-end against toolbox.

Migrate the three framework decorators the plan's §Out-of-Scope listed incorrectly: port `@managed`, `@persist` / `@persist.with(options)`, and `@lookup(selector)` from the legacy 3-arg signature to TC39 2-arg. `@managed` and `@lookup` use the field-decorator init-return pattern (not `addInitializer`) since Babel 2023-05 does not reliably invoke addInitializer callbacks for field decorators. `@persist` uses the accessor-decorator `init` chain so it composes correctly with `@bindable` / `@observable`.

Replace MobX's `@override` decorator with `@action` at 9 sites in SubformsFieldModel, desktop Select, and mobile Select. MobX's `@override` is annotation-only and logs "cannot be used with decorators" under TC39 — subclass overrides of base `@action` methods now need their own `@action` (plan risk R2). Remove the now-unused `override` imports from the three files.

Restore the pre-v85 `@bindable` contract that direct assignments (`model.foo = v`) are action-wrapped. MobX's native `observable` accessor decorator does not wrap its setter in runInAction, which broke every app relying on `enforceActions: 'observed'`. Wrap the returned `set` in `runInAction` in `createBindable`.

Rewrite `ModelLookup.findChildModelMatching` to walk the prototype chain and consider accessor-defined (getter-backed) child-model references in addition to own enumerable instance properties. This is plan risk R1: `@observable.ref accessor chartModel: ChartModel` on a parent model becomes a non-enumerable getter on the class prototype, so the legacy `forOwn(model, …)` iteration missed it and `uses(ChartModel)` could not resolve through context.

Extend the accessor codemod to handle cases where the mobx decorator is preceded by another decorator on the same line (e.g. `@managed @observable.ref foo`). Re-ran across hoist-react and toolbox — picked up 8 and 10 additional sites respectively.

Cast the `@lookup` selector argument in `ActivityDetailModel` to surface through the new strictly-typed `lookup()` signature (the old `lookup: any` hid this duck-typed access to `isActivityDetailProvider`).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Captures the app-side steps: atomic hoist + dev-utils bump, tsconfig flip, the two codemods, `@override` → `@action`, and the R1 enumeration audit with ready-to-run grep commands. Includes common migration-error troubleshooting.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@lbwexler lbwexler requested review from amcclain and haynesjm42 April 15, 2026 21:54
Comment thread docs/codemod/v87/codemod-remove-makeObservable.mjs Fixed
@lbwexler lbwexler marked this pull request as ready for review April 17, 2026 00:47

@haynesjm42 haynesjm42 left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Looks good - happy to be done with makeObservable(this)!

lbwexler and others added 8 commits May 15, 2026 06:12
# Conflicts:
#	admin/tabs/cluster/metrics/MetricsModel.ts
#	cmp/pinpad/PinPadModel.ts
#	core/HoistBase.ts
#	core/model/HoistModel.ts
#	desktop/cmp/input/DateInput.ts
#	desktop/cmp/rest/impl/RestFormModel.ts
#	desktop/cmp/viewmanager/dialog/SaveAsDialogModel.ts
#	docs/upgrade-notes/v85-upgrade-notes.md
#	eslint.config.js
#	svc/ChangelogService.ts
#	svc/InspectorService.ts
#	svc/WebSocketService.ts
#	yarn.lock
Two distinct TC39-migration follow-ups surfaced by exercising the admin app:

1. `RoleGraphModel.chartModel` was inline-initialized via `this.createChartModel()`, which reads `this.inverted` — a `@bindable accessor` declared below it. Under legacy decorators MobX installed observables on the prototype with their defaults, so reads at any time were safe. Under TC39 the backing slot only exists after that accessor's own initializer fires, so this threw `Private element is not present on this object` and the Roles tab error-boundaried on mount. Reordered the `@bindable accessor` fields above `chartModel`. Applied the same reorder to `RoleFormModel` pre-emptively — its `createFormModel` / `createGridModel` read several `@observable.ref accessor` fields, currently only from deferred callbacks, but one refactor away from biting.

2. `HeaderFilter` switcher rendered the `Custom` / `Values` buttons with blank labels because `tabs.map(it => switcherButton({...it}))` was spreading a live `TabModel`. TC39 `accessor` fields are getter/setter pairs on the prototype with per-instance private storage — they are never own enumerable properties, so spread copies none of them. Replaced with an explicit `{id: it.id, title: it.title}` pluck.

Documented both gotchas in `docs/upgrade-notes/v86-upgrade-notes.md`: added a Troubleshooting entry for the field-init order error and a "Other notable changes" entry calling out the spread-on-instance silent-drop. Apps upgrading to v86 should sweep for `{...someModel}` patterns.

Audited toolbox `client-app/src` for the same shapes — no consumer-side fixes needed.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The previous commit added two bug-fix entries describing the TC39 field-init order and instance-spread fixes. Both regressions are internal to the unreleased v86 migration — they were never present in shipped code — so they don't belong in user-facing release notes. The upgrade-notes entries remain (those are the right place to surface the gotchas).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@amcclain amcclain changed the title Migrate to TC39 Stage 3 modern decorators (v85) Migrate to TC39 Stage 3 modern decorators Jun 17, 2026
Comment thread CHANGELOG.md Outdated
null model when a `testId` was supplied, introduced by the v84 expansion of `testId` coverage on
built-in appcontainer components.

### 💥 Breaking Changes (upgrade difficulty: 🟠 MEDIUM — mechanical, codemod-assisted)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

CHANGELOG entries have gotten duped up / booked under wrong version

Comment thread CHANGELOG.md Outdated
* **Migrated to TC39 Stage 3 (2023-05) decorators**, retiring `experimentalDecorators`. Drops
`makeObservable(this)` boilerplate and gives Hoist per-property private storage. Apps add
`accessor` to `@observable`/`@bindable` fields and run the codemod in
`docs/planning/tc39-decorators/`. Main risk: `@observable accessor` fields are now prototype

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Reference to codemod in planning docs

Comment thread CHANGELOG.md Outdated
`accessor` to `@observable`/`@bindable` fields and run the codemod in
`docs/planning/tc39-decorators/`. Main risk: `@observable accessor` fields are now prototype
getter/setters, not own enumerable props — affects `Object.keys` / spread (`{...model}`) over
model instances. See `docs/upgrade-notes/v86-upgrade-notes.md`.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Was going to suggest we generate the upgrade notes as a way of examining the change - need to do so / fix this up

Comment thread CHANGELOG.md

* Model lookup now subscribes only to slots that can affect resolution — the matched slot, or
nullish/HoistModel candidates if no match. Primitive observables are excluded. Tighter than
the prior walk, which subscribed indiscriminately and triggered needless re-renders.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Is this part of the change, related to decorators? I confess I wasn't sure what to make of it - didn't know what "the matched slot" meant, assume other readers won't either.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

yes, happy to discuss with you, but its a required, meaningful change that makes the change possible.
(The accessors are no longer enumerable.)

Fortunately it ends up being an efficiency improvement as well, so happy about that.

// with optional blank lines in the body.
const text = lines.join('\n');
const reEmptyCtor =
/^([ \t]*)constructor\s*\([^)]*\)\s*\{\s*\n\s*super\s*\([^;]*\);\s*\n(?:\s*\n)*\s*\}\s*\n?/gm;
@amcclain amcclain added this to the v87 milestone Jun 25, 2026
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.

Migrate to TC39 Modern Decorators

4 participants