From 864bda5a6e27381327c993413b65b322174a59ad Mon Sep 17 00:00:00 2001 From: Tyler Reardon Date: Fri, 17 Apr 2026 18:48:01 -0700 Subject: [PATCH 01/12] feat: migrate to TC39 standard decorators 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) --- src/__spec__/decorateComputed.spec.ts | 13 +- src/decorate.ts | 387 +++++++++---------------- src/integrations/__spec__/testClass.ts | 19 +- src/mixinSupport.ts | 296 +++++++++++++++++++ src/util.ts | 87 +++++- tsconfig.json | 4 +- 6 files changed, 548 insertions(+), 258 deletions(-) create mode 100644 src/mixinSupport.ts diff --git a/src/__spec__/decorateComputed.spec.ts b/src/__spec__/decorateComputed.spec.ts index 4680986..666060b 100644 --- a/src/__spec__/decorateComputed.spec.ts +++ b/src/__spec__/decorateComputed.spec.ts @@ -4,7 +4,10 @@ import { computed } from '../index'; describe('computed decorator', () => { describe('enumerable', () => { - it('makes properties enumerable by default', () => { + it('makes properties non-enumerable by default', () => { + // Matches native JS semantics for class getters — `class X { get foo() {} }` + // puts `foo` on the prototype as non-enumerable. This also prevents + // `{ ...instance }` from accidentally triggering every computed getter. class Test { @computed get test() { return true; @@ -14,12 +17,12 @@ describe('computed decorator', () => { const instance = new Test(); expect(instance.test).toEqual(true); - expect(Object.keys(instance)).toEqual(['test']); + expect(Object.keys(instance)).toEqual([]); }); - it('can make properties non-enumerable', () => { + it('can make properties enumerable', () => { class Test { - @computed.configure({ enumerable: false }) + @computed.configure({ enumerable: true }) get test() { return true; } @@ -28,7 +31,7 @@ describe('computed decorator', () => { const instance = new Test(); expect(instance.test).toEqual(true); - expect(Object.keys(instance)).toEqual([]); + expect(Object.keys(instance)).toEqual(['test']); }); }); diff --git a/src/decorate.ts b/src/decorate.ts index 64c28b5..0e6685b 100644 --- a/src/decorate.ts +++ b/src/decorate.ts @@ -1,12 +1,15 @@ /** * @module decorate * - * TypeScript decorators for class-based reactive state management. + * TC39 Stage 3 decorators for class-based reactive state management. * * Provides `@reactive` and `@computed` decorators that enable automatic - * reactivity on class properties and getters. These decorators lazily - * initialize reactive properties on first access, making them efficient - * for large class hierarchies. + * reactivity on class fields and getters. Reactivity is installed per-instance + * via `addInitializer`, and decorator metadata is recorded on the class so + * that `applyClassMixins` can replicate mixed-in reactivity on the target. + * + * Requires TypeScript 5.0+ with standard decorators and + * `useDefineForClassFields: true` (default under `target: ES2022`+). * * @example * ```ts @@ -25,18 +28,25 @@ import { getIntegration } from '@/global'; import { applyClassMixins } from '@/util'; import { define } from '@/auto'; +import { + computedDescriptor, + findAccessor, + markInitialized, + reactiveDescriptor, + recordMeta, +} from '@/mixinSupport'; import { DecoratorOptionType, DecoratorDescriptorType, Constructor } from './interfaces'; -const itemMap = new WeakMap>(); +// //////////////////////////// +// CLASS MIXIN +// //////////////////////////// /** * Class decorator that mixes in methods from other classes. * - * Copies all prototype properties from the mixin classes onto the - * decorated class. Useful for composing behavior from multiple sources. - * - * @param mixins - Classes whose prototypes will be mixed in - * @returns A class decorator function + * Copies prototype properties from the mixin classes onto the decorated + * class and replays their `@reactive` / `@computed` decorator metadata so + * that mixed-in reactivity works on instances of the target class. * * @example * ```ts @@ -44,294 +54,185 @@ const itemMap = new WeakMap>(); * createdAt = Date.now(); * } * - * class Serializable { - * toJSON() { return JSON.stringify(this); } - * } - * - * @classMixin(Timestamped, Serializable) + * @classMixin(Timestamped) * class Model { * name: string; * } - * - * const model = new Model(); - * model.toJSON(); // Works - mixed in from Serializable * ``` */ export function classMixin(...mixins: Constructor[]) { - return (target: Constructor) => { + return function classMixinDecorator(target: T): void { if (mixins?.length) { applyClassMixins(target, mixins); } }; } -function trackTargetIfNeeded(target: object): void { - if (!itemMap.has(target)) { - itemMap.set(target, new Set()); - } -} - -function checkTargetObserved(target: object, key: string): boolean { - trackTargetIfNeeded(target); - - return itemMap.get(target).has(key); -} - -function setTargetObserved(target: object, key: string): void { - trackTargetIfNeeded(target); - itemMap.get(target).add(key); -} - // //////////////////////////// -// COMPUTED +// REACTIVE // //////////////////////////// -function computedIfNeeded( - target: object, - key: string, - descriptor: PropertyDescriptor, - options?: DecoratorOptionType -): boolean { - const pl = getIntegration(); +export type ReactiveFieldDecorator = ( + value: undefined, + context: ClassFieldDecoratorContext +) => void; + +export interface ReactiveDecorator extends ReactiveFieldDecorator { + /** + * Decorator variant that creates a shallow reactive property. + * + * Only the property itself is reactive — nested objects and arrays are not + * wrapped. Use this for large collections where deep tracking is unnecessary. + * + * @example + * ```ts + * class Cache { + * @reactive.shallow data = { nested: { value: 1 } }; + * } + * ``` + */ + shallow: ReactiveFieldDecorator, + + /** + * Creates a configured reactive decorator with custom descriptor options. + * + * @param descriptorOverrides - Options to customize the reactive behavior + * + * @example + * ```ts + * class Example { + * @reactive.configure({ deep: false, enumerable: false }) + * hiddenData = { secret: true }; + * } + * ``` + */ + configure: (descriptorOverrides: DecoratorDescriptorType) => ReactiveFieldDecorator, +} - if (pl && !checkTargetObserved(target, key)) { - define(target, key, { - ...descriptor, - get: descriptor.get.bind(target), - set: descriptor.set?.bind(target), - enumerable: true, - ...options?.descriptors, - cache: true, - }); - setTargetObserved(target, key); +function createReactiveDecorator(options?: DecoratorOptionType): ReactiveFieldDecorator { + return function reactiveDecorator(_value, context): void { + const key = context.name; - return true; - } + recordMeta(context.metadata, { kind: 'reactive', key, options }); - return false; -} + context.addInitializer(function addReactiveInitializer() { + const instance = this as object; -function decorateComputed( - target: object, - key: string, - descriptor: PropertyDescriptor, - options?: DecoratorOptionType -): PropertyDescriptor { - if (typeof descriptor.get === 'function') { - const newDescriptor: PropertyDescriptor = { - ...descriptor, - enumerable: true, - configurable: true, - get: function computedGetter(this: Record) { - computedIfNeeded(this, key, descriptor, options); + if (!markInitialized(instance, key)) return; - return this[key]; - }, - set: function computedSetter(this: Record, val: unknown) { - computedIfNeeded(this, key, descriptor, options); - this[key] = val; - }, - }; + if (!getIntegration()) return; - return newDescriptor; - } + // Under useDefineForClassFields, field initialization has already + // assigned the value as a plain data property. Capture it, then + // redefine the property as a reactive accessor. + const initialValue = (instance as Record)[key as string]; - return descriptor; + define(instance, key as string, reactiveDescriptor(initialValue, options)); + }); + }; } /** - * Decorator that creates a cached computed property from a getter. - * - * When applied to a getter, the computed value is cached and only recalculated - * when its reactive dependencies change. This provides efficient derived state - * that automatically stays in sync with source data. + * Decorator that makes a class field reactive. * - * The decorator lazily initializes the computed property on first access, - * so it works correctly with class inheritance and instance creation. - * - * @param target - The class prototype - * @param key - The property name - * @param descriptor - The property descriptor containing the getter - * @returns Modified property descriptor with caching behavior + * When the field changes, computed properties and watchers that depend on it + * will automatically update. Reactivity is deep by default — nested objects + * and arrays are also wrapped. * * @example * ```ts - * import { reactive, computed } from '@madronejs/core'; - * - * class ShoppingCart { - * @reactive items: Array<{ price: number }> = []; - * - * @computed get total() { - * return this.items.reduce((sum, item) => sum + item.price, 0); - * } - * - * @computed get isEmpty() { - * return this.items.length === 0; - * } - * } - * - * const cart = new ShoppingCart(); - * cart.items.push({ price: 10 }); - * console.log(cart.total); // 10 (computed once) - * console.log(cart.total); // 10 (cached, no recalculation) - * ``` - */ -export function computed(target: object, key: string, descriptor: PropertyDescriptor): PropertyDescriptor { - return decorateComputed(target, key, descriptor); -} - -/** - * Creates a configured computed decorator with custom options. + * import { reactive, computed, watch } from '@madronejs/core'; * - * @param descriptorOverrides - Options to customize the computed behavior - * @returns A computed decorator with the specified configuration + * class User { + * @reactive name = 'Anonymous'; + * @reactive preferences = { theme: 'dark' }; * - * @example - * ```ts - * class Example { - * @computed.configure({ cache: false }) - * get uncached() { - * return Date.now(); // Recalculates every access + * @computed get greeting() { + * return `Hello, ${this.name}!`; * } * } * ``` */ -computed.configure = function configureComputed(descriptorOverrides: DecoratorDescriptorType) { - return (target: object, key: string, descriptor: PropertyDescriptor) => decorateComputed( - target, - key, - descriptor, - { descriptors: descriptorOverrides } - ); -}; +export const reactive: ReactiveDecorator = Object.assign( + createReactiveDecorator(), + { + shallow: createReactiveDecorator({ descriptors: { deep: false } }), + configure: (descriptorOverrides: DecoratorDescriptorType) => createReactiveDecorator({ descriptors: descriptorOverrides }), + } +); // //////////////////////////// -// REACTIVE +// COMPUTED // //////////////////////////// -function reactiveIfNeeded(target: object, key: string, options?: DecoratorOptionType): boolean { - const pl = getIntegration(); +export type ComputedGetterDecorator = ( + getter: (this: This) => Value, + context: ClassGetterDecoratorContext +) => void; + +export interface ComputedDecorator extends ComputedGetterDecorator { + /** + * Creates a configured computed decorator with custom descriptor options. + * + * @example + * ```ts + * class Example { + * @computed.configure({ cache: false }) + * get uncached() { + * return Date.now(); + * } + * } + * ``` + */ + configure: (descriptorOverrides: DecoratorDescriptorType) => ComputedGetterDecorator, +} + +function createComputedDecorator(options?: DecoratorOptionType): ComputedGetterDecorator { + return function computedDecorator(getter, context): void { + const key = context.name; + const typedGetter = getter as unknown as (this: object) => unknown; - if (pl && !checkTargetObserved(target, key)) { - setTargetObserved(target, key); - define(target, key, { - ...Object.getOwnPropertyDescriptor(target, key), - enumerable: true, - ...options?.descriptors, + recordMeta(context.metadata, { + kind: 'computed', key, options, getter: typedGetter, }); - return true; - } + context.addInitializer(function addComputedInitializer() { + const instance = this as object; - return false; -} + if (!markInitialized(instance, key)) return; + + if (!getIntegration()) return; -function decorateReactive(target: object, key: string, options?: DecoratorOptionType): void { - if (typeof target === 'function') { - // handle the static case - reactiveIfNeeded(target, key); - } else { - // handle the prototype case - Object.defineProperty(target, key, { - configurable: true, - enumerable: true, - get(this: Record) { - if (reactiveIfNeeded(this, key, options)) { - return this[key]; - } + // TC39 getter decorators only receive the getter; if the class paired + // it with an un-decorated setter, pull the setter off the descriptor + // so writes flow through the reactive Computed wrapper. + const existing = findAccessor(instance, key); + const setter = existing?.set as ((val: unknown) => void) | undefined; - return undefined; - }, - set(this: Record, val: unknown) { - if (reactiveIfNeeded(this, key, options)) { - this[key] = val; - } - }, + define(instance, key as string, computedDescriptor(typedGetter, setter, options)); }); - } + }; } /** - * Decorator that makes a class property reactive. - * - * When the property value changes, any computed properties or watchers - * that depend on it will automatically update. By default, reactivity - * is deep - nested objects and arrays will also be reactive. - * - * The decorator lazily initializes reactivity on first access, making - * it efficient for classes with many properties that may not all be used. + * Decorator that creates a cached computed property from a getter. * - * @param target - The class prototype (or constructor for static properties) - * @param key - The property name + * Cached values are only recalculated when their reactive dependencies change. * * @example * ```ts - * import { reactive, computed, watch } from '@madronejs/core'; - * - * class User { - * @reactive name = 'Anonymous'; - * @reactive preferences = { theme: 'dark' }; + * class ShoppingCart { + * @reactive items: Array<{ price: number }> = []; * - * @computed get greeting() { - * return `Hello, ${this.name}!`; + * @computed get total() { + * return this.items.reduce((sum, item) => sum + item.price, 0); * } * } - * - * const user = new User(); - * - * watch( - * () => user.name, - * (name) => console.log(`Name changed to ${name}`) - * ); - * - * user.name = 'Alice'; // Triggers watcher, updates greeting - * user.preferences.theme = 'light'; // Deep reactivity works * ``` */ -export function reactive(target: object, key: string): void { - return decorateReactive(target, key); -} - -/** - * Decorator variant that creates a shallow reactive property. - * - * Only the property itself is reactive, not nested objects or arrays. - * Use this when you don't need deep reactivity and want better performance, - * or when dealing with large objects where deep tracking is unnecessary. - * - * @param target - The class prototype - * @param key - The property name - * - * @example - * ```ts - * class Cache { - * // Only triggers when `data` is reassigned, not when nested values change - * @reactive.shallow data = { nested: { value: 1 } }; - * } - * - * const cache = new Cache(); - * cache.data.nested.value = 2; // Does NOT trigger reactivity - * cache.data = { nested: { value: 3 } }; // DOES trigger reactivity - * ``` - */ -reactive.shallow = function configureReactive(target: object, key: string): void { - return decorateReactive(target, key, { descriptors: { deep: false } }); -}; - -/** - * Creates a configured reactive decorator with custom options. - * - * @param descriptorOverrides - Options to customize the reactive behavior - * @returns A reactive decorator with the specified configuration - * - * @example - * ```ts - * class Example { - * @reactive.configure({ deep: false, enumerable: false }) - * hiddenData = { secret: true }; - * } - * ``` - */ -reactive.configure = function configureReactive(descriptorOverrides: DecoratorDescriptorType) { - return (target: object, key: string) => decorateReactive(target, key, { descriptors: descriptorOverrides }); -}; +export const computed: ComputedDecorator = Object.assign( + createComputedDecorator(), + { + configure: (descriptorOverrides: DecoratorDescriptorType) => createComputedDecorator({ descriptors: descriptorOverrides }), + } +); diff --git a/src/integrations/__spec__/testClass.ts b/src/integrations/__spec__/testClass.ts index a55c7b6..46de0ce 100644 --- a/src/integrations/__spec__/testClass.ts +++ b/src/integrations/__spec__/testClass.ts @@ -161,10 +161,16 @@ describe('reactive classes', () => { } }); - it('makes accessed properties enumerable', () => { + it('makes decorated properties enumerable', () => { + // Under TC39 standard decorators with `useDefineForClassFields: true`, + // class fields (decorated or not) are always own instance properties, so + // Object.keys will include undecorated fields as well. Here we just + // verify that @reactive / @computed members show up as expected. const fooInstance = Foo.create({ name: 'foo' }); + const keys = Object.keys(fooInstance); - expect(Object.keys(fooInstance)).toEqual(['name', 'age']); + expect(keys).toContain('name'); + expect(keys).toContain('age'); }); it('caches computed', () => { @@ -193,12 +199,17 @@ describe('reactive classes', () => { expect(fooInstance.getterSetter).toEqual('test!'); }); - it('accessed computed is enumerable', () => { + it('reactive fields are enumerable; @computed is not', () => { + // `@computed` defaults to non-enumerable (matches native JS semantics for + // class getters). `@reactive` fields stay enumerable like data props. const fooInstance = Foo.create(); fooInstance.getterSetter = 'test!'; - expect(Object.keys(fooInstance)).toEqual(['name', 'age', 'getterSetter', '_getterSetter']); + const keys = Object.keys(fooInstance); + + expect(keys).toContain('_getterSetter'); + expect(keys).not.toContain('getterSetter'); }); it('watches data', async () => { diff --git a/src/mixinSupport.ts b/src/mixinSupport.ts new file mode 100644 index 0000000..38d15d5 --- /dev/null +++ b/src/mixinSupport.ts @@ -0,0 +1,296 @@ +/** + * @module mixinSupport + * + * Shared internals for the TC39 decorator system: metadata storage, + * lazy-initialization tracking, and helpers used by both the `@reactive` / + * `@computed` decorators and `applyClassMixins`. + * + * This module exists to keep `decorate.ts` and `util.ts` decoupled — without + * it, `decorate.ts` would depend on `applyClassMixins` while `util.ts` would + * depend on the decorator metadata, creating a circular import. + */ + +import { getIntegration } from '@/global'; +import { define } from '@/auto'; +import { DecoratorOptionType } from './interfaces'; + +// `Symbol.metadata` carries TC39 decorator metadata from `context.metadata` +// to `Class[Symbol.metadata]`, but most runtimes don't expose it natively. +(Symbol as unknown as { metadata: symbol }).metadata ??= Symbol.for('Symbol.metadata'); + +export const MADRONE_META = Symbol.for('@madronejs/decorators'); + +export type ReactiveMeta = { + kind: 'reactive', + key: string | symbol, + options?: DecoratorOptionType, +}; + +export type ComputedMeta = { + kind: 'computed', + key: string | symbol, + options?: DecoratorOptionType, + getter: (this: object) => unknown, + /** + * The paired non-decorated setter from the original class's prototype, if + * any. Captured and carried through the mixin chain so that downstream + * classes can wire up the reactive Computed with a working setter. + * `@computed` itself only receives the getter under TC39 decorators. + */ + setter?: (this: object, val: unknown) => void, +}; + +export type MadroneMeta = ReactiveMeta | ComputedMeta; + +const initializedMap = new WeakMap>(); + +/** + * Marks a given `key` as initialized on `target`. Returns `true` if this call + * performed the marking (i.e. the key was not already marked), and `false` + * if the key had already been initialized. Used by decorators and mixin + * helpers to avoid redefining a reactive property twice. + */ +export function markInitialized(target: object, key: string | symbol): boolean { + let set = initializedMap.get(target); + + if (!set) { + set = new Set(); + initializedMap.set(target, set); + } + + if (set.has(key)) return false; + + set.add(key); + + return true; +} + +/** Returns whether `key` has already been initialized on `target`. */ +export function isInitialized(target: object, key: string | symbol): boolean { + return initializedMap.get(target)?.has(key) ?? false; +} + +/** Records a decorator registration onto the class's metadata bag. */ +export function recordMeta(metadata: DecoratorMetadata, entry: MadroneMeta): void { + const bag = metadata as unknown as Record; + + if (!bag[MADRONE_META]) bag[MADRONE_META] = []; + + bag[MADRONE_META].push(entry); +} + +/** + * Reads the decorator metadata stored on a class by `@reactive` / `@computed`. + * Returns `undefined` if the class has no madrone decorator metadata. + */ +export function getMadroneMeta(target: object): MadroneMeta[] | undefined { + const sym = (Symbol as unknown as { metadata: symbol }).metadata; + const meta = (target as Record)[sym] as Record | undefined; + + return meta?.[MADRONE_META]; +} + +/** + * Returns the madrone decorator metadata array for `target`, creating (and + * attaching) an empty one if it doesn't exist yet. Used by `applyClassMixins` + * when `target[Symbol.metadata]` is already attached (i.e. after class + * decoration completes, or when called imperatively on a non-decorated class). + * + * During active class decoration, callers should use `ensureMadroneMetaOnBag` + * with the live `context.metadata` reference instead — otherwise mutations + * here will be overwritten when TS later assigns `context.metadata` to + * `Class[Symbol.metadata]`. + */ +export function ensureMadroneMeta(target: object): MadroneMeta[] { + const sym = (Symbol as unknown as { metadata: symbol }).metadata; + const holder = target as Record | undefined>; + + if (!holder[sym]) holder[sym] = {} as Record; + + const bag = holder[sym]; + + if (!bag[MADRONE_META]) bag[MADRONE_META] = []; + + return bag[MADRONE_META]; +} + +/** + * Like `ensureMadroneMeta` but takes a `DecoratorMetadata` bag directly — used + * by `applyClassMixins` during active class decoration, when the bag hasn't + * yet been attached to the class constructor. + */ +export function ensureMadroneMetaOnBag(metadata: DecoratorMetadata): MadroneMeta[] { + const bag = metadata as unknown as Record; + + if (!bag[MADRONE_META]) bag[MADRONE_META] = []; + + return bag[MADRONE_META]; +} + +/** Walks the prototype chain to find the first descriptor that defines `key`. */ +export function findAccessor(obj: object, key: string | symbol): PropertyDescriptor | undefined { + let current: object | null = obj; + + while (current && current !== Object.prototype && current !== Function.prototype) { + const desc = Object.getOwnPropertyDescriptor(current, key); + + if (desc) return desc; + + current = Object.getPrototypeOf(current); + } + + return undefined; +} + +export function reactiveDescriptor(value: unknown, options?: DecoratorOptionType) { + return { + value, + enumerable: true, + configurable: false, + deep: true, + ...options?.descriptors, + }; +} + +export function computedDescriptor( + getter: (this: object) => unknown, + setter: ((this: object, val: unknown) => void) | undefined, + options?: DecoratorOptionType +) { + return { + get: getter, + // Default to non-enumerable (matches native JS semantics for class + // getters — `class X { get foo() {} }` puts `foo` on the prototype as + // non-enumerable) and avoids `{ ...instance }` accidentally triggering + // every computed on the instance. Callers can override via + // `@computed.configure({ enumerable: true })`. + enumerable: false, + set: setter, + configurable: true, + cache: true, + ...options?.descriptors, + }; +} + +type MixinMarkedFn = { __madroneMixin?: true } & ((...args: unknown[]) => unknown); + +/** + * Returns true if the property descriptor was installed by `installMixinReactive` + * or `installMixinComputed` — its get/set functions are tagged with a marker + * symbol. We can't store the marker on the descriptor itself because + * `Object.defineProperty` ignores non-standard descriptor keys and they're + * lost the moment the property is defined. + */ +export function isMixinInstalled(descriptor: PropertyDescriptor | undefined): boolean { + if (!descriptor) return false; + + return Boolean( + (descriptor.get as MixinMarkedFn | undefined)?.__madroneMixin + || (descriptor.set as MixinMarkedFn | undefined)?.__madroneMixin + ); +} + +/** + * Installs a lazy-reactive accessor on `proto` for `key`. On first read or + * write, the accessor sets up real reactivity on the instance, mirroring + * what `@reactive` would do if the decorator were applied directly to the + * target class. + */ +export function installMixinReactive( + proto: object, + key: string | symbol, + options?: DecoratorOptionType +): void { + const existing = Object.getOwnPropertyDescriptor(proto, key); + + // If the target already declared its own descriptor (e.g. a re-applied + // `@reactive` on the target class itself), defer to that one. + if (existing && !isMixinInstalled(existing)) return; + + const lazyGet = function lazyReactiveGet(this: object) { + if (!getIntegration()) return undefined; + + if (markInitialized(this, key)) { + define(this, key as string, reactiveDescriptor(undefined, options)); + } + + return (this as Record)[key as string]; + } as MixinMarkedFn; + + const lazySet = function lazyReactiveSet(this: object, val: unknown) { + if (!getIntegration()) return; + + if (markInitialized(this, key)) { + define(this, key as string, reactiveDescriptor(val, options)); + } else { + (this as Record)[key as string] = val; + } + } as MixinMarkedFn; + + lazyGet.__madroneMixin = true; + lazySet.__madroneMixin = true; + + Object.defineProperty(proto, key, { + configurable: true, + enumerable: true, + get: lazyGet, + set: lazySet, + }); +} + +/** + * Wraps the computed getter on `proto[key]` so that the first access to a + * target instance installs an instance-level reactive computed. Preserves an + * un-decorated setter that pairs with the getter, if present. + */ +export function installMixinComputed( + proto: object, + key: string | symbol, + getter: (this: object) => unknown, + options?: DecoratorOptionType, + explicitSetter?: (this: object, val: unknown) => void +): void { + const existing = Object.getOwnPropertyDescriptor(proto, key); + // Prefer an explicitly-passed setter (captured from the mixin's original + // prototype). Otherwise fall back to the existing descriptor's setter, but + // only if it isn't one we already installed — inheriting another mixin's + // lazy setter would cause infinite recursion through the Computed wrapper. + const setter = explicitSetter + ?? (existing && !isMixinInstalled(existing) + ? (existing.set as ((val: unknown) => void) | undefined) + : undefined); + + const lazyGet = function lazyComputedGet(this: object) { + if (!getIntegration()) return getter.call(this); + + if (markInitialized(this, key)) { + define(this, key as string, computedDescriptor(getter, setter, options)); + } + + return (this as Record)[key as string]; + } as MixinMarkedFn; + + const lazySet = function lazyComputedSet(this: object, val: unknown) { + if (!getIntegration()) { + setter?.call(this, val); + + return; + } + + if (markInitialized(this, key)) { + define(this, key as string, computedDescriptor(getter, setter, options)); + } + + (this as Record)[key as string] = val; + } as MixinMarkedFn; + + lazyGet.__madroneMixin = true; + lazySet.__madroneMixin = true; + + Object.defineProperty(proto, key, { + configurable: true, + enumerable: true, + get: lazyGet, + set: lazySet, + }); +} diff --git a/src/util.ts b/src/util.ts index 82b75a4..7982af1 100644 --- a/src/util.ts +++ b/src/util.ts @@ -8,6 +8,9 @@ */ import type { Constructor } from '@/interfaces'; +import { + ensureMadroneMeta, ensureMadroneMetaOnBag, getMadroneMeta, installMixinComputed, installMixinReactive, isMixinInstalled, +} from '@/mixinSupport'; type AnyObject = Record; @@ -135,11 +138,87 @@ export function merge(...types: [...A]): Spread * model.getAge(); // Works! * ``` */ -export function applyClassMixins(base: Constructor, mixins: Constructor[]): void { - Object.defineProperties( - base.prototype, - Object.getOwnPropertyDescriptors(merge(...[...mixins, base].map((item) => item.prototype))) +export function applyClassMixins( + base: Constructor, + mixins: Constructor[], + baseMetadata?: DecoratorMetadata +): void { + // Build the merged descriptor map for the prototype merge, but strip any + // lazy-mixin accessors that came from a mixin's prototype. They belong to + // that mixin's own installation — re-copying them onto `base.prototype` + // would propagate accessors between unrelated classes and cause writes + // to flow through the wrong setup (e.g. a computed setter captured from + // an intermediate class, causing "No setter defined" errors on write). + const mergedDescriptors = Object.getOwnPropertyDescriptors( + merge(...[...mixins, base].map((item) => item.prototype)) ); + + for (const key of Object.keys(mergedDescriptors)) { + if (isMixinInstalled(mergedDescriptors[key])) { + delete mergedDescriptors[key]; + } + } + + Object.defineProperties(base.prototype, mergedDescriptors); + + // Replay decorator metadata from mixins. Field decorators under TC39 + // standard decorators produce no prototype artifacts of their own, so + // we install lazy accessors on the target prototype for mixed-in + // @reactive fields, and wrap mixed-in @computed getters. The installed + // entries are *also* accumulated into `base`'s metadata so that classes + // which later mix in `base` see the full transitive chain. + // + // When called from an active class decorator, `baseMetadata` is the live + // metadata reference shared with all other decorators on the class — + // `base[Symbol.metadata]` is only attached after decoration completes, so + // reading it via the class constructor during decoration sees nothing. + // + // Keys the base class re-declares with its own decorator are skipped — + // the base's own addInitializer handles reactivity, and clobbering its + // descriptor with a mixin wrapper would lose the base's paired setter + // and cause recursion on write. + const baseMeta = baseMetadata + ? ensureMadroneMetaOnBag(baseMetadata) + : ensureMadroneMeta(base); + const baseOwnKeys = new Set(baseMeta.map((e) => e.key)); + + // Collect mixin entries with later-wins semantics (matches the prototype + // merge order `[...mixins, base]`). A later mixin's entry overrides an + // earlier mixin's entry for the same key. Base's own entries then win + // over all mixin entries — `base` is always last in the merge order. + const resolved = new Map(); + + type Meta = ReturnType extends (infer U)[] | undefined ? U : never; + + for (const mixin of mixins) { + const entries = getMadroneMeta(mixin) ?? []; + + for (const entry of entries) { + resolved.set(entry.key, { entry, originProto: mixin.prototype }); + } + } + + for (const key of baseOwnKeys) resolved.delete(key); + + for (const { entry, originProto } of resolved.values()) { + if (entry.kind === 'reactive') { + installMixinReactive(base.prototype, entry.key, entry.options); + baseMeta.push(entry); + } else { + // Resolve a paired setter: prefer one already carried on the metadata + // entry (captured through an earlier mixin chain), else look at the + // originating mixin's prototype for a non-mixin setter (e.g. a + // `set $relLinks(val)` that pairs with `@computed get $relLinks()`). + const originalDesc = Object.getOwnPropertyDescriptor(originProto, entry.key); + const resolvedSetter = entry.setter + ?? (originalDesc && !isMixinInstalled(originalDesc) + ? (originalDesc.set as ((this: object, val: unknown) => void) | undefined) + : undefined); + + installMixinComputed(base.prototype, entry.key, entry.getter, entry.options, resolvedSetter); + baseMeta.push({ ...entry, setter: resolvedSetter }); + } + } } /** diff --git a/tsconfig.json b/tsconfig.json index ade205d..507c3a8 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,10 +1,10 @@ { "compilerOptions": { - "target": "es2020", + "target": "es2022", "lib": ["esNext", "dom"], "moduleResolution": "node", "esModuleInterop": true, - "experimentalDecorators": true, + "useDefineForClassFields": true, "skipLibCheck": true, "outDir": "./dist", "baseUrl": "./", From 92cad0c5ddefd0d0f7ff71b17ccddb806fc44b53 Mon Sep 17 00:00:00 2001 From: Tyler Reardon Date: Sat, 18 Apr 2026 06:51:55 -0700 Subject: [PATCH 02/12] feat: defer reactive install until an integration is registered MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- src/__spec__/deferredIntegration.spec.ts | 76 +++++++++++++ src/decorate.ts | 58 +++++++--- src/mixinSupport.ts | 135 +++++++++++++++++++++++ 3 files changed, 251 insertions(+), 18 deletions(-) create mode 100644 src/__spec__/deferredIntegration.spec.ts diff --git a/src/__spec__/deferredIntegration.spec.ts b/src/__spec__/deferredIntegration.spec.ts new file mode 100644 index 0000000..a60dfcd --- /dev/null +++ b/src/__spec__/deferredIntegration.spec.ts @@ -0,0 +1,76 @@ +/* eslint-disable max-classes-per-file */ +import { + describe, it, expect, beforeEach, afterEach, +} from 'vitest'; +import Madrone, { computed, reactive, MadroneState } from '../index'; +import { delay } from '@/test/util'; + +// These tests cover the case where a class is instantiated *before* an +// integration has been registered (e.g. `new Foo()` runs in a module that +// loads before `Madrone.use(MadroneState)`). The decorator must defer the +// real reactive install until an integration becomes available, rather +// than bailing silently and leaving the instance frozen. + +describe('deferred integration setup', () => { + beforeEach(() => { + Madrone.unuse(MadroneState); + }); + + afterEach(() => { + Madrone.use(MadroneState); + }); + + it('@reactive installs lazily once the integration is registered', async () => { + class Counter { + @reactive count = 5; + } + + // Instantiated with no integration active. + const c = new Counter(); + + // Reads/writes before integration work against the stashed value. + expect(c.count).toBe(5); + c.count = 7; + expect(c.count).toBe(7); + + // Register the integration; now reads/writes should be reactive. + Madrone.use(MadroneState); + + const changes: number[] = []; + + Madrone.watch(() => c.count, (val) => { + changes.push(val); + }); + + expect(c.count).toBe(7); + c.count = 9; + await delay(); + expect(changes).toEqual([9]); + }); + + it('@computed uses the original getter until the integration is registered', () => { + class Doubler { + @reactive base = 3; + + @computed get doubled() { + return this.base * 2; + } + } + + const d = new Doubler(); + + // Before integration: @computed falls back to calling the original + // getter directly (no caching, no reactivity tracking). + expect(d.doubled).toBe(6); + d.base = 10; + expect(d.doubled).toBe(20); + + // Register the integration; subsequent access should install the + // cached computed and start tracking dependencies. + Madrone.use(MadroneState); + + expect(d.doubled).toBe(20); + d.base = 100; + expect(d.doubled).toBe(200); + }); +}); diff --git a/src/decorate.ts b/src/decorate.ts index 0e6685b..d9b2df2 100644 --- a/src/decorate.ts +++ b/src/decorate.ts @@ -30,6 +30,7 @@ import { applyClassMixins } from '@/util'; import { define } from '@/auto'; import { computedDescriptor, + deferReactiveInstall, findAccessor, markInitialized, reactiveDescriptor, @@ -120,14 +121,21 @@ function createReactiveDecorator(options?: DecoratorOptionType): ReactiveFieldDe if (!markInitialized(instance, key)) return; - if (!getIntegration()) return; - // Under useDefineForClassFields, field initialization has already - // assigned the value as a plain data property. Capture it, then - // redefine the property as a reactive accessor. + // assigned the value as a plain data property. Capture it. const initialValue = (instance as Record)[key as string]; - define(instance, key as string, reactiveDescriptor(initialValue, options)); + if (getIntegration()) { + // Integration active — install the reactive accessor on the instance. + define(instance, key as string, reactiveDescriptor(initialValue, options)); + } else { + // No integration yet (e.g. `Madrone.use(...)` hasn't run). Defer: + // stash the initial value, drop the instance-own data prop, and + // install a prototype-level lazy accessor that retries on first + // read/write. Once an integration is registered, access triggers + // the real reactive install. + deferReactiveInstall(instance, key, initialValue, options); + } }); }; } @@ -168,7 +176,7 @@ export const reactive: ReactiveDecorator = Object.assign( export type ComputedGetterDecorator = ( getter: (this: This) => Value, context: ClassGetterDecoratorContext -) => void; +) => ((this: This) => Value) | void; export interface ComputedDecorator extends ComputedGetterDecorator { /** @@ -188,7 +196,10 @@ export interface ComputedDecorator extends ComputedGetterDecorator { } function createComputedDecorator(options?: DecoratorOptionType): ComputedGetterDecorator { - return function computedDecorator(getter, context): void { + return function computedDecorator( + getter: (this: This) => Value, + context: ClassGetterDecoratorContext + ): (this: This) => Value { const key = context.name; const typedGetter = getter as unknown as (this: object) => unknown; @@ -196,21 +207,32 @@ function createComputedDecorator(options?: DecoratorOptionType): ComputedGetterD kind: 'computed', key, options, getter: typedGetter, }); - context.addInitializer(function addComputedInitializer() { - const instance = this as object; + // Replace the prototype's getter with a lazy wrapper. Integration setup + // is deferred until the first access — if no integration is registered + // yet (e.g. a class is instantiated before `Madrone.use(...)` runs), the + // wrapper falls back to the original getter with no caching. The first + // access *after* an integration is registered installs a real cached + // reactive computed on the instance (via `define`), and subsequent + // accesses hit the instance accessor directly instead of this wrapper. + return function lazyComputedGetter(this: This): Value { + const instance = this as unknown as object; - if (!markInitialized(instance, key)) return; + if (!getIntegration()) { + return getter.call(this); + } - if (!getIntegration()) return; + if (markInitialized(instance, key)) { + // TC39 getter decorators only receive the getter; if the class + // paired it with an un-decorated setter, pull it off the descriptor + // so writes flow through the reactive Computed wrapper. + const existing = findAccessor(instance, key); + const setter = existing?.set as ((val: unknown) => void) | undefined; - // TC39 getter decorators only receive the getter; if the class paired - // it with an un-decorated setter, pull the setter off the descriptor - // so writes flow through the reactive Computed wrapper. - const existing = findAccessor(instance, key); - const setter = existing?.set as ((val: unknown) => void) | undefined; + define(instance, key as string, computedDescriptor(typedGetter, setter, options)); + } - define(instance, key as string, computedDescriptor(typedGetter, setter, options)); - }); + return (instance as Record)[key as string] as Value; + }; }; } diff --git a/src/mixinSupport.ts b/src/mixinSupport.ts index 38d15d5..b40b963 100644 --- a/src/mixinSupport.ts +++ b/src/mixinSupport.ts @@ -174,6 +174,141 @@ export function computedDescriptor( type MixinMarkedFn = { __madroneMixin?: true } & ((...args: unknown[]) => unknown); +// //////////////////////////// +// DEFERRED (LAZY) SETUP SUPPORT +// //////////////////////////// +// +// When a decorated class is instantiated before an integration has been +// registered (e.g. `new Foo()` runs in a module that loads before +// `Madrone.use(...)`), we can't build the reactive atom / cached computed +// yet. Instead of bailing silently — which would leave the instance with +// no retry path — we install a prototype-level lazy accessor that +// reattempts the install on first read/write once an integration becomes +// available. + +const protoLazyInstalled = new WeakMap>(); + +/** + * Returns true (and records) if we should install a lazy accessor for + * `(proto, key)`. Returns false if one has already been installed. Per- + * prototype tracking keeps the accessor from being reinstalled for every + * instance constructed before an integration is available. + */ +function claimProtoLazySlot(proto: object, key: string | symbol): boolean { + let keys = protoLazyInstalled.get(proto); + + if (!keys) { + keys = new Set(); + protoLazyInstalled.set(proto, keys); + } + + if (keys.has(key)) return false; + + keys.add(key); + + return true; +} + +const PENDING_VALUES = Symbol.for('@madronejs/pendingValues'); + +type PendingMap = Map; + +function pendingMap(instance: object): PendingMap { + let map = (instance as Record)[PENDING_VALUES]; + + if (!map) { + map = new Map(); + Object.defineProperty(instance, PENDING_VALUES, { + value: map, writable: false, enumerable: false, configurable: true, + }); + } + + return map; +} + +function stashPending(instance: object, key: string | symbol, value: unknown): void { + pendingMap(instance).set(key, value); +} + +function takePending(instance: object, key: string | symbol): unknown { + const map = (instance as Record)[PENDING_VALUES]; + + if (!map) return undefined; + + const val = map.get(key); + + map.delete(key); + + return val; +} + +function peekPending(instance: object, key: string | symbol): unknown { + return (instance as Record)[PENDING_VALUES]?.get(key); +} + +/** + * Installs a lazy reactive accessor on `proto[key]` — used by `@reactive`'s + * `addInitializer` when no integration is available at construction time. + * + * Call flow from the field decorator: + * 1. Field init creates `instance.foo = value` as a plain data property. + * 2. `addInitializer` runs, sees no integration, stashes the value in the + * instance's `PENDING_VALUES` map, deletes the own property, and calls + * this function (guarded by `claimProtoLazySlot`) to install the accessor. + * 3. Later, after `Madrone.use(...)`, the first read/write on an instance + * finds no own property and reaches the prototype accessor. It then + * calls `define()` to replace itself with a real reactive accessor on + * the instance, using the stashed initial value. + */ +export function installDeferredReactive( + proto: object, + key: string | symbol, + options?: DecoratorOptionType +): void { + if (!claimProtoLazySlot(proto, key)) return; + + Object.defineProperty(proto, key, { + configurable: true, + enumerable: true, + get(this: object) { + if (!getIntegration()) return peekPending(this, key); + + const value = takePending(this, key); + + define(this, key as string, reactiveDescriptor(value, options)); + + return (this as Record)[key as string]; + }, + set(this: object, val: unknown) { + if (!getIntegration()) { + stashPending(this, key, val); + + return; + } + + takePending(this, key); + define(this, key as string, reactiveDescriptor(val, options)); + }, + }); +} + +/** + * Called from `@reactive`'s `addInitializer` when no integration is + * available — stashes the initial value, drops the own data property so + * the prototype's deferred accessor takes over, and installs that accessor + * (once per prototype). + */ +export function deferReactiveInstall( + instance: object, + key: string | symbol, + initialValue: unknown, + options?: DecoratorOptionType +): void { + stashPending(instance, key, initialValue); + delete (instance as Record)[key as string]; + installDeferredReactive(Object.getPrototypeOf(instance), key, options); +} + /** * Returns true if the property descriptor was installed by `installMixinReactive` * or `installMixinComputed` — its get/set functions are tagged with a marker From 709cb9304e44a3e2a1f47d30711f0b6da19da8ac Mon Sep 17 00:00:00 2001 From: Tyler Reardon Date: Sat, 18 Apr 2026 09:30:56 -0700 Subject: [PATCH 03/12] test: exercise deferred install through the Vue integration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- src/__spec__/deferredIntegration.spec.ts | 139 ++++++++++++++++++----- 1 file changed, 112 insertions(+), 27 deletions(-) diff --git a/src/__spec__/deferredIntegration.spec.ts b/src/__spec__/deferredIntegration.spec.ts index a60dfcd..a4955c1 100644 --- a/src/__spec__/deferredIntegration.spec.ts +++ b/src/__spec__/deferredIntegration.spec.ts @@ -2,53 +2,81 @@ import { describe, it, expect, beforeEach, afterEach, } from 'vitest'; -import Madrone, { computed, reactive, MadroneState } from '../index'; -import { delay } from '@/test/util'; +import * as Vue from 'vue3'; +import { nextTick, watchEffect } from 'vue3'; +import Madrone, { + computed, reactive, MadroneState, MadroneVue3, +} from '../index'; // These tests cover the case where a class is instantiated *before* an // integration has been registered (e.g. `new Foo()` runs in a module that -// loads before `Madrone.use(MadroneState)`). The decorator must defer the -// real reactive install until an integration becomes available, rather -// than bailing silently and leaving the instance frozen. - -describe('deferred integration setup', () => { +// loads before `Madrone.use(...)`). The decorator must defer the real +// reactive install until an integration becomes available, rather than +// bailing silently and leaving the instance frozen. +// +// We exercise this with the Vue integration (not the MadroneState +// integration that madrone's `index.ts` registers by default) to also +// prove the plugin bridge is wired up: once `Madrone.use(MadroneVue)` +// runs, a Vue `watchEffect` that reads a decorated field re-runs when +// that field changes. That only works if (a) the deferred install +// landed, and (b) the active integration is genuinely the Vue one. + +const MadroneVue = MadroneVue3(Vue); + +describe('deferred integration setup (via Vue integration)', () => { beforeEach(() => { + // Ensure no integration is active at class instantiation time. Madrone.unuse(MadroneState); }); afterEach(() => { + Madrone.unuse(MadroneVue); + // Restore the default so the rest of the suite sees MadroneState. Madrone.use(MadroneState); }); - it('@reactive installs lazily once the integration is registered', async () => { + it('defers @reactive install until Vue integration is registered', async () => { class Counter { - @reactive count = 5; + @reactive count = 0; } // Instantiated with no integration active. const c = new Counter(); // Reads/writes before integration work against the stashed value. - expect(c.count).toBe(5); + expect(c.count).toBe(0); c.count = 7; expect(c.count).toBe(7); - // Register the integration; now reads/writes should be reactive. - Madrone.use(MadroneState); - - const changes: number[] = []; + // Register the Vue integration. From here on, reactive changes must + // propagate through Vue's effect system. + Madrone.use(MadroneVue); - Madrone.watch(() => c.count, (val) => { - changes.push(val); + const seen: number[] = []; + const stop = watchEffect(() => { + seen.push(c.count); }); - expect(c.count).toBe(7); + await nextTick(); + + // First run captures the current value (7 from the stash). + expect(seen).toEqual([7]); + + // Mutating the reactive field should re-run the Vue effect — only + // possible if the deferred install wired the field up to Vue's + // reactivity via `MadroneVue`. c.count = 9; - await delay(); - expect(changes).toEqual([9]); + await nextTick(); + expect(seen).toEqual([7, 9]); + + c.count = 12; + await nextTick(); + expect(seen).toEqual([7, 9, 12]); + + stop(); }); - it('@computed uses the original getter until the integration is registered', () => { + it('defers @computed install until Vue integration is registered', async () => { class Doubler { @reactive base = 3; @@ -59,18 +87,75 @@ describe('deferred integration setup', () => { const d = new Doubler(); - // Before integration: @computed falls back to calling the original - // getter directly (no caching, no reactivity tracking). + // Before integration: the @computed wrapper falls back to calling + // the original getter directly (no caching, no reactivity). expect(d.doubled).toBe(6); d.base = 10; expect(d.doubled).toBe(20); - // Register the integration; subsequent access should install the - // cached computed and start tracking dependencies. - Madrone.use(MadroneState); + // Install the Vue integration. First access after this installs the + // cached reactive computed on the instance and starts tracking. + Madrone.use(MadroneVue); - expect(d.doubled).toBe(20); + const seen: number[] = []; + const stop = watchEffect(() => { + seen.push(d.doubled); + }); + + await nextTick(); + + // First run sees 20 (current base=10). + expect(seen).toEqual([20]); + + // Changing the dependency re-runs the Vue effect. d.base = 100; - expect(d.doubled).toBe(200); + await nextTick(); + expect(seen).toEqual([20, 200]); + + d.base = 50; + await nextTick(); + expect(seen).toEqual([20, 200, 100]); + + stop(); + }); + + it('bridges @reactive and @computed together through the Vue integration', async () => { + class User { + @reactive first = 'Ada'; + @reactive last = 'Lovelace'; + + @computed get fullName() { + return `${this.first} ${this.last}`; + } + } + + const u = new User(); + + // Pre-integration stash reads. + expect(u.fullName).toBe('Ada Lovelace'); + u.first = 'Grace'; + expect(u.fullName).toBe('Grace Lovelace'); + + Madrone.use(MadroneVue); + + const seen: string[] = []; + const stop = watchEffect(() => { + seen.push(u.fullName); + }); + + await nextTick(); + expect(seen).toEqual(['Grace Lovelace']); + + // Each reactive write should trigger the computed to invalidate, which + // in turn re-triggers the Vue effect. + u.first = 'Katherine'; + await nextTick(); + expect(seen).toEqual(['Grace Lovelace', 'Katherine Lovelace']); + + u.last = 'Johnson'; + await nextTick(); + expect(seen).toEqual(['Grace Lovelace', 'Katherine Lovelace', 'Katherine Johnson']); + + stop(); }); }); From 029aaa16f30b1f2ee7323cf505157ce678c08bce Mon Sep 17 00:00:00 2001 From: Tyler Reardon Date: Sat, 18 Apr 2026 11:57:15 -0700 Subject: [PATCH 04/12] feat: forward classMixin metadata and add reactive init factory - 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) --- src/__spec__/classMixin.spec.ts | 258 ++++++++++++++++++++++++++++++++ src/decorate.ts | 40 ++++- src/interfaces.ts | 19 +++ src/mixinSupport.ts | 8 +- 4 files changed, 316 insertions(+), 9 deletions(-) create mode 100644 src/__spec__/classMixin.spec.ts diff --git a/src/__spec__/classMixin.spec.ts b/src/__spec__/classMixin.spec.ts new file mode 100644 index 0000000..7c3475f --- /dev/null +++ b/src/__spec__/classMixin.spec.ts @@ -0,0 +1,258 @@ +/* eslint-disable max-classes-per-file, @typescript-eslint/no-unsafe-declaration-merging */ +import { describe, it, expect } from 'vitest'; +import { + reactive, computed, classMixin, watch, +} from '../index'; + +// `@classMixin` copies own prototype descriptors (methods, getters, setters) +// from each mixin onto the target's prototype and replays `@reactive` / +// `@computed` decorator metadata so mixed-in reactivity works on target +// instances. One limitation under TC39 standard decorators: a mixed-in +// `@reactive` field cannot carry its field initializer expression (`= 0`) +// across the mixin boundary — use `@reactive.configure({ init: () => 0 })` +// to supply a default instead. + +describe('classMixin', () => { + describe('methods', () => { + it('copies plain prototype methods from a mixin onto the target', () => { + class Greeter { + greet(name: string) { + return `hello ${name}`; + } + } + + @classMixin(Greeter) + class Target {} + + interface Target extends Greeter {} + + expect(new Target().greet('world')).toEqual('hello world'); + }); + + it('target methods win over mixin methods for the same key', () => { + class Base { + label() { return 'base'; } + } + + @classMixin(Base) + class Target { + label() { return 'target'; } + } + + interface Target extends Base {} + + expect(new Target().label()).toEqual('target'); + }); + + it('applies multiple mixins; later mixins win over earlier ones', () => { + class First { + who() { return 'first'; } + only_first() { return 1; } + } + + class Second { + who() { return 'second'; } + } + + @classMixin(First, Second) + class Target {} + + interface Target extends First, Second {} + + const t = new Target(); + + expect(t.who()).toEqual('second'); + expect(t.only_first()).toEqual(1); + }); + }); + + describe('@reactive fields from a mixin', () => { + it('becomes reactive on the target; starts undefined without an init factory', async () => { + class NamedMixin { + @reactive fName: string; + } + + @classMixin(NamedMixin) + class Target {} + + interface Target extends NamedMixin {} + + const t = new Target(); + + // No field-initializer expression is carried over — initial value is undefined. + expect(t.fName).toBeUndefined(); + + const seen: Array = []; + const stop = watch(() => t.fName, (val) => { + seen.push(val); + }); + + t.fName = 'Ada'; + await Promise.resolve(); + t.fName = 'Grace'; + await Promise.resolve(); + + expect(seen).toEqual(['Ada', 'Grace']); + stop(); + }); + + it('uses `reactive.configure({ init })` to supply an initial value across the mixin boundary', async () => { + class Counter { + @reactive.configure({ init: () => 7 }) count: number; + } + + @classMixin(Counter) + class Target {} + + interface Target extends Counter {} + + const t = new Target(); + + expect(t.count).toEqual(7); + + // Also produces an independent initial value per instance. + const t2 = new Target(); + + expect(t2.count).toEqual(7); + t2.count = 99; + expect(t.count).toEqual(7); + expect(t2.count).toEqual(99); + }); + + it('init factory runs per instance (fresh arrays/objects are not shared)', () => { + class Listy { + @reactive.configure({ init: () => [] as number[] }) items: number[]; + } + + @classMixin(Listy) + class Target {} + + interface Target extends Listy {} + + const a = new Target(); + const b = new Target(); + + a.items.push(1); + + expect(a.items).toEqual([1]); + expect(b.items).toEqual([]); + }); + }); + + describe('@computed getters from a mixin', () => { + it('mixes in a @computed getter that stays cached and reactive', async () => { + class Doubler { + @computed get doubled() { + return (this as unknown as { base: number }).base * 2; + } + } + + @classMixin(Doubler) + class Target { + @reactive base = 2; + } + + interface Target extends Doubler {} + + const t = new Target(); + + expect(t.doubled).toEqual(4); + + const seen: number[] = []; + const stop = watch(() => t.doubled, (val) => { + seen.push(val); + }); + + t.base = 5; + await Promise.resolve(); + t.base = 7; + await Promise.resolve(); + + expect(seen).toEqual([10, 14]); + stop(); + }); + + it('preserves a setter paired with a @computed getter across the mixin boundary', () => { + type TargetShape = { _val: number }; + + class WithPair { + @computed get val(): number { + return (this as unknown as TargetShape)._val; + } + + set val(v: number) { + (this as unknown as TargetShape)._val = v * 10; + } + } + + @classMixin(WithPair) + class Target { + @reactive _val = 1; + } + + interface Target extends WithPair {} + + const t = new Target(); + + expect(t.val).toEqual(1); + + t.val = 3; + + expect(t._val).toEqual(30); + expect(t.val).toEqual(30); + }); + + it('target @computed overrides mixin @computed for the same key', () => { + class MixA { + @computed get who() { return 'mix'; } + } + + @classMixin(MixA) + class Target { + @computed get who() { return 'target'; } + } + + interface Target extends MixA {} + + expect(new Target().who).toEqual('target'); + }); + }); + + describe('combined @reactive + @computed through a mixin', () => { + it('reacts through the full chain when reactive deps are on the mixin with init factories', async () => { + class NamedMixin { + @reactive.configure({ init: () => 'Anon' }) fName: string; + @reactive.configure({ init: () => 'User' }) lName: string; + + @computed get fullName() { + return `${this.fName} ${this.lName}`; + } + } + + @classMixin(NamedMixin) + class Person { + @reactive age: number; + } + + interface Person extends NamedMixin {} + + const p = new Person(); + + expect(p.fullName).toEqual('Anon User'); + + const seen: string[] = []; + const stop = watch(() => p.fullName, (val) => { + seen.push(val); + }); + + p.fName = 'Ada'; + await Promise.resolve(); + p.lName = 'Lovelace'; + await Promise.resolve(); + + expect(p.fullName).toEqual('Ada Lovelace'); + expect(seen).toEqual(['Ada User', 'Ada Lovelace']); + stop(); + }); + }); +}); diff --git a/src/decorate.ts b/src/decorate.ts index d9b2df2..00a0361 100644 --- a/src/decorate.ts +++ b/src/decorate.ts @@ -36,7 +36,9 @@ import { reactiveDescriptor, recordMeta, } from '@/mixinSupport'; -import { DecoratorOptionType, DecoratorDescriptorType, Constructor } from './interfaces'; +import { + DecoratorOptionType, DecoratorDescriptorType, ReactiveDecoratorConfig, Constructor, +} from './interfaces'; // //////////////////////////// // CLASS MIXIN @@ -62,9 +64,17 @@ import { DecoratorOptionType, DecoratorDescriptorType, Constructor } from './int * ``` */ export function classMixin(...mixins: Constructor[]) { - return function classMixinDecorator(target: T): void { + return function classMixinDecorator( + target: T, + context: ClassDecoratorContext + ): void { if (mixins?.length) { - applyClassMixins(target, mixins); + // Forward `context.metadata` — during class decoration, `target[Symbol.metadata]` + // isn't attached yet; the live `context.metadata` reference is where other + // decorators on this class wrote their entries, and where TS will attach + // the bag when decoration completes. Without forwarding it, `applyClassMixins` + // would allocate a fresh empty bag that TS later clobbers. + applyClassMixins(target, mixins, context.metadata); } }; } @@ -97,7 +107,10 @@ export interface ReactiveDecorator extends ReactiveFieldDecorator { /** * Creates a configured reactive decorator with custom descriptor options. * - * @param descriptorOverrides - Options to customize the reactive behavior + * @param config - Descriptor overrides plus optional `init` factory. `init` + * supplies a default when the field's own initializer doesn't — the primary + * use case is mixed-in `@reactive` fields, which cannot carry their field + * initializer expression across the mixin boundary. * * @example * ```ts @@ -105,9 +118,14 @@ export interface ReactiveDecorator extends ReactiveFieldDecorator { * @reactive.configure({ deep: false, enumerable: false }) * hiddenData = { secret: true }; * } + * + * class CounterMixin { + * // Mixed-in fields lose their `= 0`; use `init` to supply one. + * @reactive.configure({ init: () => 0 }) count: number; + * } * ``` */ - configure: (descriptorOverrides: DecoratorDescriptorType) => ReactiveFieldDecorator, + configure: (config: ReactiveDecoratorConfig) => ReactiveFieldDecorator, } function createReactiveDecorator(options?: DecoratorOptionType): ReactiveFieldDecorator { @@ -122,8 +140,14 @@ function createReactiveDecorator(options?: DecoratorOptionType): ReactiveFieldDe if (!markInitialized(instance, key)) return; // Under useDefineForClassFields, field initialization has already - // assigned the value as a plain data property. Capture it. - const initialValue = (instance as Record)[key as string]; + // assigned the value as a plain data property. Capture it. If the + // field had no initializer (undefined) and the user supplied an + // `init` factory via `@reactive.configure`, use the factory instead. + const record = instance as Record; + const rawValue = record[key as string]; + const initialValue = rawValue === undefined && options?.init + ? options.init() + : rawValue; if (getIntegration()) { // Integration active — install the reactive accessor on the instance. @@ -165,7 +189,7 @@ export const reactive: ReactiveDecorator = Object.assign( createReactiveDecorator(), { shallow: createReactiveDecorator({ descriptors: { deep: false } }), - configure: (descriptorOverrides: DecoratorDescriptorType) => createReactiveDecorator({ descriptors: descriptorOverrides }), + configure: ({ init, ...descriptors }: ReactiveDecoratorConfig) => createReactiveDecorator({ descriptors, init }), } ); diff --git a/src/interfaces.ts b/src/interfaces.ts index 76bd4cc..17e2aa6 100644 --- a/src/interfaces.ts +++ b/src/interfaces.ts @@ -74,12 +74,31 @@ export interface MadroneDescriptorMap { */ export type DecoratorDescriptorType = Omit; +/** + * User-facing config accepted by `@reactive.configure({...})`. Combines + * descriptor overrides with reactive-specific knobs like `init`. + */ +export type ReactiveDecoratorConfig = DecoratorDescriptorType & { + /** + * Factory returning the initial value for the reactive field. Invoked per + * instance, at install time. Useful for supplying a default across the + * mixin boundary — mixed-in `@reactive` fields cannot carry their field + * initializer expression, so this is the explicit way to provide one. + * + * On the declaring class, the field initializer (`= 0`) still wins when + * present; `init` only fires if the field value is `undefined`. + */ + init?: () => unknown, +}; + /** * Configuration options for decorator functions. */ export type DecoratorOptionType = { /** Property descriptor overrides */ descriptors?: DecoratorDescriptorType, + /** Factory returning the initial value for a reactive field */ + init?: () => unknown, }; /** diff --git a/src/mixinSupport.ts b/src/mixinSupport.ts index b40b963..9c7f888 100644 --- a/src/mixinSupport.ts +++ b/src/mixinSupport.ts @@ -342,11 +342,17 @@ export function installMixinReactive( // `@reactive` on the target class itself), defer to that one. if (existing && !isMixinInstalled(existing)) return; + // Mixed-in `@reactive` fields don't carry a field initializer expression + // across the mixin boundary (TC39 field decorators only run for instances + // of the declaring class). If the user supplied `init` via + // `@reactive.configure`, invoke it per-instance to produce the default. const lazyGet = function lazyReactiveGet(this: object) { if (!getIntegration()) return undefined; if (markInitialized(this, key)) { - define(this, key as string, reactiveDescriptor(undefined, options)); + const initialValue = options?.init ? options.init() : undefined; + + define(this, key as string, reactiveDescriptor(initialValue, options)); } return (this as Record)[key as string]; From 62991266dd348be9fab0692c2a72357a0b3b5730 Mon Sep 17 00:00:00 2001 From: Tyler Reardon Date: Sat, 18 Apr 2026 13:28:57 -0700 Subject: [PATCH 05/12] fix: inheritance metadata corruption; defer @classMixin application 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) --- src/__spec__/inheritance.spec.ts | 172 ++++++++++++++++++++++++++ src/__spec__/staticDecorators.spec.ts | 154 +++++++++++++++++++++++ src/decorate.ts | 19 +-- src/mixinSupport.ts | 58 +++++---- src/util.ts | 64 ++++++---- 5 files changed, 414 insertions(+), 53 deletions(-) create mode 100644 src/__spec__/inheritance.spec.ts create mode 100644 src/__spec__/staticDecorators.spec.ts diff --git a/src/__spec__/inheritance.spec.ts b/src/__spec__/inheritance.spec.ts new file mode 100644 index 0000000..d7336ac --- /dev/null +++ b/src/__spec__/inheritance.spec.ts @@ -0,0 +1,172 @@ +/* eslint-disable max-classes-per-file, @typescript-eslint/no-unsafe-declaration-merging */ +import { describe, it, expect } from 'vitest'; +import { + reactive, computed, classMixin, watch, +} from '../index'; +import { getMadroneMeta } from '../mixinSupport'; + +// TC39 standard decorator metadata is attached per class via +// `Object.create(parent[Symbol.metadata])`, so property access walks the +// prototype chain. Without own-property discipline, a subclass's decorator +// writes would mutate the parent's metadata array. These tests lock that +// behavior down: subclasses see ancestor entries via proto-chain but never +// mutate them, and `@classMixin(Child)` correctly picks up Parent's fields. + +describe('decorator metadata under native `extends`', () => { + it('does not mutate parent metadata when a subclass declares its own @reactive', () => { + class Parent { + @reactive a = 1; + } + + class Child extends Parent { + @reactive b = 2; + } + + const parentKeys = (getMadroneMeta(Parent) ?? []).map((e) => e.key); + const childKeys = (getMadroneMeta(Child) ?? []).map((e) => e.key).toSorted(); + + expect(parentKeys).toEqual(['a']); + expect(childKeys).toEqual(['a', 'b']); + }); + + it('keeps parent and child instances independently reactive', async () => { + class Parent { + @reactive p = 0; + } + + class Child extends Parent { + @reactive c = 0; + } + + const parent = new Parent(); + const child = new Child(); + + const parentSeen: number[] = []; + const childSeenP: number[] = []; + const childSeenC: number[] = []; + + const stopParent = watch(() => parent.p, (v) => { + parentSeen.push(v); + }); + const stopChildP = watch(() => child.p, (v) => { + childSeenP.push(v); + }); + const stopChildC = watch(() => child.c, (v) => { + childSeenC.push(v); + }); + + parent.p = 1; + child.p = 10; + child.c = 100; + await Promise.resolve(); + + expect(parent.p).toBe(1); + expect(child.p).toBe(10); + expect(child.c).toBe(100); + expect(parentSeen).toEqual([1]); + expect(childSeenP).toEqual([10]); + expect(childSeenC).toEqual([100]); + + stopParent(); + stopChildP(); + stopChildC(); + }); + + it('classMixin(Child) installs both Parent and Child @reactive fields on the target', async () => { + class Parent { + @reactive pa: string; + } + + class Child extends Parent { + @reactive ca: string; + } + + @classMixin(Child) + class Target {} + + interface Target extends Child {} + + const t = new Target(); + + // Both keys should be reactive on target instances. + t.pa = 'parent!'; + t.ca = 'child!'; + + expect(t.pa).toBe('parent!'); + expect(t.ca).toBe('child!'); + + const seenPa: string[] = []; + const seenCa: string[] = []; + const stopPa = watch(() => t.pa, (v) => { + seenPa.push(v); + }); + const stopCa = watch(() => t.ca, (v) => { + seenCa.push(v); + }); + + t.pa = 'p2'; + await Promise.resolve(); + t.ca = 'c2'; + await Promise.resolve(); + + expect(seenPa).toEqual(['p2']); + expect(seenCa).toEqual(['c2']); + + stopPa(); + stopCa(); + }); + + it('classMixin(Parent) does not leak subclass fields onto the target', () => { + class Parent { + @reactive only: string; + } + + class Child extends Parent { + @reactive leaked: string; + } + + // Use Child to force its decorator entries to land somewhere — if they + // had leaked onto Parent, the test below would pick up `leaked` too. + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const _child = new Child(); + + @classMixin(Parent) + class Target {} + + interface Target extends Parent {} + + const parentKeys = (getMadroneMeta(Parent) ?? []).map((e) => e.key); + + expect(parentKeys).toEqual(['only']); + + // `leaked` must not be a reactive field on Target — writing to an + // un-decorated key should create a plain own data property (no reactive + // machinery), which is what the undecorated base class behavior is. + const t = new Target(); + + (t as unknown as { leaked: string }).leaked = 'set'; + expect((t as unknown as { leaked: string }).leaked).toBe('set'); + }); + + it('@computed metadata on a subclass does not contaminate the parent', () => { + class Parent { + @reactive val = 1; + + @computed get doubled() { + return this.val * 2; + } + } + + class Child extends Parent { + @computed get tripled() { + return this.val * 3; + } + } + + const parentKeys = (getMadroneMeta(Parent) ?? []).map((e) => e.key).toSorted(); + const childKeys = (getMadroneMeta(Child) ?? []).map((e) => e.key).toSorted(); + + expect(parentKeys).toEqual(['doubled', 'val']); + expect(childKeys).toEqual(['doubled', 'tripled', 'val']); + }); +}); diff --git a/src/__spec__/staticDecorators.spec.ts b/src/__spec__/staticDecorators.spec.ts new file mode 100644 index 0000000..5ab3b26 --- /dev/null +++ b/src/__spec__/staticDecorators.spec.ts @@ -0,0 +1,154 @@ +/* eslint-disable max-classes-per-file */ +import { describe, it, expect } from 'vitest'; +import { reactive, computed, watch } from '../index'; + +// Static `@reactive` / `@computed` support. Under TC39 standard decorators, +// a static field's `addInitializer` callback runs with `this` bound to the +// class constructor, so the same install path works — reactivity lands on +// the class itself. + +describe('static @reactive', () => { + it('makes a static field reactive', async () => { + class Counter { + @reactive static count = 0; + } + + expect(Counter.count).toBe(0); + + const seen: number[] = []; + const stop = watch(() => Counter.count, (v) => { + seen.push(v); + }); + + Counter.count = 5; + await Promise.resolve(); + Counter.count = 9; + await Promise.resolve(); + + expect(seen).toEqual([5, 9]); + stop(); + }); + + it('honors .configure overrides on statics', () => { + class Hidden { + @reactive.configure({ enumerable: false }) static secret = 42; + } + + expect(Hidden.secret).toBe(42); + expect(Object.keys(Hidden)).not.toContain('secret'); + }); + + it('.shallow works on statics', () => { + class Wrapper { + @reactive.shallow static data = { nested: { n: 1 } }; + } + + let calls = 0; + const stop = watch(() => Wrapper.data, () => { + calls += 1; + }); + + // Deep mutation shouldn't fire — shallow only tracks reassignment. + Wrapper.data.nested.n = 2; + expect(calls).toBe(0); + + // Replacement does fire. + Wrapper.data = { nested: { n: 3 } }; + + return Promise.resolve().then(() => { + expect(calls).toBe(1); + stop(); + }); + }); +}); + +describe('static @computed', () => { + it('caches static computed getters backed by static @reactive', () => { + let calls = 0; + + class Cached { + @reactive static source = 1; + + @computed static get derived() { + calls += 1; + + return Cached.source * 10; + } + } + + expect(Cached.derived).toBe(10); + expect(Cached.derived).toBe(10); + expect(Cached.derived).toBe(10); + expect(calls).toBe(1); + + Cached.source = 2; + expect(Cached.derived).toBe(20); + expect(calls).toBe(2); + }); + + it('watch() reacts to static @computed changes', async () => { + class Tracked { + @reactive static a = 1; + @reactive static b = 2; + + @computed static get sum() { + return Tracked.a + Tracked.b; + } + } + + const seen: number[] = []; + const stop = watch(() => Tracked.sum, (v) => { + seen.push(v); + }); + + Tracked.a = 10; + await Promise.resolve(); + Tracked.b = 20; + await Promise.resolve(); + + expect(seen).toEqual([12, 30]); + stop(); + }); + + it('static computed with paired setter', () => { + class Paired { + @reactive static _val: string = 'init'; + + @computed static get val() { + return Paired._val; + } + + static set val(v: string) { + Paired._val = `[${v}]`; + } + } + + expect(Paired.val).toBe('init'); + + Paired.val = 'x'; + + expect(Paired._val).toBe('[x]'); + expect(Paired.val).toBe('[x]'); + }); +}); + +describe('static decorator inheritance', () => { + it('subclass static @reactive does not mutate parent static metadata', () => { + class Parent { + @reactive static a = 1; + } + + class Child extends Parent { + @reactive static b = 2; + } + + expect(Parent.a).toBe(1); + expect((Child as unknown as { b: number }).b).toBe(2); + + Parent.a = 10; + + // Each class has its own static storage; Parent.a changes don't affect + // Child's own static (Child.a is inherited via proto chain from Parent). + expect(Parent.a).toBe(10); + }); +}); diff --git a/src/decorate.ts b/src/decorate.ts index 00a0361..0643304 100644 --- a/src/decorate.ts +++ b/src/decorate.ts @@ -68,14 +68,17 @@ export function classMixin(...mixins: Constructor[]) { target: T, context: ClassDecoratorContext ): void { - if (mixins?.length) { - // Forward `context.metadata` — during class decoration, `target[Symbol.metadata]` - // isn't attached yet; the live `context.metadata` reference is where other - // decorators on this class wrote their entries, and where TS will attach - // the bag when decoration completes. Without forwarding it, `applyClassMixins` - // would allocate a fresh empty bag that TS later clobbers. - applyClassMixins(target, mixins, context.metadata); - } + if (!mixins?.length) return; + + // Defer mixin application until after the class body has been fully + // decorated. A class decorator's `addInitializer` callback runs after + // TS attaches `target[Symbol.metadata]`, so by the time we call + // `applyClassMixins`, base's own @reactive / @computed entries are + // readable via the standard metadata path — no need to thread + // `context.metadata` through as a separate parameter. + context.addInitializer(function mixinInitializer() { + applyClassMixins(this as unknown as Constructor, mixins); + }); }; } diff --git a/src/mixinSupport.ts b/src/mixinSupport.ts index 9c7f888..6f4aa44 100644 --- a/src/mixinSupport.ts +++ b/src/mixinSupport.ts @@ -70,13 +70,27 @@ export function isInitialized(target: object, key: string | symbol): boolean { return initializedMap.get(target)?.has(key) ?? false; } -/** Records a decorator registration onto the class's metadata bag. */ -export function recordMeta(metadata: DecoratorMetadata, entry: MadroneMeta): void { - const bag = metadata as unknown as Record; +/** + * Returns `bag`'s own `MADRONE_META` array, creating it if necessary. + * + * Under TC39 decorators, a subclass's metadata bag is created via + * `Object.create(parent[Symbol.metadata])`, so bare property reads like + * `bag[MADRONE_META]` walk the prototype chain and find the parent's array. + * Pushing onto that would mutate the parent, so we seed a fresh own array + * with a shallow copy of any inherited entries instead — the subclass sees + * the full ancestor chain in its own bag, and parent arrays stay intact. + */ +function ownMadroneMetaArray(bag: Record): MadroneMeta[] { + if (!Object.prototype.hasOwnProperty.call(bag, MADRONE_META)) { + bag[MADRONE_META] = [...(bag[MADRONE_META] ?? [])]; + } - if (!bag[MADRONE_META]) bag[MADRONE_META] = []; + return bag[MADRONE_META]; +} - bag[MADRONE_META].push(entry); +/** Records a decorator registration onto the class's metadata bag. */ +export function recordMeta(metadata: DecoratorMetadata, entry: MadroneMeta): void { + ownMadroneMetaArray(metadata as unknown as Record).push(entry); } /** @@ -101,30 +115,28 @@ export function getMadroneMeta(target: object): MadroneMeta[] | undefined { * here will be overwritten when TS later assigns `context.metadata` to * `Class[Symbol.metadata]`. */ -export function ensureMadroneMeta(target: object): MadroneMeta[] { - const sym = (Symbol as unknown as { metadata: symbol }).metadata; - const holder = target as Record | undefined>; - - if (!holder[sym]) holder[sym] = {} as Record; - - const bag = holder[sym]; - - if (!bag[MADRONE_META]) bag[MADRONE_META] = []; - - return bag[MADRONE_META]; -} - /** * Like `ensureMadroneMeta` but takes a `DecoratorMetadata` bag directly — used - * by `applyClassMixins` during active class decoration, when the bag hasn't - * yet been attached to the class constructor. + * by `applyClassMixins` when a consumer calls it synchronously from inside a + * class decorator and threads `context.metadata` through as a parameter. */ export function ensureMadroneMetaOnBag(metadata: DecoratorMetadata): MadroneMeta[] { - const bag = metadata as unknown as Record; + return ownMadroneMetaArray(metadata as unknown as Record); +} - if (!bag[MADRONE_META]) bag[MADRONE_META] = []; +export function ensureMadroneMeta(target: object): MadroneMeta[] { + const sym = (Symbol as unknown as { metadata: symbol }).metadata; + const holder = target as Record | undefined>; - return bag[MADRONE_META]; + // Class constructors inherit static properties via their own prototype + // chain, so `holder[sym]` could be an inherited bag from a parent class. + // Give `target` its own bag chained off the inherited one so proto lookups + // still work for reads but writes land on `target`. + if (!Object.prototype.hasOwnProperty.call(holder, sym)) { + holder[sym] = Object.create(holder[sym] ?? null) as Record; + } + + return ownMadroneMetaArray(holder[sym]); } /** Walks the prototype chain to find the first descriptor that defines `key`. */ diff --git a/src/util.ts b/src/util.ts index 7982af1..03c8c1a 100644 --- a/src/util.ts +++ b/src/util.ts @@ -100,14 +100,34 @@ export function merge(...types: [...A]): Spread } /** - * Applies mixin classes to a base class by merging their prototypes. + * Applies mixin classes to a base class by merging their prototypes and + * replaying any `@reactive` / `@computed` metadata recorded on the mixins. * - * This function mutates the base class, copying all prototype properties - * from the mixin classes onto the base class prototype. Properties from - * the base class take precedence over mixin properties in case of conflicts. + * Mutates `base`: copies own prototype descriptors (methods, getters, setters) + * from each mixin onto `base.prototype`, then installs lazy-reactive accessors + * on `base.prototype` for each mixed-in `@reactive` field. Properties declared + * on `base` win over mixin entries with the same key. + * + * ### Timing + * + * Under TC39 standard decorators, `base[Symbol.metadata]` isn't attached + * until after class decoration completes. This function needs to read base's + * own entries from *somewhere*. Two valid patterns: + * + * - **Called after class decoration completes** (module top level, or inside + * a class decorator's `context.addInitializer` callback): reads + * `base[Symbol.metadata]` directly. No third argument needed. + * - **Called synchronously inside a class decorator**: pass `context.metadata` + * as the third argument so base's own entries are found in the live + * metadata bag that TS will attach once decoration finishes. The bundled + * `@classMixin` uses the `addInitializer` variant, but this third-argument + * form is supported for consumers building their own class decorators that + * can't defer. * * @param base - The base class to extend (will be mutated) * @param mixins - Array of mixin classes whose prototypes will be merged in + * @param baseMetadata - Optional: pass `context.metadata` when calling + * synchronously from inside a class decorator. * * @example * ```ts @@ -120,28 +140,30 @@ export function merge(...types: [...A]): Spread * } * } * - * class Serializable { - * toJSON() { - * return JSON.stringify(this); - * } - * } - * * class Model { * id: string; * } * - * // Add Timestamped and Serializable methods to Model - * applyClassMixins(Model, [Timestamped, Serializable]); + * applyClassMixins(Model, [Timestamped]); + * new Model().getAge(); + * ``` * - * const model = new Model(); - * model.toJSON(); // Works! - * model.getAge(); // Works! + * @example + * ```ts + * // Inside a custom class decorator, pass context.metadata so base's own + * // decorator entries aren't lost. + * function withTimestamps( + * target: T, + * context: ClassDecoratorContext, + * ) { + * applyClassMixins(target, [Timestamped], context.metadata); + * } * ``` */ export function applyClassMixins( base: Constructor, mixins: Constructor[], - baseMetadata?: DecoratorMetadata + baseMetadata?: DecoratorMetadata, ): void { // Build the merged descriptor map for the prototype merge, but strip any // lazy-mixin accessors that came from a mixin's prototype. They belong to @@ -168,15 +190,13 @@ export function applyClassMixins( // entries are *also* accumulated into `base`'s metadata so that classes // which later mix in `base` see the full transitive chain. // - // When called from an active class decorator, `baseMetadata` is the live - // metadata reference shared with all other decorators on the class — - // `base[Symbol.metadata]` is only attached after decoration completes, so - // reading it via the class constructor during decoration sees nothing. - // // Keys the base class re-declares with its own decorator are skipped — // the base's own addInitializer handles reactivity, and clobbering its // descriptor with a mixin wrapper would lose the base's paired setter - // and cause recursion on write. + // and cause recursion on write. If called synchronously inside a class + // decorator, `baseMetadata` points at the live metadata bag (which TS + // later attaches to `base[Symbol.metadata]`). Otherwise we read the + // already-attached bag via `ensureMadroneMeta`. const baseMeta = baseMetadata ? ensureMadroneMetaOnBag(baseMetadata) : ensureMadroneMeta(base); From b7c419b11bba3b8381ebc7f69986c4b2b45bc1d5 Mon Sep 17 00:00:00 2001 From: Tyler Reardon Date: Sat, 18 Apr 2026 13:52:37 -0700 Subject: [PATCH 06/12] refactor: drop reactive init factory; unify deferred + mixin lazy install MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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) --- src/__spec__/classMixin.spec.ts | 65 +++++-------- src/decorate.ts | 28 ++---- src/interfaces.ts | 19 ---- src/mixinSupport.ts | 157 ++++++++++++-------------------- src/util.ts | 39 ++++---- 5 files changed, 106 insertions(+), 202 deletions(-) diff --git a/src/__spec__/classMixin.spec.ts b/src/__spec__/classMixin.spec.ts index 7c3475f..538e72f 100644 --- a/src/__spec__/classMixin.spec.ts +++ b/src/__spec__/classMixin.spec.ts @@ -7,10 +7,10 @@ import { // `@classMixin` copies own prototype descriptors (methods, getters, setters) // from each mixin onto the target's prototype and replays `@reactive` / // `@computed` decorator metadata so mixed-in reactivity works on target -// instances. One limitation under TC39 standard decorators: a mixed-in -// `@reactive` field cannot carry its field initializer expression (`= 0`) -// across the mixin boundary — use `@reactive.configure({ init: () => 0 })` -// to supply a default instead. +// instances. Under TC39 standard decorators, a `@reactive` field decorator +// only runs for instances of its declaring class — so a mixed-in field's +// initial value (`= 0`) is not carried across the mixin boundary. Mixed-in +// reactive fields start as `undefined` and become reactive on first write. describe('classMixin', () => { describe('methods', () => { @@ -67,7 +67,7 @@ describe('classMixin', () => { }); describe('@reactive fields from a mixin', () => { - it('becomes reactive on the target; starts undefined without an init factory', async () => { + it('is reactive on the target; starts undefined (field initializer does not cross the boundary)', async () => { class NamedMixin { @reactive fName: string; } @@ -79,7 +79,6 @@ describe('classMixin', () => { const t = new Target(); - // No field-initializer expression is carried over — initial value is undefined. expect(t.fName).toBeUndefined(); const seen: Array = []; @@ -96,46 +95,29 @@ describe('classMixin', () => { stop(); }); - it('uses `reactive.configure({ init })` to supply an initial value across the mixin boundary', async () => { - class Counter { - @reactive.configure({ init: () => 7 }) count: number; + it('keeps per-instance reactivity independent across target instances', async () => { + class Shared { + @reactive value: number; } - @classMixin(Counter) + @classMixin(Shared) class Target {} - interface Target extends Counter {} - - const t = new Target(); - - expect(t.count).toEqual(7); - - // Also produces an independent initial value per instance. - const t2 = new Target(); - - expect(t2.count).toEqual(7); - t2.count = 99; - expect(t.count).toEqual(7); - expect(t2.count).toEqual(99); - }); - - it('init factory runs per instance (fresh arrays/objects are not shared)', () => { - class Listy { - @reactive.configure({ init: () => [] as number[] }) items: number[]; - } - - @classMixin(Listy) - class Target {} - - interface Target extends Listy {} + interface Target extends Shared {} const a = new Target(); const b = new Target(); - a.items.push(1); + a.value = 1; + b.value = 2; - expect(a.items).toEqual([1]); - expect(b.items).toEqual([]); + expect(a.value).toBe(1); + expect(b.value).toBe(2); + + a.value = 10; + + expect(a.value).toBe(10); + expect(b.value).toBe(2); }); }); @@ -219,13 +201,13 @@ describe('classMixin', () => { }); describe('combined @reactive + @computed through a mixin', () => { - it('reacts through the full chain when reactive deps are on the mixin with init factories', async () => { + it('reacts through the full chain once mixin @reactive fields are written', async () => { class NamedMixin { - @reactive.configure({ init: () => 'Anon' }) fName: string; - @reactive.configure({ init: () => 'User' }) lName: string; + @reactive fName: string; + @reactive lName: string; @computed get fullName() { - return `${this.fName} ${this.lName}`; + return `${this.fName ?? 'Anon'} ${this.lName ?? 'User'}`; } } @@ -238,6 +220,7 @@ describe('classMixin', () => { const p = new Person(); + // Before any write, mixin @reactive fields are undefined. expect(p.fullName).toEqual('Anon User'); const seen: string[] = []; diff --git a/src/decorate.ts b/src/decorate.ts index 0643304..12d45e7 100644 --- a/src/decorate.ts +++ b/src/decorate.ts @@ -36,9 +36,7 @@ import { reactiveDescriptor, recordMeta, } from '@/mixinSupport'; -import { - DecoratorOptionType, DecoratorDescriptorType, ReactiveDecoratorConfig, Constructor, -} from './interfaces'; +import { DecoratorOptionType, DecoratorDescriptorType, Constructor } from './interfaces'; // //////////////////////////// // CLASS MIXIN @@ -110,10 +108,7 @@ export interface ReactiveDecorator extends ReactiveFieldDecorator { /** * Creates a configured reactive decorator with custom descriptor options. * - * @param config - Descriptor overrides plus optional `init` factory. `init` - * supplies a default when the field's own initializer doesn't — the primary - * use case is mixed-in `@reactive` fields, which cannot carry their field - * initializer expression across the mixin boundary. + * @param descriptorOverrides - Options to customize the reactive behavior * * @example * ```ts @@ -121,14 +116,9 @@ export interface ReactiveDecorator extends ReactiveFieldDecorator { * @reactive.configure({ deep: false, enumerable: false }) * hiddenData = { secret: true }; * } - * - * class CounterMixin { - * // Mixed-in fields lose their `= 0`; use `init` to supply one. - * @reactive.configure({ init: () => 0 }) count: number; - * } * ``` */ - configure: (config: ReactiveDecoratorConfig) => ReactiveFieldDecorator, + configure: (descriptorOverrides: DecoratorDescriptorType) => ReactiveFieldDecorator, } function createReactiveDecorator(options?: DecoratorOptionType): ReactiveFieldDecorator { @@ -143,14 +133,8 @@ function createReactiveDecorator(options?: DecoratorOptionType): ReactiveFieldDe if (!markInitialized(instance, key)) return; // Under useDefineForClassFields, field initialization has already - // assigned the value as a plain data property. Capture it. If the - // field had no initializer (undefined) and the user supplied an - // `init` factory via `@reactive.configure`, use the factory instead. - const record = instance as Record; - const rawValue = record[key as string]; - const initialValue = rawValue === undefined && options?.init - ? options.init() - : rawValue; + // assigned the value as a plain data property. Capture it. + const initialValue = (instance as Record)[key as string]; if (getIntegration()) { // Integration active — install the reactive accessor on the instance. @@ -192,7 +176,7 @@ export const reactive: ReactiveDecorator = Object.assign( createReactiveDecorator(), { shallow: createReactiveDecorator({ descriptors: { deep: false } }), - configure: ({ init, ...descriptors }: ReactiveDecoratorConfig) => createReactiveDecorator({ descriptors, init }), + configure: (descriptorOverrides: DecoratorDescriptorType) => createReactiveDecorator({ descriptors: descriptorOverrides }), } ); diff --git a/src/interfaces.ts b/src/interfaces.ts index 17e2aa6..76bd4cc 100644 --- a/src/interfaces.ts +++ b/src/interfaces.ts @@ -74,31 +74,12 @@ export interface MadroneDescriptorMap { */ export type DecoratorDescriptorType = Omit; -/** - * User-facing config accepted by `@reactive.configure({...})`. Combines - * descriptor overrides with reactive-specific knobs like `init`. - */ -export type ReactiveDecoratorConfig = DecoratorDescriptorType & { - /** - * Factory returning the initial value for the reactive field. Invoked per - * instance, at install time. Useful for supplying a default across the - * mixin boundary — mixed-in `@reactive` fields cannot carry their field - * initializer expression, so this is the explicit way to provide one. - * - * On the declaring class, the field initializer (`= 0`) still wins when - * present; `init` only fires if the field value is `undefined`. - */ - init?: () => unknown, -}; - /** * Configuration options for decorator functions. */ export type DecoratorOptionType = { /** Property descriptor overrides */ descriptors?: DecoratorDescriptorType, - /** Factory returning the initial value for a reactive field */ - init?: () => unknown, }; /** diff --git a/src/mixinSupport.ts b/src/mixinSupport.ts index 6f4aa44..e8de8b4 100644 --- a/src/mixinSupport.ts +++ b/src/mixinSupport.ts @@ -184,8 +184,6 @@ export function computedDescriptor( }; } -type MixinMarkedFn = { __madroneMixin?: true } & ((...args: unknown[]) => unknown); - // //////////////////////////// // DEFERRED (LAZY) SETUP SUPPORT // //////////////////////////// @@ -258,75 +256,12 @@ function peekPending(instance: object, key: string | symbol): unknown { return (instance as Record)[PENDING_VALUES]?.get(key); } -/** - * Installs a lazy reactive accessor on `proto[key]` — used by `@reactive`'s - * `addInitializer` when no integration is available at construction time. - * - * Call flow from the field decorator: - * 1. Field init creates `instance.foo = value` as a plain data property. - * 2. `addInitializer` runs, sees no integration, stashes the value in the - * instance's `PENDING_VALUES` map, deletes the own property, and calls - * this function (guarded by `claimProtoLazySlot`) to install the accessor. - * 3. Later, after `Madrone.use(...)`, the first read/write on an instance - * finds no own property and reaches the prototype accessor. It then - * calls `define()` to replace itself with a real reactive accessor on - * the instance, using the stashed initial value. - */ -export function installDeferredReactive( - proto: object, - key: string | symbol, - options?: DecoratorOptionType -): void { - if (!claimProtoLazySlot(proto, key)) return; - - Object.defineProperty(proto, key, { - configurable: true, - enumerable: true, - get(this: object) { - if (!getIntegration()) return peekPending(this, key); - - const value = takePending(this, key); - - define(this, key as string, reactiveDescriptor(value, options)); - - return (this as Record)[key as string]; - }, - set(this: object, val: unknown) { - if (!getIntegration()) { - stashPending(this, key, val); - - return; - } - - takePending(this, key); - define(this, key as string, reactiveDescriptor(val, options)); - }, - }); -} - -/** - * Called from `@reactive`'s `addInitializer` when no integration is - * available — stashes the initial value, drops the own data property so - * the prototype's deferred accessor takes over, and installs that accessor - * (once per prototype). - */ -export function deferReactiveInstall( - instance: object, - key: string | symbol, - initialValue: unknown, - options?: DecoratorOptionType -): void { - stashPending(instance, key, initialValue); - delete (instance as Record)[key as string]; - installDeferredReactive(Object.getPrototypeOf(instance), key, options); -} +type MixinMarkedFn = { __madroneMixin?: true } & ((...args: unknown[]) => unknown); /** - * Returns true if the property descriptor was installed by `installMixinReactive` - * or `installMixinComputed` — its get/set functions are tagged with a marker - * symbol. We can't store the marker on the descriptor itself because - * `Object.defineProperty` ignores non-standard descriptor keys and they're - * lost the moment the property is defined. + * Returns true if the property descriptor was installed by a mixin helper + * (marker tagged on the function itself). Can't store the marker on the + * descriptor — `Object.defineProperty` ignores non-standard descriptor keys. */ export function isMixinInstalled(descriptor: PropertyDescriptor | undefined): boolean { if (!descriptor) return false; @@ -338,46 +273,55 @@ export function isMixinInstalled(descriptor: PropertyDescriptor | undefined): bo } /** - * Installs a lazy-reactive accessor on `proto` for `key`. On first read or - * write, the accessor sets up real reactivity on the instance, mirroring - * what `@reactive` would do if the decorator were applied directly to the - * target class. + * Installs a lazy reactive accessor on `proto[key]`. On first read or write, + * the accessor installs a real reactive descriptor on the instance (via + * `define`), then future accesses hit the instance-own accessor directly. + * + * Two callers, one implementation: + * - **Deferred pre-integration** (`deferReactiveInstall`): the field has an + * initial value the caller stashed on the instance beforehand. First + * access picks up the stash, promotes to real reactive. + * - **Mixin replay** (`applyClassMixins`): no stash exists, so first access + * starts the reactive at `undefined`. A mixed-in `@reactive` field can't + * carry its field initializer across the mixin boundary — TC39 field + * decorators only run for instances of the declaring class. + * + * Reads before an integration is registered return the stash directly; writes + * before that point replace the stash. Once an integration is registered, the + * next read/write promotes to a real reactive. */ -export function installMixinReactive( +export function installLazyReactive( proto: object, key: string | symbol, options?: DecoratorOptionType ): void { + if (!claimProtoLazySlot(proto, key)) return; + + // If the prototype already has a non-mixin-installed descriptor for this + // key (e.g. the base class declared its own accessor), leave it alone. const existing = Object.getOwnPropertyDescriptor(proto, key); - // If the target already declared its own descriptor (e.g. a re-applied - // `@reactive` on the target class itself), defer to that one. if (existing && !isMixinInstalled(existing)) return; - // Mixed-in `@reactive` fields don't carry a field initializer expression - // across the mixin boundary (TC39 field decorators only run for instances - // of the declaring class). If the user supplied `init` via - // `@reactive.configure`, invoke it per-instance to produce the default. const lazyGet = function lazyReactiveGet(this: object) { - if (!getIntegration()) return undefined; + if (!getIntegration()) return peekPending(this, key); - if (markInitialized(this, key)) { - const initialValue = options?.init ? options.init() : undefined; + const value = takePending(this, key); - define(this, key as string, reactiveDescriptor(initialValue, options)); - } + define(this, key as string, reactiveDescriptor(value, options)); return (this as Record)[key as string]; } as MixinMarkedFn; const lazySet = function lazyReactiveSet(this: object, val: unknown) { - if (!getIntegration()) return; + if (!getIntegration()) { + stashPending(this, key, val); - if (markInitialized(this, key)) { - define(this, key as string, reactiveDescriptor(val, options)); - } else { - (this as Record)[key as string] = val; + return; } + + takePending(this, key); + define(this, key as string, reactiveDescriptor(val, options)); } as MixinMarkedFn; lazyGet.__madroneMixin = true; @@ -392,9 +336,30 @@ export function installMixinReactive( } /** - * Wraps the computed getter on `proto[key]` so that the first access to a - * target instance installs an instance-level reactive computed. Preserves an - * un-decorated setter that pairs with the getter, if present. + * Called from `@reactive`'s `addInitializer` when no integration is + * available — stashes the initial value, drops the own data property so + * the prototype's lazy accessor takes over, and installs that accessor + * (once per prototype). + */ +export function deferReactiveInstall( + instance: object, + key: string | symbol, + initialValue: unknown, + options?: DecoratorOptionType +): void { + stashPending(instance, key, initialValue); + delete (instance as Record)[key as string]; + installLazyReactive(Object.getPrototypeOf(instance), key, options); +} + +/** + * Wraps a mixed-in `@computed` getter on `proto[key]` with a lazy wrapper + * that installs an instance-level cached reactive computed on first access. + * Called by `applyClassMixins` during metadata replay. + * + * The wrapper is tagged so nested `applyClassMixins` calls can strip it from + * the prototype merge — re-copying a mixin wrapper onto an unrelated class + * would re-bind a setter closure captured from the wrong class. */ export function installMixinComputed( proto: object, @@ -404,10 +369,6 @@ export function installMixinComputed( explicitSetter?: (this: object, val: unknown) => void ): void { const existing = Object.getOwnPropertyDescriptor(proto, key); - // Prefer an explicitly-passed setter (captured from the mixin's original - // prototype). Otherwise fall back to the existing descriptor's setter, but - // only if it isn't one we already installed — inheriting another mixin's - // lazy setter would cause infinite recursion through the Computed wrapper. const setter = explicitSetter ?? (existing && !isMixinInstalled(existing) ? (existing.set as ((val: unknown) => void) | undefined) @@ -442,7 +403,7 @@ export function installMixinComputed( Object.defineProperty(proto, key, { configurable: true, - enumerable: true, + enumerable: false, get: lazyGet, set: lazySet, }); diff --git a/src/util.ts b/src/util.ts index 03c8c1a..f69ca1e 100644 --- a/src/util.ts +++ b/src/util.ts @@ -9,7 +9,7 @@ import type { Constructor } from '@/interfaces'; import { - ensureMadroneMeta, ensureMadroneMetaOnBag, getMadroneMeta, installMixinComputed, installMixinReactive, isMixinInstalled, + ensureMadroneMeta, ensureMadroneMetaOnBag, getMadroneMeta, installLazyReactive, installMixinComputed, isMixinInstalled, } from '@/mixinSupport'; type AnyObject = Record; @@ -183,37 +183,32 @@ export function applyClassMixins( Object.defineProperties(base.prototype, mergedDescriptors); - // Replay decorator metadata from mixins. Field decorators under TC39 - // standard decorators produce no prototype artifacts of their own, so - // we install lazy accessors on the target prototype for mixed-in - // @reactive fields, and wrap mixed-in @computed getters. The installed - // entries are *also* accumulated into `base`'s metadata so that classes + // Replay `@reactive` metadata from mixins — field decorators under TC39 + // produce no prototype artifacts of their own, so we install lazy-reactive + // accessors on the target prototype for each mixed-in reactive key. + // Entries are also accumulated into `base`'s metadata so that classes // which later mix in `base` see the full transitive chain. // // Keys the base class re-declares with its own decorator are skipped — // the base's own addInitializer handles reactivity, and clobbering its - // descriptor with a mixin wrapper would lose the base's paired setter - // and cause recursion on write. If called synchronously inside a class - // decorator, `baseMetadata` points at the live metadata bag (which TS - // later attaches to `base[Symbol.metadata]`). Otherwise we read the - // already-attached bag via `ensureMadroneMeta`. + // descriptor with a mixin accessor would cause recursion on write. If + // called synchronously inside a class decorator, `baseMetadata` points at + // the live metadata bag (which TS later attaches to `base[Symbol.metadata]`). + // Otherwise we read the already-attached bag via `ensureMadroneMeta`. const baseMeta = baseMetadata ? ensureMadroneMetaOnBag(baseMetadata) : ensureMadroneMeta(base); const baseOwnKeys = new Set(baseMeta.map((e) => e.key)); // Collect mixin entries with later-wins semantics (matches the prototype - // merge order `[...mixins, base]`). A later mixin's entry overrides an - // earlier mixin's entry for the same key. Base's own entries then win - // over all mixin entries — `base` is always last in the merge order. - const resolved = new Map(); + // merge order `[...mixins, base]`). Base's own entries win over all mixin + // entries — base is always last in the merge order. + type MetaEntry = ReturnType extends (infer U)[] | undefined ? U : never; - type Meta = ReturnType extends (infer U)[] | undefined ? U : never; + const resolved = new Map(); for (const mixin of mixins) { - const entries = getMadroneMeta(mixin) ?? []; - - for (const entry of entries) { + for (const entry of getMadroneMeta(mixin) ?? []) { resolved.set(entry.key, { entry, originProto: mixin.prototype }); } } @@ -222,13 +217,13 @@ export function applyClassMixins( for (const { entry, originProto } of resolved.values()) { if (entry.kind === 'reactive') { - installMixinReactive(base.prototype, entry.key, entry.options); + installLazyReactive(base.prototype, entry.key, entry.options); baseMeta.push(entry); } else { // Resolve a paired setter: prefer one already carried on the metadata // entry (captured through an earlier mixin chain), else look at the - // originating mixin's prototype for a non-mixin setter (e.g. a - // `set $relLinks(val)` that pairs with `@computed get $relLinks()`). + // originating mixin's prototype for a non-mixin setter (a `set foo()` + // that pairs with `@computed get foo()`). const originalDesc = Object.getOwnPropertyDescriptor(originProto, entry.key); const resolvedSetter = entry.setter ?? (originalDesc && !isMixinInstalled(originalDesc) From 780be64d0203e0057bb7ccd5044b69a87374c98f Mon Sep 17 00:00:00 2001 From: Tyler Reardon Date: Sat, 18 Apr 2026 14:14:42 -0700 Subject: [PATCH 07/12] refactor: track mixin-installed accessors via WeakSet instead of function marker MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- src/mixinSupport.ts | 36 ++++++++++++++++++++++-------------- 1 file changed, 22 insertions(+), 14 deletions(-) diff --git a/src/mixinSupport.ts b/src/mixinSupport.ts index e8de8b4..66f64bb 100644 --- a/src/mixinSupport.ts +++ b/src/mixinSupport.ts @@ -256,19 +256,27 @@ function peekPending(instance: object, key: string | symbol): unknown { return (instance as Record)[PENDING_VALUES]?.get(key); } -type MixinMarkedFn = { __madroneMixin?: true } & ((...args: unknown[]) => unknown); +// Tracks `get`/`set` functions that came from `installLazyReactive` or +// `installMixinComputed`. We can't tag the descriptor itself +// (`Object.defineProperty` silently drops non-standard fields), and we'd +// rather not mutate the function objects — so membership in this WeakSet +// is the authoritative "was this accessor installed by us" signal. +const mixinInstalledFns = new WeakSet(); + +function markMixinFn(fn: object): void { + mixinInstalledFns.add(fn); +} /** - * Returns true if the property descriptor was installed by a mixin helper - * (marker tagged on the function itself). Can't store the marker on the - * descriptor — `Object.defineProperty` ignores non-standard descriptor keys. + * Returns true if the property descriptor's get or set function was installed + * by a mixin helper. */ export function isMixinInstalled(descriptor: PropertyDescriptor | undefined): boolean { if (!descriptor) return false; return Boolean( - (descriptor.get as MixinMarkedFn | undefined)?.__madroneMixin - || (descriptor.set as MixinMarkedFn | undefined)?.__madroneMixin + (descriptor.get && mixinInstalledFns.has(descriptor.get)) + || (descriptor.set && mixinInstalledFns.has(descriptor.set)) ); } @@ -311,7 +319,7 @@ export function installLazyReactive( define(this, key as string, reactiveDescriptor(value, options)); return (this as Record)[key as string]; - } as MixinMarkedFn; + }; const lazySet = function lazyReactiveSet(this: object, val: unknown) { if (!getIntegration()) { @@ -322,10 +330,10 @@ export function installLazyReactive( takePending(this, key); define(this, key as string, reactiveDescriptor(val, options)); - } as MixinMarkedFn; + }; - lazyGet.__madroneMixin = true; - lazySet.__madroneMixin = true; + markMixinFn(lazyGet); + markMixinFn(lazySet); Object.defineProperty(proto, key, { configurable: true, @@ -382,7 +390,7 @@ export function installMixinComputed( } return (this as Record)[key as string]; - } as MixinMarkedFn; + }; const lazySet = function lazyComputedSet(this: object, val: unknown) { if (!getIntegration()) { @@ -396,10 +404,10 @@ export function installMixinComputed( } (this as Record)[key as string] = val; - } as MixinMarkedFn; + }; - lazyGet.__madroneMixin = true; - lazySet.__madroneMixin = true; + markMixinFn(lazyGet); + markMixinFn(lazySet); Object.defineProperty(proto, key, { configurable: true, From d102fc145656ad89126a19fe90e6c079350a02f9 Mon Sep 17 00:00:00 2001 From: Tyler Reardon Date: Sat, 18 Apr 2026 14:43:53 -0700 Subject: [PATCH 08/12] chore: WeakMap-backed pending stash; expand classMixin comment; CHANGELOG - 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) --- CHANGELOG.md | 10 ++++++++++ src/decorate.ts | 18 +++++++++++++----- src/mixinSupport.ts | 18 ++++++++++-------- 3 files changed, 33 insertions(+), 13 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 39bd866..2a365ca 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Breaking + +- **Decorators**: Migrated from TypeScript experimental decorators to TC39 standard decorators. Consumers must use `target: "ES2022"` (or later) and `useDefineForClassFields: true` in `tsconfig.json`, and `experimentalDecorators: true` must be removed if set. Requires TypeScript 5.0+. +- **`@computed`**: Now defaults to **non-enumerable** (matches native JS class-getter semantics). Callers that relied on `Object.keys(instance)` / `{ ...instance }` picking up computeds should use `@computed.configure({ enumerable: true })`. + +### Added + +- **Deferred install**: `@reactive` fields now install lazily when a class is instantiated before an integration is registered (`Madrone.use(...)`). Values set before integration are stashed per-instance and become reactive on first read/write after integration. Previously, pre-integration instances were silently non-reactive. +- **`applyClassMixins`**: Optional third `baseMetadata` parameter for callers writing their own synchronous class decorators that need to thread `context.metadata` through. The common path (`@classMixin`, or calling `applyClassMixins` at module top level) doesn't need it. + ### Fixed - **Observer**: Fixed dependencies being cleared when reactive objects are created during a computed's execution. Previously, writing to a new atom would clear all dependencies sharing the same key, including the original source. diff --git a/src/decorate.ts b/src/decorate.ts index 12d45e7..11957b3 100644 --- a/src/decorate.ts +++ b/src/decorate.ts @@ -69,11 +69,19 @@ export function classMixin(...mixins: Constructor[]) { if (!mixins?.length) return; // Defer mixin application until after the class body has been fully - // decorated. A class decorator's `addInitializer` callback runs after - // TS attaches `target[Symbol.metadata]`, so by the time we call - // `applyClassMixins`, base's own @reactive / @computed entries are - // readable via the standard metadata path — no need to thread - // `context.metadata` through as a separate parameter. + // decorated. Calling `applyClassMixins` synchronously here would run + // before TS attaches `target[Symbol.metadata]`, so `applyClassMixins` + // would see an empty metadata bag for `target` and fail to dedup + // against base's own `@reactive` / `@computed` declarations. + // + // A class decorator's `addInitializer` callback runs after metadata + // attachment — by the time this fires, `this[Symbol.metadata]` is + // populated with all of base's decorator entries and the standard + // metadata read in `applyClassMixins` is correct. + // + // Consumers writing their own class decorator that calls + // `applyClassMixins` synchronously can pass `context.metadata` as the + // optional third argument to get the same effect without deferring. context.addInitializer(function mixinInitializer() { applyClassMixins(this as unknown as Constructor, mixins); }); diff --git a/src/mixinSupport.ts b/src/mixinSupport.ts index 66f64bb..4d19f16 100644 --- a/src/mixinSupport.ts +++ b/src/mixinSupport.ts @@ -219,18 +219,20 @@ function claimProtoLazySlot(proto: object, key: string | symbol): boolean { return true; } -const PENDING_VALUES = Symbol.for('@madronejs/pendingValues'); - +// Per-instance map of pending values stashed when an integration wasn't +// available at construction time. Kept in a WeakMap rather than as an own +// symbol-keyed property on the instance so that `Reflect.ownKeys(instance)` +// and similar introspection stays clean. type PendingMap = Map; +const pendingValues = new WeakMap(); + function pendingMap(instance: object): PendingMap { - let map = (instance as Record)[PENDING_VALUES]; + let map = pendingValues.get(instance); if (!map) { map = new Map(); - Object.defineProperty(instance, PENDING_VALUES, { - value: map, writable: false, enumerable: false, configurable: true, - }); + pendingValues.set(instance, map); } return map; @@ -241,7 +243,7 @@ function stashPending(instance: object, key: string | symbol, value: unknown): v } function takePending(instance: object, key: string | symbol): unknown { - const map = (instance as Record)[PENDING_VALUES]; + const map = pendingValues.get(instance); if (!map) return undefined; @@ -253,7 +255,7 @@ function takePending(instance: object, key: string | symbol): unknown { } function peekPending(instance: object, key: string | symbol): unknown { - return (instance as Record)[PENDING_VALUES]?.get(key); + return pendingValues.get(instance)?.get(key); } // Tracks `get`/`set` functions that came from `installLazyReactive` or From 1f57ebe6d8493fa115918897cd1f5c86bdd7da96 Mon Sep 17 00:00:00 2001 From: Tyler Reardon Date: Sat, 18 Apr 2026 14:58:07 -0700 Subject: [PATCH 09/12] feat: default @reactive to configurable: true MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- CHANGELOG.md | 1 + src/__spec__/decorateReactive.spec.ts | 10 +++++----- src/mixinSupport.ts | 2 +- 3 files changed, 7 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2a365ca..5a711ac 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **Decorators**: Migrated from TypeScript experimental decorators to TC39 standard decorators. Consumers must use `target: "ES2022"` (or later) and `useDefineForClassFields: true` in `tsconfig.json`, and `experimentalDecorators: true` must be removed if set. Requires TypeScript 5.0+. - **`@computed`**: Now defaults to **non-enumerable** (matches native JS class-getter semantics). Callers that relied on `Object.keys(instance)` / `{ ...instance }` picking up computeds should use `@computed.configure({ enumerable: true })`. +- **`@reactive`**: Now defaults to **configurable: true**. Previously the installed descriptor was non-configurable, which blocked redefinition (HMR, test harnesses, downstream re-decoration). Use `@reactive.configure({ configurable: false })` for the old behavior. ### Added diff --git a/src/__spec__/decorateReactive.spec.ts b/src/__spec__/decorateReactive.spec.ts index 8d5ad21..4a11f02 100644 --- a/src/__spec__/decorateReactive.spec.ts +++ b/src/__spec__/decorateReactive.spec.ts @@ -28,7 +28,7 @@ describe('reactive decorator', () => { }); describe('configurable', () => { - it('makes properties non-configurable by default', () => { + it('makes properties configurable by default', () => { class Test { @reactive test: boolean = true; } @@ -47,12 +47,12 @@ describe('reactive decorator', () => { error = error_; } - expect(error?.message).toEqual('Cannot redefine property: test'); + expect(error?.message).toBeUndefined(); }); - it('can make properties configurable', () => { + it('can make properties non-configurable', () => { class Test { - @reactive.configure({ configurable: true }) test: boolean = true; + @reactive.configure({ configurable: false }) test: boolean = true; } const instance = new Test(); @@ -69,7 +69,7 @@ describe('reactive decorator', () => { error = error_; } - expect(error?.message).toBeUndefined(); + expect(error?.message).toEqual('Cannot redefine property: test'); }); }); }); diff --git a/src/mixinSupport.ts b/src/mixinSupport.ts index 4d19f16..0bc213e 100644 --- a/src/mixinSupport.ts +++ b/src/mixinSupport.ts @@ -158,7 +158,7 @@ export function reactiveDescriptor(value: unknown, options?: DecoratorOptionType return { value, enumerable: true, - configurable: false, + configurable: true, deep: true, ...options?.descriptors, }; From 001c8e1251db1e91d263bcebabc3da9200044b3e Mon Sep 17 00:00:00 2001 From: Tyler Reardon Date: Sat, 18 Apr 2026 15:04:55 -0700 Subject: [PATCH 10/12] feat: add compose() utility for functional mixins MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 = (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) --- CHANGELOG.md | 1 + src/__spec__/compose.spec.ts | 209 +++++++++++++++++++++++++++++++++++ src/index.ts | 2 +- src/util.ts | 59 ++++++++++ 4 files changed, 270 insertions(+), 1 deletion(-) create mode 100644 src/__spec__/compose.spec.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 5a711ac..2692099 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **Deferred install**: `@reactive` fields now install lazily when a class is instantiated before an integration is registered (`Madrone.use(...)`). Values set before integration are stashed per-instance and become reactive on first read/write after integration. Previously, pre-integration instances were silently non-reactive. - **`applyClassMixins`**: Optional third `baseMetadata` parameter for callers writing their own synchronous class decorators that need to thread `context.metadata` through. The common path (`@classMixin`, or calling `applyClassMixins` at module top level) doesn't need it. +- **`compose(...mixins)`**: Functional-mixin helper that folds a list of higher-order class factories (`(Base: B) => class extends Base { ... }`) into a single composable base class for `extends`. Alternative to `@classMixin` that uses native class inheritance — field initializers run, types flow through `extends` without declaration merging, no metadata replay. Leftmost mixin is outermost (Redux-style). ### Fixed diff --git a/src/__spec__/compose.spec.ts b/src/__spec__/compose.spec.ts new file mode 100644 index 0000000..618e83d --- /dev/null +++ b/src/__spec__/compose.spec.ts @@ -0,0 +1,209 @@ +/* eslint-disable max-classes-per-file, unicorn/consistent-function-scoping */ +import { describe, it, expect } from 'vitest'; +import { + reactive, computed, compose, watch, + type Constructor, +} from '../index'; + +// `compose` is the functional-mixin alternative to `@classMixin`. Unlike +// the descriptor-replay path, it uses native JS inheritance, so `@reactive` +// field initializers run, `@computed` getters mix in naturally, and types +// flow through `extends` without declaration merging. + +describe('compose', () => { + describe('basic composition', () => { + it('a single mixin adds its fields to the target via extends', () => { + const Named = (Base: T) => class extends Base { + @reactive fName = 'Ada'; + }; + + class Person extends compose(Named) {} + + const p = new Person(); + + expect(p.fName).toEqual('Ada'); + }); + + it('mixin @reactive field initializers carry over (unlike @classMixin)', () => { + const Counter = (Base: T) => class extends Base { + @reactive count = 42; + }; + + class Thing extends compose(Counter) {} + + expect(new Thing().count).toEqual(42); + }); + + it('mixin @computed getters work and stay reactive', async () => { + const Doubler = (Base: T) => class extends Base { + @reactive source = 3; + + @computed get doubled() { + return this.source * 2; + } + }; + + class Thing extends compose(Doubler) {} + + const t = new Thing(); + + expect(t.doubled).toEqual(6); + + const seen: number[] = []; + const stop = watch(() => t.doubled, (val) => { + seen.push(val); + }); + + t.source = 10; + await Promise.resolve(); + t.source = 25; + await Promise.resolve(); + + expect(seen).toEqual([20, 50]); + stop(); + }); + }); + + describe('multiple mixins', () => { + it('composes two mixins — both sets of fields exist on instances', () => { + const Named = (Base: T) => class extends Base { + @reactive name = 'Grace'; + }; + + const Timestamped = (Base: T) => class extends Base { + @reactive createdAt = 1000; + }; + + class Person extends compose(Named, Timestamped) {} + + const p = new Person(); + + expect(p.name).toEqual('Grace'); + expect(p.createdAt).toEqual(1000); + }); + + it('leftmost mixin is outermost (Redux-style compose semantics)', () => { + const A = (Base: T) => class extends Base { + who() { return 'a'; } + }; + + const B = (Base: T) => class extends Base { + who() { return 'b'; } + }; + + const C = (Base: T) => class extends Base { + who() { return 'c'; } + }; + + // compose(A, B, C) = A(B(C(Base))). A is outermost; its method wins. + class X extends compose(A, B, C) {} + + expect(new X().who()).toEqual('a'); + }); + + it('target class methods win over composed mixin methods', () => { + const A = (Base: T) => class extends Base { + label() { return 'mixin'; } + }; + + class Target extends compose(A) { + label() { return 'target'; } + } + + expect(new Target().label()).toEqual('target'); + }); + }); + + describe('combined reactive + computed through compose', () => { + it('full reactive chain works end-to-end', async () => { + const Named = (Base: T) => class extends Base { + @reactive fName = 'Ada'; + @reactive lName = 'Lovelace'; + + @computed get fullName() { + return `${this.fName} ${this.lName}`; + } + }; + + class Person extends compose(Named) { + @reactive age = 36; + } + + const p = new Person(); + + expect(p.fullName).toEqual('Ada Lovelace'); + expect(p.age).toEqual(36); + + const seen: string[] = []; + const stop = watch(() => p.fullName, (val) => { + seen.push(val); + }); + + p.fName = 'Grace'; + await Promise.resolve(); + p.lName = 'Hopper'; + await Promise.resolve(); + + expect(p.fullName).toEqual('Grace Hopper'); + expect(seen).toEqual(['Grace Lovelace', 'Grace Hopper']); + stop(); + }); + + it('per-instance state is isolated', () => { + const Counted = (Base: T) => class extends Base { + @reactive n = 0; + }; + + class Thing extends compose(Counted) {} + + const a = new Thing(); + const b = new Thing(); + + a.n = 1; + b.n = 2; + + expect(a.n).toEqual(1); + expect(b.n).toEqual(2); + }); + }); + + describe('type shape', () => { + it('produces a constructable class with the expected instance shape', () => { + const Foo = (Base: T) => class extends Base { + foo = 'foo'; + }; + + const Bar = (Base: T) => class extends Base { + bar = 'bar'; + }; + + const Composed = compose(Foo, Bar); + const instance = new Composed(); + + expect((instance as unknown as { foo: string }).foo).toEqual('foo'); + expect((instance as unknown as { bar: string }).bar).toEqual('bar'); + }); + + it('handles more than a handful of mixins (no hard arity cap)', () => { + const M1 = (Base: T) => class extends Base { m1 = 1; }; + const M2 = (Base: T) => class extends Base { m2 = 2; }; + const M3 = (Base: T) => class extends Base { m3 = 3; }; + const M4 = (Base: T) => class extends Base { m4 = 4; }; + const M5 = (Base: T) => class extends Base { m5 = 5; }; + const M6 = (Base: T) => class extends Base { m6 = 6; }; + const M7 = (Base: T) => class extends Base { m7 = 7; }; + + class Big extends compose(M1, M2, M3, M4, M5, M6, M7) {} + + const b = new Big() as unknown as Record; + + expect(b.m1).toEqual(1); + expect(b.m2).toEqual(2); + expect(b.m3).toEqual(3); + expect(b.m4).toEqual(4); + expect(b.m5).toEqual(5); + expect(b.m6).toEqual(6); + expect(b.m7).toEqual(7); + }); + }); +}); diff --git a/src/index.ts b/src/index.ts index abcd377..caa6c6a 100644 --- a/src/index.ts +++ b/src/index.ts @@ -24,5 +24,5 @@ export * from '@/integrations'; export * from '@/interfaces'; export * from '@/decorate'; export { toRaw } from '@/global'; -export { merge, applyClassMixins } from '@/util'; +export { merge, applyClassMixins, compose } from '@/util'; export { watch, auto } from '@/auto'; diff --git a/src/util.ts b/src/util.ts index f69ca1e..6a4aaa4 100644 --- a/src/util.ts +++ b/src/util.ts @@ -263,3 +263,62 @@ export function getDefaultDescriptors( return descriptors; } + +/** + * Composes multiple functional mixins — higher-order class functions of the + * form `(base: B) => class extends base { ... }` — into + * a single base class suitable for `extends`. + * + * Unlike `@classMixin`, which copies descriptors across a prototype boundary, + * this is native JavaScript class inheritance: field initializers run, the + * prototype chain is real, and types flow through `extends` without needing + * `interface X extends Y {}` declaration merging. + * + * Reduces right-to-left, so the leftmost mixin is outermost (matches Redux- + * style `compose`, and the mental model that `class X extends compose(A, B)` + * reads "X is an A that is a B"). + * + * @example + * ```ts + * const Timestamped = (Base: T) => class extends Base { + * @reactive createdAt = Date.now(); + * }; + * + * const Named = (Base: T) => class extends Base { + * @reactive fName = ''; + * @reactive lName = ''; + * + * @computed get fullName() { + * return `${this.fName} ${this.lName}`; + * } + * }; + * + * class Person extends compose(Timestamped, Named) { + * @reactive age = 0; + * } + * ``` + */ +type MixinFactory = (base: Constructor) => Constructor; + +type UnionToIntersection = ( + U extends unknown ? (arg: U) => void : never +) extends (arg: infer I) => void + ? I + : never; + +/** Intersection of return types across a tuple of mixin factories. */ +type ComposedResult = UnionToIntersection< + { [K in keyof Ms]: ReturnType }[number] +>; + +export function compose( + ...mixins: [...Ms] +): ComposedResult { + let Base: Constructor = class {}; + + for (let i = mixins.length - 1; i >= 0; i -= 1) { + Base = mixins[i](Base); + } + + return Base as ComposedResult; +} From 7428f5f8fe03dbc3a93e3cc5bd8c602e5bc81a67 Mon Sep 17 00:00:00 2001 From: Tyler Reardon Date: Sat, 18 Apr 2026 15:35:50 -0700 Subject: [PATCH 11/12] docs: cleanup comments and JSDoc after the decorator migration - 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) --- src/decorate.ts | 41 +++++++++++++++++++++++++++++++++++------ src/mixinSupport.ts | 38 +++++++++++++++++++++----------------- src/util.ts | 39 ++++++++++++++++++++++----------------- 3 files changed, 78 insertions(+), 40 deletions(-) diff --git a/src/decorate.ts b/src/decorate.ts index 11957b3..7f2e82b 100644 --- a/src/decorate.ts +++ b/src/decorate.ts @@ -43,22 +43,51 @@ import { DecoratorOptionType, DecoratorDescriptorType, Constructor } from './int // //////////////////////////// /** - * Class decorator that mixes in methods from other classes. + * Class decorator that mixes prototype members and decorator metadata from + * other classes into the decorated class. * - * Copies prototype properties from the mixin classes onto the decorated - * class and replays their `@reactive` / `@computed` decorator metadata so - * that mixed-in reactivity works on instances of the target class. + * Copies own prototype descriptors (methods, getters, setters) from each + * mixin onto the target's prototype, then replays `@reactive` / `@computed` + * decorator metadata so mixed-in reactivity works on target instances. + * + * ### Limitations + * + * - **`@reactive` field initializers don't carry across.** TC39 field + * decorators only run on instances of the declaring class, so a mixin's + * `@reactive count = 0` installs a reactive accessor on target instances + * but starts `undefined`. Declare reactive state on the target class, or + * use {@link compose} for native-JS inheritance where field initializers + * do run. + * - **Types don't flow automatically.** Add `interface Target extends Mixin {}` + * declaration merging next to the target class for type-safe access to + * mixed-in members. Or use {@link compose}, which propagates types through + * `extends` natively. + * - **Chaining order matters.** `@classMixin(A, B) class X {}` and + * `@classMixin(A) @classMixin(B) class X {}` resolve same-key conflicts + * differently — the combined form applies mixins in one pass (later wins + * among mixins; base wins over all), while the stacked form runs as two + * separate passes. Prefer the combined form. + * + * @see {@link compose} for the functional-mixin alternative. * * @example * ```ts * class Timestamped { - * createdAt = Date.now(); + * @reactive createdAt: number; + * + * touch() { this.createdAt = Date.now(); } * } * + * interface Model extends Timestamped {} + * * @classMixin(Timestamped) * class Model { - * name: string; + * @reactive name: string; * } + * + * const m = new Model(); + * m.touch(); // mixed-in method + * m.name = 'foo'; // target reactive field * ``` */ export function classMixin(...mixins: Constructor[]) { diff --git a/src/mixinSupport.ts b/src/mixinSupport.ts index 0bc213e..7738699 100644 --- a/src/mixinSupport.ts +++ b/src/mixinSupport.ts @@ -104,6 +104,15 @@ export function getMadroneMeta(target: object): MadroneMeta[] | undefined { return meta?.[MADRONE_META]; } +/** + * Like `ensureMadroneMeta` but takes a `DecoratorMetadata` bag directly — used + * by `applyClassMixins` when a consumer calls it synchronously from inside a + * class decorator and threads `context.metadata` through as a parameter. + */ +export function ensureMadroneMetaOnBag(metadata: DecoratorMetadata): MadroneMeta[] { + return ownMadroneMetaArray(metadata as unknown as Record); +} + /** * Returns the madrone decorator metadata array for `target`, creating (and * attaching) an empty one if it doesn't exist yet. Used by `applyClassMixins` @@ -115,15 +124,6 @@ export function getMadroneMeta(target: object): MadroneMeta[] | undefined { * here will be overwritten when TS later assigns `context.metadata` to * `Class[Symbol.metadata]`. */ -/** - * Like `ensureMadroneMeta` but takes a `DecoratorMetadata` bag directly — used - * by `applyClassMixins` when a consumer calls it synchronously from inside a - * class decorator and threads `context.metadata` through as a parameter. - */ -export function ensureMadroneMetaOnBag(metadata: DecoratorMetadata): MadroneMeta[] { - return ownMadroneMetaArray(metadata as unknown as Record); -} - export function ensureMadroneMeta(target: object): MadroneMeta[] { const sym = (Symbol as unknown as { metadata: symbol }).metadata; const holder = target as Record | undefined>; @@ -190,11 +190,14 @@ export function computedDescriptor( // // When a decorated class is instantiated before an integration has been // registered (e.g. `new Foo()` runs in a module that loads before -// `Madrone.use(...)`), we can't build the reactive atom / cached computed -// yet. Instead of bailing silently — which would leave the instance with -// no retry path — we install a prototype-level lazy accessor that -// reattempts the install on first read/write once an integration becomes -// available. +// `Madrone.use(...)`), we can't build the reactive atom yet. Instead of +// bailing silently — which would leave the instance with no retry path — +// we install a prototype-level lazy accessor that reattempts the install +// on first read/write once an integration becomes available. +// +// `@computed` doesn't go through this machinery — its decorator-returned +// lazy getter sits on the prototype unconditionally and falls back to an +// uncached call when no integration is registered (see decorate.ts). const protoLazyInstalled = new WeakMap>(); @@ -367,9 +370,10 @@ export function deferReactiveInstall( * that installs an instance-level cached reactive computed on first access. * Called by `applyClassMixins` during metadata replay. * - * The wrapper is tagged so nested `applyClassMixins` calls can strip it from - * the prototype merge — re-copying a mixin wrapper onto an unrelated class - * would re-bind a setter closure captured from the wrong class. + * The wrapper's get/set functions are recorded in `mixinInstalledFns` so + * nested `applyClassMixins` calls can strip them from the prototype merge + * — re-copying a mixin wrapper onto an unrelated class would re-bind a + * setter closure captured from the wrong class. */ export function installMixinComputed( proto: object, diff --git a/src/util.ts b/src/util.ts index 6a4aaa4..9433fd1 100644 --- a/src/util.ts +++ b/src/util.ts @@ -245,8 +245,6 @@ export function applyClassMixins( * @param obj - The source object * @param defaults - Default descriptor values to apply * @returns Property descriptor map with defaults applied - * - * @internal */ export function getDefaultDescriptors( obj: object, @@ -264,6 +262,19 @@ export function getDefaultDescriptors( return descriptors; } +type MixinFactory = (base: Constructor) => Constructor; + +type UnionToIntersection = ( + U extends unknown ? (arg: U) => void : never +) extends (arg: infer I) => void + ? I + : never; + +/** Intersection of return types across a tuple of mixin factories. */ +type ComposedResult = UnionToIntersection< + { [K in keyof Ms]: ReturnType }[number] +>; + /** * Composes multiple functional mixins — higher-order class functions of the * form `(base: B) => class extends base { ... }` — into @@ -272,11 +283,18 @@ export function getDefaultDescriptors( * Unlike `@classMixin`, which copies descriptors across a prototype boundary, * this is native JavaScript class inheritance: field initializers run, the * prototype chain is real, and types flow through `extends` without needing - * `interface X extends Y {}` declaration merging. + * `interface X extends Y {}` declaration merging. Prefer `compose` when + * mixin `@reactive` fields need their initial values to carry over to + * target instances. * * Reduces right-to-left, so the leftmost mixin is outermost (matches Redux- * style `compose`, and the mental model that `class X extends compose(A, B)` - * reads "X is an A that is a B"). + * reads "X is an A that is a B"). Accepts any number of mixin factories — + * there's no hard arity cap, the typing is variadic. + * + * @see {@link classMixin} for the decorator-based alternative that doesn't + * add prototype-chain layers but requires `interface X extends Y {}` type + * merging and loses mixin field initializers. * * @example * ```ts @@ -298,19 +316,6 @@ export function getDefaultDescriptors( * } * ``` */ -type MixinFactory = (base: Constructor) => Constructor; - -type UnionToIntersection = ( - U extends unknown ? (arg: U) => void : never -) extends (arg: infer I) => void - ? I - : never; - -/** Intersection of return types across a tuple of mixin factories. */ -type ComposedResult = UnionToIntersection< - { [K in keyof Ms]: ReturnType }[number] ->; - export function compose( ...mixins: [...Ms] ): ComposedResult { From 2a3b6f3e76401ff016a95813cde5ce984355e914 Mon Sep 17 00:00:00 2001 From: Tyler Reardon Date: Sat, 18 Apr 2026 16:14:15 -0700 Subject: [PATCH 12/12] =?UTF-8?q?docs:=20trim=20CHANGELOG=20=E2=80=94=20dr?= =?UTF-8?q?op=20internal-detail=20entries?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- CHANGELOG.md | 2 -- 1 file changed, 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2692099..21720a9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,8 +15,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added -- **Deferred install**: `@reactive` fields now install lazily when a class is instantiated before an integration is registered (`Madrone.use(...)`). Values set before integration are stashed per-instance and become reactive on first read/write after integration. Previously, pre-integration instances were silently non-reactive. -- **`applyClassMixins`**: Optional third `baseMetadata` parameter for callers writing their own synchronous class decorators that need to thread `context.metadata` through. The common path (`@classMixin`, or calling `applyClassMixins` at module top level) doesn't need it. - **`compose(...mixins)`**: Functional-mixin helper that folds a list of higher-order class factories (`(Base: B) => class extends Base { ... }`) into a single composable base class for `extends`. Alternative to `@classMixin` that uses native class inheritance — field initializers run, types flow through `extends` without declaration merging, no metadata replay. Leftmost mixin is outermost (Redux-style). ### Fixed