Skip to content

feat: use TC39 decorators instead of experimentalDecorators#74

Merged
treardon17 merged 14 commits into
mainfrom
tdr/decorators
Apr 20, 2026
Merged

feat: use TC39 decorators instead of experimentalDecorators#74
treardon17 merged 14 commits into
mainfrom
tdr/decorators

Conversation

@treardon17
Copy link
Copy Markdown
Collaborator

Type Description Icon Changelog
chore Internal stuff (feat/fix/debt/etc...) 👷
feat Shiny new functionality 😎

🛠️ Changes:

Breaking

  • TC39 standard decorators replace TypeScript's experimental decorators. Requires TS 5.0+, target: "ES2022" (or later), and useDefineForClassFields: true. Remove experimentalDecorators: true from tsconfig.json.
  • @computed defaults to non-enumerable (matches native class-getter semantics — Object.keys(instance) and { ...instance } no longer pick up computeds). Opt back via @computed.configure({ enumerable: true }).
  • @reactive defaults to configurable: true (was non-configurable; blocked HMR, test resets, re-decoration). Opt back via @reactive.configure({ configurable: false }).

New

  • compose(...mixins) — functional-mixin helper that folds higher-order class factories ((Base: B) => class extends Base { ... }) via native extends. Alternative to @classMixin where field initializers carry over and types flow through inheritance without interface X extends Y {} declaration merging.

📚 Bookkeeping:

Testing (if applicable):

  • Ran/wrote unit tests for this

Checklist

  • Assigned PR to myself
  • Added at least 1 person on the team as reviewer
  • Release Notes: PRs types that have the 🗒️ next to them also require release notes to be added to the CHANGELOG.md

treardon17 and others added 12 commits April 17, 2026 18:48
Replace legacy `experimentalDecorators` with TC39 Stage 3 decorators.
`@reactive` becomes a field decorator that uses `addInitializer` to install
a reactive accessor on the instance. `@computed` becomes a getter decorator
that installs a cached reactive computed on the instance.

Mixin support is preserved via a new `mixinSupport` module that reads decorator
metadata from mixed-in classes (via `context.metadata` / `Symbol.metadata`) and
replays it onto the target prototype. `applyClassMixins` accumulates mixin
metadata into the target so downstream chains see the full inheritance.

Also publishes `ReactiveDecorator` and `ComputedDecorator` types so consumers
re-exporting the decorators don't hit TS4023 "cannot be named" errors.

BREAKING: consumers must turn off `experimentalDecorators` in their tsconfig.
`@computed` now defaults to `enumerable: false` to match native class-getter
semantics and avoid accidental eager evaluation during object spread.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Classes that get instantiated before `Madrone.use(...)` now set up
correctly once an integration becomes available. Previously the
`addInitializer` callback would check `getIntegration()` and bail
silently — leaving those instances frozen as plain objects with no
retry path if an integration was registered later.

- `@reactive`: when no integration is active, stashes the initial value
  on the instance (via a non-enumerable symbol-keyed map), drops the
  instance-own data property, and installs a prototype-level lazy
  accessor (guarded by a per-prototype/per-key WeakMap so each prototype
  is only instrumented once). First read/write after an integration is
  registered installs the real reactive accessor via `define()`, using
  the stashed value; subsequent accesses hit the instance accessor
  directly.
- `@computed`: returns a lazy wrapper getter that replaces the class's
  original on the prototype. Without an integration, the wrapper falls
  back to calling the original getter (no caching). Once an integration
  is active, first access installs a cached reactive computed on the
  instance; subsequent accesses bypass the prototype wrapper.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Switch the deferred-integration tests from `MadroneState` (which
`index.ts` registers by default) to `MadroneVue3(Vue)`, which must be
explicitly registered. This proves two things in one pass:

1. The deferred install actually lands once an integration becomes
   available — not just that any-integration-eventually-works.
2. The plugin system genuinely bridges to Vue's reactivity — a Vue
   `watchEffect` re-runs when a decorated field changes, which is only
   possible if the deferred install wired the field up to the active
   integration's notify path.

The tests now cover `@reactive` alone, `@computed` alone, and a
combined case where a computed depends on two reactive fields — each
verifies Vue's effect reacts to writes after late `Madrone.use(...)`.

Use `nextTick` with the default `flush: 'pre'` rather than
`flush: 'sync'`; the latter surfaces an unrelated ordering quirk in
`typeHandlers.ts` (the `onSet` hook fires before `Reflect.set` lands)
that isn't in scope for this change.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- Forward context.metadata through @classMixin so base class decorators
  aren't lost when TS attaches the metadata bag after decoration.
- Add reactive.configure({ init: () => ... }) so mixed-in @reactive fields
  can supply a default, since TC39 field initializers don't cross the mixin
  boundary. On the declaring class, init only fires when the field is
  otherwise undefined.
- Cover classMixin methods, mixin @reactive (with and without init factory),
  @computed mixins with paired setters and target overrides, and a full
  @reactive + @computed chain through a mixin.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Bug 1 (metadata): subclass decorator writes were mutating the parent's
MADRONE_META array. A subclass's metadata bag proto-chains to the parent's
via Object.create, so bare property reads like `bag[MADRONE_META]` walked
up and found the parent's array. Fix: own-property check everywhere we
write the array, seed a fresh own array with a copy of inherited entries
so subclasses still see the ancestor chain without touching parent state.

Bug 2 (timing): @classMixin no longer forwards context.metadata, defers
application via context.addInitializer instead. By the time the initializer
fires, TS has already attached target[Symbol.metadata], so applyClassMixins
reads the standard metadata path. The optional baseMetadata parameter is
kept for consumers building their own class decorators that can't defer
(e.g. drone's mixinClass / sdviClass), with docs explaining when to use it.

Bug 3 (static): added dedicated coverage for static @reactive / @computed
(field reactivity, cached getters, paired setters, .configure overrides,
.shallow, static inheritance).

Also added inheritance tests that would have caught bug 1.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…tall

- Remove `init` factory option from `reactive.configure` and the
  `ReactiveDecoratorConfig` type. It was a workaround for mixin field
  initializers not crossing the class boundary under TC39 decorators;
  users who need that pattern can declare reactive state on the target
  class or use functional mixins.

- Collapse `installDeferredReactive` + `installMixinReactive` into a single
  `installLazyReactive` primitive. Both paths install a prototype-level
  lazy accessor that promotes to a real reactive on first read/write; the
  only runtime difference is whether there's a stashed initial value to
  pick up (pre-integration defer case) or not (mixin replay). The
  `installMixinComputed` path stays separate — its paired-setter
  resolution needs to thread through the `__madroneMixin` marker to
  survive multi-layer mixin composition.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…tion marker

Previously we tagged lazy getters/setters with a `__madroneMixin = true`
property to distinguish them from user-declared accessors. Move the
tracking into a module-private WeakSet so we stop mutating function
objects. Same semantics (identity-based membership), smaller surface area
— drops the `MixinMarkedFn` type and its scattered casts.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…ELOG

- Move per-instance pending-value stash from an own symbol-keyed property
  to a module-private WeakMap so it doesn't show up in
  Reflect.ownKeys(instance) or Object.getOwnPropertySymbols(instance).
- Expand the @classMixin addInitializer comment to explain the timing
  choice and cross-reference the applyClassMixins third-parameter path
  for consumers writing their own synchronous class decorators.
- Fill in the [Unreleased] CHANGELOG section covering the TC39 migration,
  @computed default-non-enumerable, deferred install, and the
  applyClassMixins third parameter.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The previous non-configurable default blocked redefining reactive
properties after first install — inconvenient for HMR, test harnesses,
and downstream re-decoration. It matched a leftover from the Vue 2 era
where non-configurable descriptors avoided double-wrapping; Vue 3 is
proxy-based and doesn't need the guard.

Flipping the default is a breaking change for any code that relied on
`Object.defineProperty` throwing on a reactive key. `@reactive.configure({
configurable: false })` restores the old behavior.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
compose(...mixinFactories) folds a list of higher-order class functions
into a single base class suitable for `extends`. Alternative to
@classMixin that uses native class inheritance:

  const Named = <T extends Constructor>(Base: T) => class extends Base {
    @reactive fName = '';
  };

  class Person extends compose(Named, Timestamped) {
    @reactive age = 0;
  }

Unlike @classMixin, field initializers from the mixin run (via real
extends), types flow through without interface-declaration-merging
boilerplate, and there's no metadata-replay machinery. Leftmost mixin
is outermost (Redux-style).

Variadic-tuple return type (UnionToIntersection over ReturnType of each
factory in the tuple) means no hard arity cap — compose(A, B, C, D, E, F, ...)
works for any N.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- Fix compose() JSDoc placement (was stranded above type aliases; now
  directly above the function) and add a see-also to classMixin plus a
  note that the typing is variadic.
- Reorder mixinSupport.ts so ensureMadroneMeta's JSDoc sits above
  ensureMadroneMeta rather than ensureMadroneMetaOnBag.
- Replace the misleading `createdAt = Date.now()` example on @classMixin
  (class fields don't carry across the prototype-merge boundary) with a
  decorator + method example, and expand the JSDoc with concrete notes
  on the @reactive field-initializer limitation, type-merging
  requirement, chaining-order caveat, and a pointer to compose().
- Update the deferred-install section header to clarify @computed goes
  through its own lazy getter path, not this machinery.
- Replace "tagged" phrasing in installMixinComputed's JSDoc with
  "recorded in mixinInstalledFns" (matches the WeakSet refactor).
- Drop the @internal tag on getDefaultDescriptors since the function is
  exported and used externally; the mismatch was confusing.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The deferred-install and applyClassMixins third-parameter entries are
implementation details, not user-facing features. The deferred path is
indistinguishable from the old synchronous path for anyone who calls
Madrone.use() before constructing instances (the common case). The
third parameter is niche power-user API for consumers writing their own
class decorators.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@treardon17 treardon17 requested a review from bitencode April 18, 2026 23:17
@treardon17 treardon17 self-assigned this Apr 18, 2026
Copy link
Copy Markdown
Collaborator

@bitencode bitencode left a comment

Choose a reason for hiding this comment

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

🎉 Looks good to me - let's try it! 😄

@treardon17 treardon17 merged commit edfd907 into main Apr 20, 2026
5 checks passed
@treardon17 treardon17 deleted the tdr/decorators branch April 20, 2026 19:14
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants