Migrate to TC39 Stage 3 modern decorators#4333
Conversation
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>
# Conflicts: # CHANGELOG.md
haynesjm42
left a comment
There was a problem hiding this comment.
Looks good - happy to be done with makeObservable(this)!
# 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>
| 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) |
There was a problem hiding this comment.
CHANGELOG entries have gotten duped up / booked under wrong version
| * **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 |
There was a problem hiding this comment.
Reference to codemod in planning docs
| `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`. |
There was a problem hiding this comment.
Was going to suggest we generate the upgrade notes as a way of examining the change - need to do so / fix this up
|
|
||
| * 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. |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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; |
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-utilsv14.0 — mixing versions silently breaks all@observable/@bindablefields.Commits
46407448eMigrate to TC39 Stage 3 modern decorators (v85) — atomic commit covering phases 1–6: rewrite@bindable/@bindable.reffor TC39, port 6 custom decorators inutils/js/Decorators.ts, codemod to addaccessorkeywords (342 sites across 131 files), codemod to removemakeObservable(this)(141 calls, 48 empty constructors, 139 import cleanups), deletemobx/overrides.ts+checkMakeObservable, fliptsconfig.json. Bumps to 85.0.0-SNAPSHOT.d68ffdfc2Fix 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@overridewith@actionat 9 sites (risk R2), wrap@bindablesetter inrunInActionto preserve direct-assignment semantics, rewriteModelLookup.findChildModelMatchingto walk the prototype chain souses()resolves accessor-backed child-model references (risk R1), extend codemod for@decorator @observable.ref foopre-decorator patterns.1ff2396c3Bug fix — stopsModelLookupprototype walk atHoistModel.prototypeto avoid side-effectful framework getters.See
CHANGELOG.mdfor the user-facing notes and the Phase 0 sandbox atdocs/planning/tc39-decorators/spike/that validated the approach before commit.Test plan
tsc --buildclean,yarn lint:codeclean (pre-commit hook enforces both).