diff --git a/CHANGELOG.md b/CHANGELOG.md index 39bd866..21720a9 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 })`. +- **`@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 + +- **`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 - **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/__spec__/classMixin.spec.ts b/src/__spec__/classMixin.spec.ts new file mode 100644 index 0000000..538e72f --- /dev/null +++ b/src/__spec__/classMixin.spec.ts @@ -0,0 +1,241 @@ +/* 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. 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', () => { + 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('is reactive on the target; starts undefined (field initializer does not cross the boundary)', async () => { + class NamedMixin { + @reactive fName: string; + } + + @classMixin(NamedMixin) + class Target {} + + interface Target extends NamedMixin {} + + const t = new Target(); + + 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('keeps per-instance reactivity independent across target instances', async () => { + class Shared { + @reactive value: number; + } + + @classMixin(Shared) + class Target {} + + interface Target extends Shared {} + + const a = new Target(); + const b = new Target(); + + a.value = 1; + b.value = 2; + + expect(a.value).toBe(1); + expect(b.value).toBe(2); + + a.value = 10; + + expect(a.value).toBe(10); + expect(b.value).toBe(2); + }); + }); + + 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 once mixin @reactive fields are written', async () => { + class NamedMixin { + @reactive fName: string; + @reactive lName: string; + + @computed get fullName() { + return `${this.fName ?? 'Anon'} ${this.lName ?? 'User'}`; + } + } + + @classMixin(NamedMixin) + class Person { + @reactive age: number; + } + + interface Person extends NamedMixin {} + + const p = new Person(); + + // Before any write, mixin @reactive fields are undefined. + 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/__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/__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/__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/__spec__/deferredIntegration.spec.ts b/src/__spec__/deferredIntegration.spec.ts new file mode 100644 index 0000000..a4955c1 --- /dev/null +++ b/src/__spec__/deferredIntegration.spec.ts @@ -0,0 +1,161 @@ +/* eslint-disable max-classes-per-file */ +import { + describe, it, expect, beforeEach, afterEach, +} from 'vitest'; +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(...)`). 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('defers @reactive install until Vue integration is registered', async () => { + class Counter { + @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(0); + c.count = 7; + expect(c.count).toBe(7); + + // Register the Vue integration. From here on, reactive changes must + // propagate through Vue's effect system. + Madrone.use(MadroneVue); + + const seen: number[] = []; + const stop = watchEffect(() => { + seen.push(c.count); + }); + + 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 nextTick(); + expect(seen).toEqual([7, 9]); + + c.count = 12; + await nextTick(); + expect(seen).toEqual([7, 9, 12]); + + stop(); + }); + + it('defers @computed install until Vue integration is registered', async () => { + class Doubler { + @reactive base = 3; + + @computed get doubled() { + return this.base * 2; + } + } + + const d = new Doubler(); + + // 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); + + // Install the Vue integration. First access after this installs the + // cached reactive computed on the instance and starts tracking. + Madrone.use(MadroneVue); + + 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; + 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(); + }); +}); 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 64c28b5..7f2e82b 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,313 +28,281 @@ import { getIntegration } from '@/global'; import { applyClassMixins } from '@/util'; import { define } from '@/auto'; +import { + computedDescriptor, + deferReactiveInstall, + 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 + * Class decorator that mixes prototype members and decorator metadata from + * other classes into the decorated 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; * - * class Serializable { - * toJSON() { return JSON.stringify(this); } + * touch() { this.createdAt = Date.now(); } * } * - * @classMixin(Timestamped, Serializable) + * interface Model extends Timestamped {} + * + * @classMixin(Timestamped) * class Model { - * name: string; + * @reactive name: string; * } * - * const model = new Model(); - * model.toJSON(); // Works - mixed in from Serializable + * const m = new Model(); + * m.touch(); // mixed-in method + * m.name = 'foo'; // target reactive field * ``` */ export function classMixin(...mixins: Constructor[]) { - return (target: Constructor) => { - if (mixins?.length) { - applyClassMixins(target, mixins); - } - }; -} + return function classMixinDecorator( + target: T, + context: ClassDecoratorContext + ): void { + if (!mixins?.length) return; -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); + // Defer mixin application until after the class body has been fully + // 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); + }); + }; } // //////////////////////////// -// COMPUTED +// REACTIVE // //////////////////////////// -function computedIfNeeded( - target: object, - key: string, - descriptor: PropertyDescriptor, - options?: DecoratorOptionType -): boolean { - const pl = getIntegration(); +export type ReactiveFieldDecorator = ( + value: undefined, + context: ClassFieldDecoratorContext +) => void; - 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); +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, - return true; - } - - return false; + /** + * 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, } -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); +function createReactiveDecorator(options?: DecoratorOptionType): ReactiveFieldDecorator { + return function reactiveDecorator(_value, context): void { + const key = context.name; - return this[key]; - }, - set: function computedSetter(this: Record, val: unknown) { - computedIfNeeded(this, key, descriptor, options); - this[key] = val; - }, - }; + recordMeta(context.metadata, { kind: 'reactive', key, options }); - return newDescriptor; - } + context.addInitializer(function addReactiveInitializer() { + const instance = this as object; - return descriptor; + 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]; + + 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); + } + }); + }; } /** - * 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. - * - * The decorator lazily initializes the computed property on first access, - * so it works correctly with class inheritance and instance creation. + * Decorator that makes a class field reactive. * - * @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 +) => ((this: This) => Value) | 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: (this: This) => Value, + context: ClassGetterDecoratorContext + ): (this: This) => Value { + 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; - } + // 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; - return false; -} + if (!getIntegration()) { + return getter.call(this); + } -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]; - } + 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; - 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. - * - * @param target - The class prototype (or constructor for static properties) - * @param key - The property name - * - * @example - * ```ts - * import { reactive, computed, watch } from '@madronejs/core'; - * - * class User { - * @reactive name = 'Anonymous'; - * @reactive preferences = { theme: 'dark' }; - * - * @computed get greeting() { - * return `Hello, ${this.name}!`; - * } - * } - * - * 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); + return (instance as Record)[key as string] as Value; + }; + }; } /** - * 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. + * Decorator that creates a cached computed property from a getter. * - * @param target - The class prototype - * @param key - The property name + * Cached values are only recalculated when their reactive dependencies change. * * @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 + * class ShoppingCart { + * @reactive items: Array<{ price: number }> = []; * - * @example - * ```ts - * class Example { - * @reactive.configure({ deep: false, enumerable: false }) - * hiddenData = { secret: true }; + * @computed get total() { + * return this.items.reduce((sum, item) => sum + item.price, 0); + * } * } * ``` */ -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/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/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..7738699 --- /dev/null +++ b/src/mixinSupport.ts @@ -0,0 +1,424 @@ +/** + * @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; +} + +/** + * 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] ?? [])]; + } + + return bag[MADRONE_META]; +} + +/** 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); +} + +/** + * 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]; +} + +/** + * 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` + * 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>; + + // 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`. */ +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: true, + 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, + }; +} + +// //////////////////////////// +// 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 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>(); + +/** + * 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; +} + +// 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 = pendingValues.get(instance); + + if (!map) { + map = new Map(); + pendingValues.set(instance, map); + } + + 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 = pendingValues.get(instance); + + if (!map) return undefined; + + const val = map.get(key); + + map.delete(key); + + return val; +} + +function peekPending(instance: object, key: string | symbol): unknown { + return pendingValues.get(instance)?.get(key); +} + +// 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'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 && mixinInstalledFns.has(descriptor.get)) + || (descriptor.set && mixinInstalledFns.has(descriptor.set)) + ); +} + +/** + * 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 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 (existing && !isMixinInstalled(existing)) return; + + const lazyGet = function lazyReactiveGet(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]; + }; + + const lazySet = function lazyReactiveSet(this: object, val: unknown) { + if (!getIntegration()) { + stashPending(this, key, val); + + return; + } + + takePending(this, key); + define(this, key as string, reactiveDescriptor(val, options)); + }; + + markMixinFn(lazyGet); + markMixinFn(lazySet); + + Object.defineProperty(proto, key, { + configurable: true, + enumerable: true, + get: lazyGet, + set: lazySet, + }); +} + +/** + * 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'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, + key: string | symbol, + getter: (this: object) => unknown, + options?: DecoratorOptionType, + explicitSetter?: (this: object, val: unknown) => void +): void { + const existing = Object.getOwnPropertyDescriptor(proto, key); + 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]; + }; + + 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; + }; + + markMixinFn(lazyGet); + markMixinFn(lazySet); + + Object.defineProperty(proto, key, { + configurable: true, + enumerable: false, + get: lazyGet, + set: lazySet, + }); +} diff --git a/src/util.ts b/src/util.ts index 82b75a4..9433fd1 100644 --- a/src/util.ts +++ b/src/util.ts @@ -8,6 +8,9 @@ */ import type { Constructor } from '@/interfaces'; +import { + ensureMadroneMeta, ensureMadroneMetaOnBag, getMadroneMeta, installLazyReactive, installMixinComputed, isMixinInstalled, +} from '@/mixinSupport'; type AnyObject = Record; @@ -97,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 @@ -117,29 +140,100 @@ 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[]): 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 `@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 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]`). 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; + + const resolved = new Map(); + + for (const mixin of mixins) { + for (const entry of getMadroneMeta(mixin) ?? []) { + 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') { + 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 (a `set foo()` + // that pairs with `@computed get foo()`). + 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 }); + } + } } /** @@ -151,8 +245,6 @@ export function applyClassMixins(base: Constructor, mixins: Constructor[]): void * @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, @@ -169,3 +261,69 @@ 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 + * 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. 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"). 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 + * 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; + * } + * ``` + */ +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; +} diff --git a/tsconfig.json b/tsconfig.json index d5d14c5..d2de08c 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,11 +1,11 @@ { "compilerOptions": { - "target": "es2020", + "target": "es2022", "lib": ["esNext", "dom"], "strict": false, "moduleResolution": "bundler", "esModuleInterop": true, - "experimentalDecorators": true, + "useDefineForClassFields": true, "skipLibCheck": true, "outDir": "./dist", "paths": {