feat: use TC39 decorators instead of experimentalDecorators#74
Merged
Conversation
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>
bitencode
approved these changes
Apr 20, 2026
Collaborator
bitencode
left a comment
There was a problem hiding this comment.
🎉 Looks good to me - let's try it! 😄
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
chorefeat🛠️ Changes:
Breaking
New
📚 Bookkeeping:
Testing (if applicable):
Checklist
CHANGELOG.md