From dc651dbcec04f10bdbced4b4ff31162287ea52a8 Mon Sep 17 00:00:00 2001 From: Tyler Reardon Date: Tue, 16 Dec 2025 18:25:18 -0800 Subject: [PATCH 01/22] claude file --- CLAUDE.md | 66 +++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 66 insertions(+) create mode 100644 CLAUDE.md diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..b2b8600 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,66 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Project Overview + +Madrone is an object composition and reactivity framework for JavaScript/TypeScript. It provides reactive state management with support for computed properties, watchers, and integration with Vue 3's reactivity system. + +Documentation: https://madronejs.github.io/docs/core/ + +## Common Commands + +```bash +pnpm test # Run tests in watch mode (vitest) +pnpm test-ci # Run tests once with coverage +pnpm lint # Run ESLint on src/ +pnpm build # Build library with Vite +pnpm build-types # Generate TypeScript declarations +pnpm build-all # Clean, build, and generate types +``` + +Run a single test file: +```bash +pnpm test src/reactivity/__spec__/watcher.spec.ts +``` + +## Architecture + +### Core Layers + +1. **Reactivity System** (`src/reactivity/`) + - `Reactive.ts` - Creates reactive proxies for objects/arrays using `Proxy`. Handles deep reactivity by default. + - `Observer.ts` - The `ObservableItem` class that tracks dependencies and caches computed values. Manages dirty state and change notifications. + - `Computed.ts` - Factory for creating cached computed properties via Observer + - `Watcher.ts` - Watches reactive expressions and calls handlers on change + - `global.ts` - Core dependency tracking infrastructure: proxy/target mappings, observer-to-dependency graphs, and microtask scheduler + +2. **Integration Layer** (`src/integrations/`) + - `MadroneState` - Default integration using Madrone's own reactivity (loaded by default) + - `MadroneVue3` - Integration that bridges Madrone objects with Vue 3's reactivity system + +3. **Public API** (`src/auto.ts`, `src/decorate.ts`, `src/index.ts`) + - `auto()` - Makes all properties on an object reactive/computed automatically + - `define()` - Defines a single reactive or computed property + - `watch()` - Watches reactive expressions + - `@reactive` / `@computed` - Decorators for class-based usage + +### Key Patterns + +**Integration System**: Madrone uses a plugin architecture (`Integration` interface in `interfaces.ts`). Integrations define how reactive/computed properties are created. The last added integration becomes active. + +```typescript +Madrone.use(MadroneVue3({ reactive, toRaw })); // Switch to Vue 3 reactivity +Madrone.unuse(integration); // Remove integration +``` + +**Dependency Tracking**: When a computed runs its getter, `getCurrentObserver()` returns the active Observer. Reactive proxies call `dependTracker()` on property access to register dependencies. When reactive values change, `trackerChanged()` marks dependent observers dirty. + +**Path Alias**: The codebase uses `@/` as an alias for `./src/` (configured in tsconfig.json and vite.config.ts). + +## Test Structure + +Tests use Vitest and are colocated in `__spec__` directories: +- `src/__spec__/` - Tests for decorators, merge utility, examples +- `src/reactivity/__spec__/` - Tests for reactive, observer, watcher +- `src/integrations/__spec__/` - Tests for MadroneState and Vue3 integration From c49ad1b2dc24c5619a13cdca084ec1434cb4602c Mon Sep 17 00:00:00 2001 From: Tyler Reardon Date: Tue, 16 Dec 2025 18:27:27 -0800 Subject: [PATCH 02/22] gitignore --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index fd6ae34..d61228c 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,4 @@ node_modules /types /docs /coverage +.claude From 3c9033a9aac0b4ec0026363b7ed9f89344767518 Mon Sep 17 00:00:00 2001 From: Tyler Reardon Date: Tue, 16 Dec 2025 18:29:26 -0800 Subject: [PATCH 03/22] map/set support --- src/reactivity/__spec__/observer_map.spec.ts | 203 ++++++++ ...rver_set.xspec.ts => observer_set.spec.ts} | 41 ++ src/reactivity/__spec__/reactive_map.spec.ts | 85 ++++ ...tive_set.xspec.ts => reactive_set.spec.ts} | 0 src/reactivity/typeHandlers.ts | 435 +++++++++++++++--- 5 files changed, 707 insertions(+), 57 deletions(-) create mode 100644 src/reactivity/__spec__/observer_map.spec.ts rename src/reactivity/__spec__/{observer_set.xspec.ts => observer_set.spec.ts} (58%) create mode 100644 src/reactivity/__spec__/reactive_map.spec.ts rename src/reactivity/__spec__/{reactive_set.xspec.ts => reactive_set.spec.ts} (100%) diff --git a/src/reactivity/__spec__/observer_map.spec.ts b/src/reactivity/__spec__/observer_map.spec.ts new file mode 100644 index 0000000..3d30e45 --- /dev/null +++ b/src/reactivity/__spec__/observer_map.spec.ts @@ -0,0 +1,203 @@ +import { describe, it, expect } from 'vitest'; +import Observer from '../Observer'; +import Reactive from '../Reactive'; + +describe('map', () => { + it('busts cache on Map set (new key)', () => { + let counter = 0; + const item = new Map(); + const tracked = Reactive(item); + const obs = Observer({ + get: () => { + counter += 1; + return tracked.has('foo'); + }, + }); + + expect(obs.value).toEqual(false); + expect(obs.value).toEqual(false); + expect(counter).toEqual(1); + tracked.set('foo', 'bar'); + expect(obs.value).toEqual(true); + expect(obs.value).toEqual(true); + expect(counter).toEqual(2); + }); + + it('busts cache on Map set (existing key, new value)', () => { + let counter = 0; + const item = new Map([['foo', 'bar']]); + const tracked = Reactive(item); + const obs = Observer({ + get: () => { + counter += 1; + return tracked.get('foo'); + }, + }); + + expect(obs.value).toEqual('bar'); + expect(obs.value).toEqual('bar'); + expect(counter).toEqual(1); + tracked.set('foo', 'baz'); + expect(obs.value).toEqual('baz'); + expect(obs.value).toEqual('baz'); + expect(counter).toEqual(2); + }); + + it('does not bust cache on Map set (same value)', () => { + let counter = 0; + const item = new Map([['foo', 'bar']]); + const tracked = Reactive(item); + const obs = Observer({ + get: () => { + counter += 1; + return tracked.get('foo'); + }, + }); + + expect(obs.value).toEqual('bar'); + expect(counter).toEqual(1); + tracked.set('foo', 'bar'); // same value + expect(obs.value).toEqual('bar'); + expect(counter).toEqual(1); // no change + }); + + it('busts cache on Map delete', () => { + let counter = 0; + const item = new Map([['foo', 'bar']]); + const tracked = Reactive(item); + const obs = Observer({ + get: () => { + counter += 1; + return tracked.has('foo'); + }, + }); + + expect(obs.value).toEqual(true); + expect(counter).toEqual(1); + tracked.delete('foo'); + expect(obs.value).toEqual(false); + expect(counter).toEqual(2); + }); + + it('busts cache on Map clear', () => { + let counter = 0; + const item = new Map([['foo', 'bar']]); + const tracked = Reactive(item); + const obs = Observer({ + get: () => { + counter += 1; + return tracked.has('foo'); + }, + }); + + expect(obs.value).toEqual(true); + expect(counter).toEqual(1); + tracked.clear(); + expect(obs.value).toEqual(false); + expect(counter).toEqual(2); + }); + + it('tracks size changes', () => { + let counter = 0; + const item = new Map(); + const tracked = Reactive(item); + const obs = Observer({ + get: () => { + counter += 1; + return tracked.size; + }, + }); + + expect(obs.value).toEqual(0); + expect(counter).toEqual(1); + tracked.set('foo', 'bar'); + expect(obs.value).toEqual(1); + expect(counter).toEqual(2); + tracked.set('baz', 'qux'); + expect(obs.value).toEqual(2); + expect(counter).toEqual(3); + }); + + it('tracks forEach iteration', () => { + let counter = 0; + const item = new Map([['a', 1], ['b', 2]]); + const tracked = Reactive(item); + const obs = Observer({ + get: () => { + counter += 1; + + let sum = 0; + + for (const [, v] of tracked) { + sum += v; + } + + return sum; + }, + }); + + expect(obs.value).toEqual(3); + expect(counter).toEqual(1); + tracked.set('c', 3); + expect(obs.value).toEqual(6); + expect(counter).toEqual(2); + }); + + it('busts cache on map.keys() iteration', () => { + let counter = 0; + const item = new Map([['a', 1], ['b', 2]]); + const tracked = Reactive(item); + const obs = Observer({ + get: () => { + counter += 1; + + return [...tracked.keys()].join(','); + }, + }); + + expect(obs.value).toEqual('a,b'); + expect(counter).toEqual(1); + tracked.set('c', 3); + expect(obs.value).toEqual('a,b,c'); + expect(counter).toEqual(2); + }); + + it('busts cache on map.values() iteration', () => { + let counter = 0; + const item = new Map([['a', 1], ['b', 2]]); + const tracked = Reactive(item); + const obs = Observer({ + get: () => { + counter += 1; + + return [...tracked.values()].reduce((sum, n) => sum + n, 0); + }, + }); + + expect(obs.value).toEqual(3); + expect(counter).toEqual(1); + tracked.set('c', 3); + expect(obs.value).toEqual(6); + expect(counter).toEqual(2); + }); + + it('busts cache on Array.from(map)', () => { + let counter = 0; + const item = new Map([['a', 1], ['b', 2]]); + const tracked = Reactive(item); + const obs = Observer({ + get: () => { + counter += 1; + + // eslint-disable-next-line unicorn/prefer-spread -- testing Array.from specifically + return Array.from(tracked).length; + }, + }); + + expect(obs.value).toEqual(2); + expect(counter).toEqual(1); + tracked.set('c', 3); + expect(obs.value).toEqual(3); + expect(counter).toEqual(2); + }); +}); diff --git a/src/reactivity/__spec__/observer_set.xspec.ts b/src/reactivity/__spec__/observer_set.spec.ts similarity index 58% rename from src/reactivity/__spec__/observer_set.xspec.ts rename to src/reactivity/__spec__/observer_set.spec.ts index 3708cf0..c163907 100644 --- a/src/reactivity/__spec__/observer_set.xspec.ts +++ b/src/reactivity/__spec__/observer_set.spec.ts @@ -3,6 +3,47 @@ import Observer from '../Observer'; import Reactive from '../Reactive'; describe('set', () => { + it('busts cache on Array.from(set)', () => { + let counter = 0; + const item = new Set(['a', 'b']); + const tracked = Reactive(item); + const obs = Observer({ + get: () => { + counter += 1; + + // eslint-disable-next-line unicorn/prefer-spread -- testing Array.from specifically + return Array.from(tracked).join(','); + }, + }); + + expect(obs.value).toEqual('a,b'); + expect(obs.value).toEqual('a,b'); + expect(counter).toEqual(1); + tracked.add('c'); + expect(obs.value).toEqual('a,b,c'); + expect(obs.value).toEqual('a,b,c'); + expect(counter).toEqual(2); + }); + + it('busts cache on [...set] spread', () => { + let counter = 0; + const item = new Set([1, 2]); + const tracked = Reactive(item); + const obs = Observer({ + get: () => { + counter += 1; + + return [...tracked].reduce((sum, n) => sum + n, 0); + }, + }); + + expect(obs.value).toEqual(3); + expect(counter).toEqual(1); + tracked.add(3); + expect(obs.value).toEqual(6); + expect(counter).toEqual(2); + }); + it('busts cache on Set add', () => { let counter = 0; const item = new Set(); diff --git a/src/reactivity/__spec__/reactive_map.spec.ts b/src/reactivity/__spec__/reactive_map.spec.ts new file mode 100644 index 0000000..9e4f4dc --- /dev/null +++ b/src/reactivity/__spec__/reactive_map.spec.ts @@ -0,0 +1,85 @@ +import { describe, it, expect } from 'vitest'; +import Reactive from '../Reactive'; +import { isReactive } from '../global'; + +describe('Reactive maps', () => { + it('returns reactive values from get', () => { + const map = new Map([['foo', { bar: true }]]); + const obs = Reactive(map); + + expect(isReactive(obs.get('foo'))).toEqual(true); + }); + + it('iterates using reactive forEach', () => { + const map = new Map([ + ['a', { foo: true }], + ['b', { foo: false }], + ]); + const obs = Reactive(map); + + for (const [, value] of obs) { + expect(isReactive(value)).toEqual(true); + } + }); + + it('makes reactive values from values()', () => { + const map = new Map([ + ['a', { foo: true }], + ['b', { foo: false }], + ]); + const obs = Reactive(map); + + for (const value of obs.values()) { + expect(isReactive(value)).toEqual(true); + } + }); + + it('makes reactive values from entries()', () => { + const map = new Map([ + ['a', { foo: true }], + ['b', { foo: false }], + ]); + const obs = Reactive(map); + + for (const [key, value] of obs.entries()) { + expect(typeof key).toEqual('string'); + expect(isReactive(value)).toEqual(true); + } + }); + + it('makes reactive values from Symbol.iterator', () => { + const map = new Map([ + ['a', { foo: true }], + ['b', { foo: false }], + ]); + const obs = Reactive(map); + + for (const [key, value] of obs) { + expect(typeof key).toEqual('string'); + expect(isReactive(value)).toEqual(true); + } + }); + + it('supports chaining with set', () => { + const map = new Map(); + const obs = Reactive(map); + + const result = obs.set('a', 1).set('b', 2).set('c', 3); + + expect(result).toBe(obs); + expect(obs.size).toEqual(3); + }); + + it('does not make primitive values reactive', () => { + const map = new Map([ + ['a', 1], + ['b', 'string'], + ['c', true], + ]); + const obs = Reactive(map); + + expect(obs.get('a')).toEqual(1); + expect(obs.get('b')).toEqual('string'); + expect(obs.get('c')).toEqual(true); + }); +}); diff --git a/src/reactivity/__spec__/reactive_set.xspec.ts b/src/reactivity/__spec__/reactive_set.spec.ts similarity index 100% rename from src/reactivity/__spec__/reactive_set.xspec.ts rename to src/reactivity/__spec__/reactive_set.spec.ts diff --git a/src/reactivity/typeHandlers.ts b/src/reactivity/typeHandlers.ts index 488eb38..7de8a3e 100644 --- a/src/reactivity/typeHandlers.ts +++ b/src/reactivity/typeHandlers.ts @@ -2,14 +2,6 @@ import Reactive from './Reactive'; import { KEYS_SYMBOL, dependTarget, targetChanged } from './global'; import { TypeHandlerOptions, ReactiveOptions } from './interfaces'; -// const wrap = (target, name, cb) => (...args) => { -// const proto = Reflect.getPrototypeOf(target); -// const method = proto[name]; -// const getValue = () => proto[name].call(target, ...args); - -// return cb({ getValue, proto, method, args }); -// }; - const makeOptions = (handlerOptions: TypeHandlerOptions) => { const { name, @@ -145,70 +137,399 @@ const arrayHandler = (options) => ({ ...defaultHandlers(options), }); -// const setHandler = (options) => ({ -// get: (target, key, receiver) => { -// const proto = Reflect.getPrototypeOf(target); +/** + * Wraps a value in a Reactive proxy if deep mode is enabled and the value is an object + */ +const wrapIfDeep = (value: T, options: ReactiveOptions): T => { + if (options?.deep && value && typeof value === 'object') { + return Reactive(value, options); + } + return value; +}; + +/** + * Creates a reactive iterator that wraps yielded values + */ +function *reactiveIterator( + iterator: IterableIterator, + options: ReactiveOptions, + wrapValue: (val: T) => T +): IterableIterator { + for (const item of iterator) { + yield wrapValue(item); + } +} + +const setHandler = (options: ReactiveOptions) => ({ + get: (target: Set, key: PropertyKey, receiver: any) => { + // Handle size property + if (key === 'size') { + dependTarget(target, KEYS_SYMBOL); + options?.onGet?.(makeOptions({ + name: options.name, + target, + key, + })); + + return target.size; + } + + // Handle has - tracks dependency on the specific value + if (key === 'has') { + return (value: any) => { + dependTarget(target, KEYS_SYMBOL); + options?.onGet?.(makeOptions({ + name: options.name, + target, + key, + value, + })); + + return target.has(value); + }; + } + + // Handle add - triggers change + if (key === 'add') { + return (value: any) => { + const hadValue = target.has(value); + + target.add(value); + + if (!hadValue) { + targetChanged(target, KEYS_SYMBOL); + options?.onSet?.(makeOptions({ + name: options.name, + target, + key, + value, + keysChanged: true, + })); + } + + return receiver; // Return proxy for chaining + }; + } + + // Handle delete - triggers change + if (key === 'delete') { + return (value: any) => { + const hadValue = target.has(value); + const deleted = target.delete(value); + + if (hadValue) { + targetChanged(target, KEYS_SYMBOL); + options?.onDelete?.(makeOptions({ + name: options.name, + target, + key, + value, + keysChanged: true, + })); + } + + return deleted; + }; + } + + // Handle clear - triggers change + if (key === 'clear') { + return () => { + const hadValues = target.size > 0; + + target.clear(); + + if (hadValues) { + targetChanged(target, KEYS_SYMBOL); + options?.onDelete?.(makeOptions({ + name: options.name, + target, + key, + keysChanged: true, + })); + } + }; + } + + // Handle forEach - tracks dependency, wraps values + if (key === 'forEach') { + return (cb: (value: any, key: any, set: Set) => void, thisArg?: any) => { + dependTarget(target, KEYS_SYMBOL); + options?.onGet?.(makeOptions({ + name: options.name, + target, + key, + })); -// if (key === 'has') { -// return wrap(target, key, ({ getValue }) => { -// const value = getValue(); + for (const [val1, val2] of target.entries()) { + cb.call(thisArg, wrapIfDeep(val1, options), wrapIfDeep(val2, options), receiver); + } + }; + } -// dependTarget(target); -// options?.onGet?.(makeOptions({ options, target, value })); + // Handle values/keys (they're the same for Set) + if (key === 'values' || key === 'keys') { + return () => { + dependTarget(target, KEYS_SYMBOL); + options?.onGet?.(makeOptions({ + name: options.name, + target, + key, + })); -// if (options?.deep) { -// return Reactive(value, options); -// } + return reactiveIterator(target.values(), options, (v) => wrapIfDeep(v, options)); + }; + } -// return value; -// }); -// } + // Handle entries + if (key === 'entries') { + return () => { + dependTarget(target, KEYS_SYMBOL); + options?.onGet?.(makeOptions({ + name: options.name, + target, + key, + })); -// if (['add', 'clear', 'delete'].includes(key)) { -// const hookName = key === 'add' ? 'onSet' : 'onDelete'; + return reactiveIterator( + target.entries(), + options, + ([v1, v2]) => [wrapIfDeep(v1, options), wrapIfDeep(v2, options)] as [any, any] + ); + }; + } -// return wrap(target, key, ({ getValue }) => { -// const value = getValue(); + // Handle Symbol.iterator + if (key === Symbol.iterator) { + return () => { + dependTarget(target, KEYS_SYMBOL); + options?.onGet?.(makeOptions({ + name: options.name, + target, + key, + })); -// targetChanged(target); -// options?.[hookName]?.(makeOptions({ options, target, value })); + return reactiveIterator(target[Symbol.iterator](), options, (v) => wrapIfDeep(v, options)); + }; + } -// return value; -// }); -// } + // Handle Symbol.toStringTag + if (key === Symbol.toStringTag) { + return 'Set'; + } -// if (key === 'forEach') { -// return wrap(target, key, ({ method, args }) => { -// const [cb] = args; + return Reflect.get(target, key, receiver); + }, +}); -// dependTarget(target); +const mapHandler = (options: ReactiveOptions) => ({ + get: (target: Map, key: PropertyKey, receiver: any) => { + // Handle size property + if (key === 'size') { + dependTarget(target, KEYS_SYMBOL); + options?.onGet?.(makeOptions({ + name: options.name, + target, + key, + })); -// method.call(target, (val1, val2, theSet) => { -// cb( -// Reactive(val1, options), -// Reactive(val2, options), -// Reactive(theSet, options) -// ); -// }); -// }); -// } + return target.size; + } -// if (key in proto) { -// return proto[key].bind(target); -// } + // Handle has - tracks dependency on the key + if (key === 'has') { + return (mapKey: any) => { + dependTarget(target, mapKey); + options?.onGet?.(makeOptions({ + name: options.name, + target, + key: mapKey, + })); -// return Reflect.get(target, key, receiver); -// }, -// }); -// const mapHandler = (options) => {}; -// const weaksetHandler = (options) => {}; -// const weakmapHandler = (options) => {}; + return target.has(mapKey); + }; + } + + // Handle get - tracks dependency on the key + if (key === 'get') { + return (mapKey: any) => { + dependTarget(target, mapKey); + options?.onGet?.(makeOptions({ + name: options.name, + target, + key: mapKey, + })); + + return wrapIfDeep(target.get(mapKey), options); + }; + } + + // Handle set - triggers change + if (key === 'set') { + return (mapKey: any, value: any) => { + const hadKey = target.has(mapKey); + const oldValue = target.get(mapKey); + + target.set(mapKey, value); + + if (!hadKey) { + targetChanged(target, KEYS_SYMBOL); + } + + if (!hadKey || oldValue !== value) { + targetChanged(target, mapKey); + options?.onSet?.(makeOptions({ + name: options.name, + target, + key: mapKey, + value, + keysChanged: !hadKey, + valueChanged: oldValue !== value, + })); + } + + return receiver; // Return proxy for chaining + }; + } + + // Handle delete - triggers change + if (key === 'delete') { + return (mapKey: any) => { + const hadKey = target.has(mapKey); + const deleted = target.delete(mapKey); + + if (hadKey) { + targetChanged(target, KEYS_SYMBOL); + targetChanged(target, mapKey); + options?.onDelete?.(makeOptions({ + name: options.name, + target, + key: mapKey, + keysChanged: true, + })); + } + + return deleted; + }; + } + + // Handle clear - triggers change + if (key === 'clear') { + return () => { + const keys = [...target.keys()]; + const hadValues = target.size > 0; + + target.clear(); + + if (hadValues) { + targetChanged(target, KEYS_SYMBOL); + + for (const k of keys) { + targetChanged(target, k); + } + + options?.onDelete?.(makeOptions({ + name: options.name, + target, + key, + keysChanged: true, + })); + } + }; + } + + // Handle forEach - tracks dependency, wraps values + if (key === 'forEach') { + return (cb: (value: any, key: any, map: Map) => void, thisArg?: any) => { + dependTarget(target, KEYS_SYMBOL); + options?.onGet?.(makeOptions({ + name: options.name, + target, + key, + })); + + for (const [mapKey, value] of target.entries()) { + cb.call(thisArg, wrapIfDeep(value, options), mapKey, receiver); + } + }; + } + + // Handle keys + if (key === 'keys') { + return () => { + dependTarget(target, KEYS_SYMBOL); + options?.onGet?.(makeOptions({ + name: options.name, + target, + key, + })); + + return target.keys(); + }; + } + + // Handle values + if (key === 'values') { + return () => { + dependTarget(target, KEYS_SYMBOL); + options?.onGet?.(makeOptions({ + name: options.name, + target, + key, + })); + + return reactiveIterator(target.values(), options, (v) => wrapIfDeep(v, options)); + }; + } + + // Handle entries + if (key === 'entries') { + return () => { + dependTarget(target, KEYS_SYMBOL); + options?.onGet?.(makeOptions({ + name: options.name, + target, + key, + })); + + return reactiveIterator( + target.entries(), + options, + ([k, v]) => [k, wrapIfDeep(v, options)] as [any, any] + ); + }; + } + + // Handle Symbol.iterator + if (key === Symbol.iterator) { + return () => { + dependTarget(target, KEYS_SYMBOL); + options?.onGet?.(makeOptions({ + name: options.name, + target, + key, + })); + + return reactiveIterator( + target[Symbol.iterator](), + options, + ([k, v]) => [k, wrapIfDeep(v, options)] as [any, any] + ); + }; + } + + // Handle Symbol.toStringTag + if (key === Symbol.toStringTag) { + return 'Map'; + } + + return Reflect.get(target, key, receiver); + }, +}); export default Object.freeze({ object: objectHandler, array: arrayHandler, - // set: setHandler, - // map: mapHandler, - // weakset: weaksetHandler, - // weakmap: weakmapHandler, + set: setHandler, + map: mapHandler, }); From a222b4b396d5dccda26aba78a6540660a402e7cc Mon Sep 17 00:00:00 2001 From: Tyler Reardon Date: Tue, 16 Dec 2025 19:32:47 -0800 Subject: [PATCH 04/22] types --- src/decorate.ts | 60 +++++++++++++------------- src/integrations/MadroneState.ts | 53 +++++++++++++++-------- src/interfaces.ts | 25 ++++++----- src/reactivity/global.ts | 72 +++++++++++++++++--------------- src/reactivity/interfaces.ts | 22 +++++----- src/reactivity/typeHandlers.ts | 42 +++++++++---------- src/util.ts | 25 +++++++---- 7 files changed, 165 insertions(+), 134 deletions(-) diff --git a/src/decorate.ts b/src/decorate.ts index a33d326..56a6760 100644 --- a/src/decorate.ts +++ b/src/decorate.ts @@ -3,28 +3,31 @@ import { applyClassMixins } from '@/util'; import { define } from '@/auto'; import { DecoratorOptionType, DecoratorDescriptorType } from './interfaces'; -const itemMap: WeakMap> = new WeakMap(); +type Constructor = new (...args: unknown[]) => object; -export function classMixin(...mixins: Array<() => any>) { - return (target: () => any) => { +const itemMap = new WeakMap>(); + +export function classMixin(...mixins: Constructor[]) { + return (target: Constructor) => { if (mixins?.length) { applyClassMixins(target, mixins); } }; } -function trackTargetIfNeeded(target) { +function trackTargetIfNeeded(target: object): void { if (!itemMap.has(target)) { itemMap.set(target, new Set()); } } -function checkTargetObserved(target, key) { +function checkTargetObserved(target: object, key: string): boolean { trackTargetIfNeeded(target); + return itemMap.get(target).has(key); } -function setTargetObserved(target, key) { +function setTargetObserved(target: object, key: string): void { trackTargetIfNeeded(target); itemMap.get(target).add(key); } @@ -34,11 +37,11 @@ function setTargetObserved(target, key) { // //////////////////////////// function computedIfNeeded( - target: any, + target: object, key: string, descriptor: PropertyDescriptor, options?: DecoratorOptionType -) { +): boolean { const pl = getIntegration(); if (pl && !checkTargetObserved(target, key)) { @@ -51,6 +54,7 @@ function computedIfNeeded( cache: true, }); setTargetObserved(target, key); + return true; } @@ -58,24 +62,25 @@ function computedIfNeeded( } function decorateComputed( - target: any, + target: object, key: string, descriptor: PropertyDescriptor, options?: DecoratorOptionType -) { +): PropertyDescriptor { if (typeof descriptor.get === 'function') { - const newDescriptor = { + const newDescriptor: PropertyDescriptor = { ...descriptor, enumerable: true, configurable: true, }; - newDescriptor.get = function computedGetter() { + newDescriptor.get = function computedGetter(this: Record) { computedIfNeeded(this, key, descriptor, options); + return this[key]; }; - newDescriptor.set = function computedSetter(val) { + newDescriptor.set = function computedSetter(this: Record, val: unknown) { computedIfNeeded(this, key, descriptor, options); this[key] = val; }; @@ -93,12 +98,12 @@ function decorateComputed( * @param descriptor property descriptors * @returns the modified property descriptors */ -export function computed(target: any, key: string, descriptor: PropertyDescriptor) { +export function computed(target: object, key: string, descriptor: PropertyDescriptor): PropertyDescriptor { return decorateComputed(target, key, descriptor); } computed.configure = function configureComputed(descriptorOverrides: DecoratorDescriptorType) { - return (target: any, key: string, descriptor: PropertyDescriptor) => decorateComputed( + return (target: object, key: string, descriptor: PropertyDescriptor) => decorateComputed( target, key, descriptor, @@ -110,7 +115,7 @@ computed.configure = function configureComputed(descriptorOverrides: DecoratorDe // REACTIVE // //////////////////////////// -function reactiveIfNeeded(target: any, key: string, options?: DecoratorOptionType) { +function reactiveIfNeeded(target: object, key: string, options?: DecoratorOptionType): boolean { const pl = getIntegration(); if (pl && !checkTargetObserved(target, key)) { @@ -120,13 +125,14 @@ function reactiveIfNeeded(target: any, key: string, options?: DecoratorOptionTyp enumerable: true, ...options?.descriptors, }); + return true; } return false; } -function decorateReactive(target: any, key: string, options?: DecoratorOptionType) { +function decorateReactive(target: object, key: string, options?: DecoratorOptionType): void { if (typeof target === 'function') { // handle the static case reactiveIfNeeded(target, key); @@ -135,14 +141,14 @@ function decorateReactive(target: any, key: string, options?: DecoratorOptionTyp Object.defineProperty(target, key, { configurable: true, enumerable: true, - get() { + get(this: Record) { if (reactiveIfNeeded(this, key, options)) { return this[key]; } return undefined; }, - set(val) { + set(this: Record, val: unknown) { if (reactiveIfNeeded(this, key, options)) { this[key] = val; } @@ -151,29 +157,19 @@ function decorateReactive(target: any, key: string, options?: DecoratorOptionTyp } } -// eslint-disable-next-line @typescript-eslint/no-unsafe-function-type -interface reactive extends Function { - /** Create a shallow reactive property */ - shallow: (target: any, key: string) => ReturnType, - /** Configure the descriptors for a property */ - configure: ( - overrides: DecoratorDescriptorType - ) => (target: any, key: string) => ReturnType, -} - /** * Configure a reactive property * @param target The target to add the reactive property to * @param key The name of the reactive property */ -export function reactive(target: any, key: string) { +export function reactive(target: object, key: string): void { return decorateReactive(target, key); } -reactive.shallow = function configureReactive(target: any, key: string) { +reactive.shallow = function configureReactive(target: object, key: string): void { return decorateReactive(target, key, { descriptors: { deep: false } }); }; reactive.configure = function configureReactive(descriptorOverrides: DecoratorDescriptorType) { - return (target: any, key: string) => decorateReactive(target, key, { descriptors: descriptorOverrides }); + return (target: object, key: string) => decorateReactive(target, key, { descriptors: descriptorOverrides }); }; diff --git a/src/integrations/MadroneState.ts b/src/integrations/MadroneState.ts index 06a2d7e..392119c 100644 --- a/src/integrations/MadroneState.ts +++ b/src/integrations/MadroneState.ts @@ -1,28 +1,33 @@ import { objectAccessed } from '@/global'; -import { Integration, MadroneComputedDescriptor, MadronePropertyDescriptor } from '@/interfaces'; +import { + Integration, + IntegrationOptions, + MadroneComputedDescriptor, + MadronePropertyDescriptor, +} from '@/interfaces'; import { Computed, Reactive, Watcher, toRaw, } from '@/reactivity'; import { ReactiveOptions } from '@/reactivity/interfaces'; import { ObservableHooksType } from '@/reactivity/Observer'; -type MadroneStateOptions = { +export type MadroneStateOptions = { reactive?: ReactiveOptions, computed?: ObservableHooksType, }; -export function describeComputed( +export function describeComputed( name: string, config: MadroneComputedDescriptor, options?: MadroneStateOptions -) { - let getter; - let setter; +): PropertyDescriptor { + let getter: () => T; + let setter: (val: T) => void; if (config.cache) { const cp = Computed({ ...config, - get: config.get, + get: config.get as () => T, name, onImmediateChange: options?.computed?.onImmediateChange, onChange: options?.computed?.onChange, @@ -30,19 +35,21 @@ export function describeComputed( onSet: options?.computed?.onSet, }); - getter = function get() { + getter = function get(this: object) { objectAccessed(this); + return cp.value; }; - setter = function set(val) { + setter = function set(val: T) { cp.value = val; }; } else { - getter = function get() { + getter = function get(this: object) { objectAccessed(this); + return config.get.call(this); }; - setter = function set(...args) { + setter = function set(this: object, ...args: [T]) { config.set.call(this, ...args); }; } @@ -59,7 +66,7 @@ export function describeProperty( name: string, config: MadronePropertyDescriptor, options?: MadroneStateOptions -) { +): PropertyDescriptor { const tg = { value: config.value }; const atom = Reactive(tg, { name, @@ -74,7 +81,7 @@ export function describeProperty( return { configurable: config.configurable, enumerable: config.enumerable, - get: function get() { + get: function get(this: object) { objectAccessed(this); const { value: atomVal } = atom; @@ -86,18 +93,28 @@ export function describeProperty( return atomVal; }, - set: function set(val) { + set: function set(val: unknown) { atom.value = val; }, }; } -export function defineComputed(target, name: string, config: MadroneComputedDescriptor, options) { - Object.defineProperty(target, name, describeComputed(name, config, options)); +export function defineComputed( + target: object, + name: string, + config: MadroneComputedDescriptor, + options?: IntegrationOptions +): void { + Object.defineProperty(target, name, describeComputed(name, config, options as MadroneStateOptions)); } -export function defineProperty(target, name: string, config: MadronePropertyDescriptor, options?) { - Object.defineProperty(target, name, describeProperty(name, config, options)); +export function defineProperty( + target: object, + name: string, + config: MadronePropertyDescriptor, + options?: IntegrationOptions +): void { + Object.defineProperty(target, name, describeProperty(name, config, options as MadroneStateOptions)); } const MadroneState: Integration = { diff --git a/src/interfaces.ts b/src/interfaces.ts index f68c0a1..290e046 100644 --- a/src/interfaces.ts +++ b/src/interfaces.ts @@ -26,33 +26,38 @@ export type WatcherOptions = { immediate?: boolean, }; +export interface IntegrationOptions { + reactive?: unknown, + computed?: unknown, +} + export interface Integration { defineProperty: ( - target: any, + target: object, name: string, config: MadronePropertyDescriptor, - options?: any - ) => any, + options?: IntegrationOptions + ) => void, defineComputed: ( - target: any, + target: object, name: string, config: MadroneComputedDescriptor, - options?: any - ) => any, + options?: IntegrationOptions + ) => void, toRaw?: (target: T) => T, watch?: ( - scope: () => any, - handler: (val: T, old?: T) => any, + scope: () => T, + handler: (val: T, old?: T) => void, options?: WatcherOptions ) => () => void, describeComputed?: ( name: string, config: MadroneComputedDescriptor, - options?: any + options?: IntegrationOptions ) => PropertyDescriptor, describeProperty?: ( name: string, config: MadronePropertyDescriptor, - options?: any + options?: IntegrationOptions ) => PropertyDescriptor, } diff --git a/src/reactivity/global.ts b/src/reactivity/global.ts index 1c92ae2..2305414 100644 --- a/src/reactivity/global.ts +++ b/src/reactivity/global.ts @@ -4,43 +4,50 @@ import { getCurrentObserver, ObservableItem } from './Observer'; export const KEYS_SYMBOL = Symbol('keys'); export const OBSERVER_SYMBOL = Symbol('computed'); +type DependencyKey = string | symbol | ObservableItem; + /** Mapping from target object to its proxy */ -const TARGET_TO_PROXY = new WeakMap(); +const TARGET_TO_PROXY = new WeakMap(); /** Mapping from proxy to the object it proxies */ -const PROXY_TO_TARGET = new WeakMap(); +const PROXY_TO_TARGET = new WeakMap(); /** Mapping from proxy to the observers that depend on it */ const PROXY_TO_OBSERVERS = new WeakMap< object, - Map, Set>> + Map>> >(); /** Mapping from observer to its dependencies */ const OBSERVER_TO_PROXIES = new WeakMap< - ObservableItem, - Map, Set> + ObservableItem, + Map> >(); /** List of scheduled tasks */ let TASK_QUEUE: (() => void)[] = []; /** The id of the timeout that will handle all scheduled tasks */ -let SCHEDULER_ID = null; +let SCHEDULER_ID: symbol | null = null; /** Check if the current target has a proxy associated with it */ -export const isReactiveTarget = (target) => TARGET_TO_PROXY.has(target); +export const isReactiveTarget = (target: object): boolean => TARGET_TO_PROXY.has(target); /** Check if the current proxy has a target object */ -export const isReactive = (trk) => PROXY_TO_TARGET.has(trk); -export const getReactive = (target) => TARGET_TO_PROXY.get(target); -export const getTarget = (tracker) => PROXY_TO_TARGET.get(tracker); -export const getProxy = (targetOrProxy) => (isReactive(targetOrProxy) ? targetOrProxy : getReactive(targetOrProxy)); -export const toRaw = (targetOrProxy) => (isReactive(targetOrProxy) ? getTarget(targetOrProxy) : targetOrProxy); - -export const getDependencies = (observer) => OBSERVER_TO_PROXIES.get(observer); +export const isReactive = (trk: object): boolean => PROXY_TO_TARGET.has(trk); +export const getReactive = (target: T): T | undefined => TARGET_TO_PROXY.get(target) as T; +export const getTarget = (tracker: T): T | undefined => PROXY_TO_TARGET.get(tracker) as T; +export const getProxy = (targetOrProxy: T): T | undefined => ( + isReactive(targetOrProxy) ? targetOrProxy : getReactive(targetOrProxy) +); +export const toRaw = (targetOrProxy: T): T => ( + isReactive(targetOrProxy) ? getTarget(targetOrProxy) : targetOrProxy +); + +export const getDependencies = (observer: ObservableItem) => OBSERVER_TO_PROXIES.get(observer); /** Get the list of items that are observing a given proxy */ -export const getObservers = (tracker) => PROXY_TO_OBSERVERS.get(getProxy(tracker)); +export const getObservers = (tracker: object) => PROXY_TO_OBSERVERS.get(getProxy(tracker)); -export const addReactive = (target, proxy) => { +export const addReactive = (target: T, proxy: T): void => { TARGET_TO_PROXY.set(target, proxy); PROXY_TO_TARGET.set(proxy, target); }; -const doTasksIfNeeded = () => { + +const doTasksIfNeeded = (): void => { if (SCHEDULER_ID === null) { SCHEDULER_ID = Symbol('scheduler'); setTimeout(() => { @@ -56,27 +63,27 @@ const doTasksIfNeeded = () => { }); } }; -export const schedule = (task) => { + +export const schedule = (task: () => void): void => { TASK_QUEUE.push(task); doTasksIfNeeded(); }; /** * Clear all of the current dependencies an observer has - * @param {Observable} obs the observable to clear it's dependencies - * @param {String} key the key to clear - * @returns {void} + * @param obs the observable to clear it's dependencies + * @param key the key to clear */ export const observerClear = ( - obs: ObservableItem, - key: string | symbol | ObservableItem -) => { + obs: ObservableItem, + key: DependencyKey +): void => { const proxies = OBSERVER_TO_PROXIES.get(obs); const trackers = proxies?.get(key); if (trackers) { for (const trk of trackers) { - PROXY_TO_OBSERVERS.get(trk).delete(obs); + PROXY_TO_OBSERVERS.get(trk)?.delete(obs); } trackers.clear(); @@ -90,11 +97,10 @@ export const observerClear = ( /** * Make an observer depend on a trackable item - * @param {trackable} trk the trackable item we're depending on - * @param {String} key the key to depend on - * @returns {void} + * @param trk the trackable item we're depending on + * @param key the key to depend on */ -export const dependTracker = (trk: object, key: string | symbol | ObservableItem) => { +export const dependTracker = (trk: object, key: DependencyKey): void => { const current = getCurrentObserver(); if (!current) return; @@ -129,9 +135,8 @@ export const dependTracker = (trk: object, key: string | symbol | ObservableItem * Make an observer depend on a raw target * @param target the target to depend on * @param key the key to depend on - * @returns {void} */ -export const dependTarget = (target: object, key: string | symbol) => { +export const dependTarget = (target: object, key: string | symbol): void => { const trk = getReactive(target); if (trk) { @@ -143,9 +148,8 @@ export const dependTarget = (target: object, key: string | symbol) => { * Tell all observers of a trackable that the trackable changed * @param trk the trackable that changed * @param key the key on the trackable that changed - * @return {void} */ -export const trackerChanged = (trk, key) => { +export const trackerChanged = (trk: object, key: DependencyKey): void => { const observers = PROXY_TO_OBSERVERS.get(trk); if (observers?.get(key)) { @@ -164,6 +168,6 @@ export const trackerChanged = (trk, key) => { * @param target the target that changed * @param key the key on the trackable that changed */ -export const targetChanged = (target: any, key: string | symbol) => { +export const targetChanged = (target: object, key: string | symbol): void => { trackerChanged(getReactive(target), key); }; diff --git a/src/reactivity/interfaces.ts b/src/reactivity/interfaces.ts index 9a4c646..a74a327 100644 --- a/src/reactivity/interfaces.ts +++ b/src/reactivity/interfaces.ts @@ -1,22 +1,22 @@ -export type TypeHandlerOptions = { +export type TypeHandlerOptions = { name?: string, deep?: boolean, - receiver?: any, + receiver?: T, target?: T, - key?: keyof T, - value?: T[keyof T], + key?: PropertyKey, + value?: unknown, keysChanged?: boolean, valueChanged?: boolean, }; -export type HandlerHookType = (options: TypeHandlerOptions) => void; -export type CheckProxyHookType = (options: { +export type HandlerHookType = (options: TypeHandlerOptions) => void; +export type CheckProxyHookType = (options: { target: T, - key: keyof T, - value: T[keyof T], + key: PropertyKey, + value: unknown, }) => boolean; -export type ReactiveHandlerHooks = { +export type ReactiveHandlerHooks = { onGet: HandlerHookType, onSet: HandlerHookType, onDelete: HandlerHookType, @@ -24,7 +24,7 @@ export type ReactiveHandlerHooks = { needsProxy: CheckProxyHookType, }; -export type ReactiveOptions = { - name?: any, +export type ReactiveOptions = { + name?: string, deep?: boolean, } & Partial>; diff --git a/src/reactivity/typeHandlers.ts b/src/reactivity/typeHandlers.ts index 7de8a3e..ccb6b94 100644 --- a/src/reactivity/typeHandlers.ts +++ b/src/reactivity/typeHandlers.ts @@ -110,7 +110,7 @@ function defaultHandlers(options: ReactiveOptions) { return value; }, - set: (target: object, propertyKey: PropertyKey, value: any) => { + set: (target: object, propertyKey: PropertyKey, value: unknown) => { optionSet(options, target, propertyKey, value); return Reflect.set(target, propertyKey, value); }, @@ -161,7 +161,7 @@ function *reactiveIterator( } const setHandler = (options: ReactiveOptions) => ({ - get: (target: Set, key: PropertyKey, receiver: any) => { + get: (target: Set, key: PropertyKey, receiver: object) => { // Handle size property if (key === 'size') { dependTarget(target, KEYS_SYMBOL); @@ -176,7 +176,7 @@ const setHandler = (options: ReactiveOptions) => ({ // Handle has - tracks dependency on the specific value if (key === 'has') { - return (value: any) => { + return (value: unknown) => { dependTarget(target, KEYS_SYMBOL); options?.onGet?.(makeOptions({ name: options.name, @@ -191,7 +191,7 @@ const setHandler = (options: ReactiveOptions) => ({ // Handle add - triggers change if (key === 'add') { - return (value: any) => { + return (value: unknown) => { const hadValue = target.has(value); target.add(value); @@ -213,7 +213,7 @@ const setHandler = (options: ReactiveOptions) => ({ // Handle delete - triggers change if (key === 'delete') { - return (value: any) => { + return (value: unknown) => { const hadValue = target.has(value); const deleted = target.delete(value); @@ -253,7 +253,7 @@ const setHandler = (options: ReactiveOptions) => ({ // Handle forEach - tracks dependency, wraps values if (key === 'forEach') { - return (cb: (value: any, key: any, set: Set) => void, thisArg?: any) => { + return (cb: (value: unknown, key: unknown, set: Set) => void, thisArg?: unknown) => { dependTarget(target, KEYS_SYMBOL); options?.onGet?.(makeOptions({ name: options.name, @@ -323,7 +323,7 @@ const setHandler = (options: ReactiveOptions) => ({ }); const mapHandler = (options: ReactiveOptions) => ({ - get: (target: Map, key: PropertyKey, receiver: any) => { + get: (target: Map, key: PropertyKey, receiver: object) => { // Handle size property if (key === 'size') { dependTarget(target, KEYS_SYMBOL); @@ -338,12 +338,12 @@ const mapHandler = (options: ReactiveOptions) => ({ // Handle has - tracks dependency on the key if (key === 'has') { - return (mapKey: any) => { - dependTarget(target, mapKey); + return (mapKey: unknown) => { + dependTarget(target, mapKey as string | symbol); options?.onGet?.(makeOptions({ name: options.name, target, - key: mapKey, + key: mapKey as PropertyKey, })); return target.has(mapKey); @@ -352,12 +352,12 @@ const mapHandler = (options: ReactiveOptions) => ({ // Handle get - tracks dependency on the key if (key === 'get') { - return (mapKey: any) => { - dependTarget(target, mapKey); + return (mapKey: unknown) => { + dependTarget(target, mapKey as string | symbol); options?.onGet?.(makeOptions({ name: options.name, target, - key: mapKey, + key: mapKey as PropertyKey, })); return wrapIfDeep(target.get(mapKey), options); @@ -366,7 +366,7 @@ const mapHandler = (options: ReactiveOptions) => ({ // Handle set - triggers change if (key === 'set') { - return (mapKey: any, value: any) => { + return (mapKey: unknown, value: unknown) => { const hadKey = target.has(mapKey); const oldValue = target.get(mapKey); @@ -377,11 +377,11 @@ const mapHandler = (options: ReactiveOptions) => ({ } if (!hadKey || oldValue !== value) { - targetChanged(target, mapKey); + targetChanged(target, mapKey as string | symbol); options?.onSet?.(makeOptions({ name: options.name, target, - key: mapKey, + key: mapKey as PropertyKey, value, keysChanged: !hadKey, valueChanged: oldValue !== value, @@ -394,17 +394,17 @@ const mapHandler = (options: ReactiveOptions) => ({ // Handle delete - triggers change if (key === 'delete') { - return (mapKey: any) => { + return (mapKey: unknown) => { const hadKey = target.has(mapKey); const deleted = target.delete(mapKey); if (hadKey) { targetChanged(target, KEYS_SYMBOL); - targetChanged(target, mapKey); + targetChanged(target, mapKey as string | symbol); options?.onDelete?.(makeOptions({ name: options.name, target, - key: mapKey, + key: mapKey as PropertyKey, keysChanged: true, })); } @@ -425,7 +425,7 @@ const mapHandler = (options: ReactiveOptions) => ({ targetChanged(target, KEYS_SYMBOL); for (const k of keys) { - targetChanged(target, k); + targetChanged(target, k as string | symbol); } options?.onDelete?.(makeOptions({ @@ -440,7 +440,7 @@ const mapHandler = (options: ReactiveOptions) => ({ // Handle forEach - tracks dependency, wraps values if (key === 'forEach') { - return (cb: (value: any, key: any, map: Map) => void, thisArg?: any) => { + return (cb: (value: unknown, key: unknown, map: Map) => void, thisArg?: unknown) => { dependTarget(target, KEYS_SYMBOL); options?.onGet?.(makeOptions({ name: options.name, diff --git a/src/util.ts b/src/util.ts index 93efd1d..814ade5 100644 --- a/src/util.ts +++ b/src/util.ts @@ -1,5 +1,7 @@ +type AnyObject = Record; + type OptionalPropertyNames = { - [K in keyof T]-?: any extends { [P in K]: T[K] } ? K : never; + [K in keyof T]-?: object extends { [P in K]: T[K] } ? K : never; }[keyof T]; type SpreadProperties = { @@ -15,8 +17,10 @@ export type SpreadTwo = Id< & SpreadProperties & keyof L> >; -export type Spread = A extends [infer L, ...infer R] - ? SpreadTwo any ? ReturnType : L, Spread> +type ObjectOrFactory = object | ((...args: unknown[]) => object); + +export type Spread = A extends [infer L, ...infer R] + ? SpreadTwo infer RT ? RT : L, Spread> : unknown; /** @@ -24,12 +28,12 @@ export type Spread = A extends [infer L, ...infer R * @param types * @returns The new object definition */ -export function merge any))[]>(...types: [...A]) { +export function merge(...types: [...A]): Spread { const defs = {} as PropertyDescriptorMap; const newVal = {}; for (const type of types) { - const theType = typeof type === 'function' ? type() : type; + const theType = typeof type === 'function' ? (type as () => object)() : type; Object.assign(defs, Object.getOwnPropertyDescriptors(theType ?? type ?? {})); } @@ -39,25 +43,30 @@ export function merge any))[]>(...types: [...A] return newVal as Spread; } +type Constructor = new (...args: unknown[]) => object; + /** * Extend the prototype of a base class with the prototypes of other classes. Mutates the base class. * @param base Base class that the mixins will be mixed into. Any naming conflicts will prefer this base class. * @param constructors List of mixin classes that will be applied to the base class. */ -export function applyClassMixins(base: any, mixins: [...any]) { +export function applyClassMixins(base: Constructor, mixins: Constructor[]): void { Object.defineProperties( base.prototype, Object.getOwnPropertyDescriptors(merge(...[...mixins, base].map((item) => item.prototype))) ); } -export function getDefaultDescriptors(obj, defaults?) { +export function getDefaultDescriptors( + obj: object, + defaults?: Partial +): PropertyDescriptorMap { const descriptors = Object.getOwnPropertyDescriptors(obj); const newDefaults = { configurable: true, enumerable: false, ...defaults }; for (const key of Object.keys(descriptors)) { for (const [descKey, descValue] of Object.entries(newDefaults)) { - descriptors[key][descKey] = descValue; + (descriptors[key] as AnyObject)[descKey] = descValue; } } From 2bb423b62b7b56c510e26c5fd41986be97d9866e Mon Sep 17 00:00:00 2001 From: Tyler Reardon Date: Wed, 17 Dec 2025 10:01:04 -0800 Subject: [PATCH 05/22] documentation --- src/auto.ts | 100 ++++++++++++++++- src/decorate.ts | 182 +++++++++++++++++++++++++++++-- src/global.ts | 159 +++++++++++++++++++++++++-- src/integrations/MadroneState.ts | 93 ++++++++++++++++ src/integrations/MadroneVue3.ts | 71 +++++++++++- src/interfaces.ts | 164 +++++++++++++++++++++++++++- src/reactivity/Computed.ts | 50 ++++++++- src/reactivity/Observer.ts | 101 ++++++++++++++++- src/reactivity/Reactive.ts | 69 ++++++++++-- src/reactivity/Watcher.ts | 64 +++++++++-- src/reactivity/global.ts | 61 +++++++++-- src/reactivity/interfaces.ts | 44 ++++++++ src/util.ts | 109 +++++++++++++++++- 13 files changed, 1202 insertions(+), 65 deletions(-) diff --git a/src/auto.ts b/src/auto.ts index d7ce8a8..b6b24e0 100644 --- a/src/auto.ts +++ b/src/auto.ts @@ -1,7 +1,28 @@ import { getIntegration } from '@/global'; import { MadroneDescriptor, WatcherOptions } from '@/interfaces'; -export function define(obj: T, key: string, descriptor: MadroneDescriptor) { +/** + * Defines a single reactive or computed property on an object. + * + * If the descriptor has a `get` function, it creates a computed property that + * automatically tracks dependencies and caches its result. Otherwise, it creates + * a reactive property that triggers updates when changed. + * + * @param obj - The target object to define the property on + * @param key - The property name to define + * @param descriptor - Configuration for the property including getter/setter or value + * @throws Error if no integration is configured (call `Madrone.use()` first) + * + * @example + * ```ts + * const state = { count: 0 }; + * define(state, 'doubled', { + * get() { return this.count * 2; }, + * cache: true + * }); + * ``` + */ +export function define(obj: T, key: string, descriptor: MadroneDescriptor): void { const pl = getIntegration(); if (!pl) { @@ -26,10 +47,43 @@ export function define(obj: T, key: string, descriptor: Madron } } +/** + * Automatically makes all properties on an object reactive. + * + * Iterates through all own properties of the object and converts them: + * - Properties with getters become cached computed properties + * - Regular properties become deeply reactive by default + * + * This is the primary way to create reactive state in Madrone. + * + * @param obj - The object to make reactive + * @param objDescriptors - Optional per-property configuration overrides + * @returns The same object, now with reactive properties + * + * @example + * ```ts + * const state = auto({ + * count: 0, + * get doubled() { return this.count * 2; } + * }); + * + * state.count = 5; + * console.log(state.doubled); // 10 + * ``` + * + * @example + * ```ts + * // With descriptor overrides + * const state = auto( + * { items: [] }, + * { items: { deep: false } } // Shallow reactivity for items + * ); + * ``` + */ export function auto( obj: T, objDescriptors?: { [K in keyof T]?: MadroneDescriptor } -) { +): T { const descriptors = Object.getOwnPropertyDescriptors(obj); const getDesc = (name: string, descName: keyof MadroneDescriptor) => objDescriptors?.[name]?.[descName]; @@ -48,11 +102,49 @@ export function auto( return obj as T; } +/** + * Watches a reactive expression and calls a handler when its value changes. + * + * The `scope` function is called immediately to establish dependencies. + * Whenever any reactive property accessed within `scope` changes, the + * function re-runs and `handler` is called with the new and old values. + * + * @param scope - A function that returns the value to watch. All reactive + * properties accessed within this function become dependencies. + * @param handler - Callback invoked when the watched value changes + * @param options - Optional configuration + * @param options.immediate - If true, calls handler immediately with current value + * @returns A disposer function that stops watching when called + * + * @example + * ```ts + * const state = auto({ count: 0 }); + * + * const stop = watch( + * () => state.count, + * (newVal, oldVal) => console.log(`Changed from ${oldVal} to ${newVal}`) + * ); + * + * state.count = 5; // logs: "Changed from 0 to 5" + * stop(); // Stop watching + * ``` + * + * @example + * ```ts + * // Watch with immediate execution + * watch( + * () => state.count, + * (val) => console.log(`Count is ${val}`), + * { immediate: true } + * ); + * // Immediately logs: "Count is 0" + * ``` + */ export function watch( scope: () => T, - handler: (val: T, old: T) => any, + handler: (val: T, old: T) => void, options?: WatcherOptions -) { +): (() => void) | undefined { const pl = getIntegration(); return pl?.watch?.(scope, handler, options); diff --git a/src/decorate.ts b/src/decorate.ts index 56a6760..0df31b6 100644 --- a/src/decorate.ts +++ b/src/decorate.ts @@ -1,3 +1,27 @@ +/** + * @module decorate + * + * TypeScript 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. + * + * @example + * ```ts + * import { reactive, computed } from 'madrone'; + * + * class Counter { + * @reactive count = 0; + * + * @computed get doubled() { + * return this.count * 2; + * } + * } + * ``` + */ + import { getIntegration } from '@/global'; import { applyClassMixins } from '@/util'; import { define } from '@/auto'; @@ -7,6 +31,34 @@ type Constructor = new (...args: unknown[]) => object; const itemMap = new WeakMap>(); +/** + * 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 + * + * @example + * ```ts + * class Timestamped { + * createdAt = Date.now(); + * } + * + * class Serializable { + * toJSON() { return JSON.stringify(this); } + * } + * + * @classMixin(Timestamped, Serializable) + * class Model { + * name: string; + * } + * + * const model = new Model(); + * model.toJSON(); // Works - mixed in from Serializable + * ``` + */ export function classMixin(...mixins: Constructor[]) { return (target: Constructor) => { if (mixins?.length) { @@ -92,16 +144,62 @@ function decorateComputed( } /** - * Configure a getter property to be cached - * @param target The target to add the computed property to - * @param key The name of the computed property - * @param descriptor property descriptors - * @returns the modified property descriptors + * 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. + * + * @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 + * + * @example + * ```ts + * import { reactive, computed } from 'madrone'; + * + * 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. + * + * @param descriptorOverrides - Options to customize the computed behavior + * @returns A computed decorator with the specified configuration + * + * @example + * ```ts + * class Example { + * @computed.configure({ cache: false }) + * get uncached() { + * return Date.now(); // Recalculates every access + * } + * } + * ``` + */ computed.configure = function configureComputed(descriptorOverrides: DecoratorDescriptorType) { return (target: object, key: string, descriptor: PropertyDescriptor) => decorateComputed( target, @@ -158,18 +256,86 @@ function decorateReactive(target: object, key: string, options?: DecoratorOption } /** - * Configure a reactive property - * @param target The target to add the reactive property to - * @param key The name of the reactive property + * 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 'madrone'; + * + * 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); } +/** + * 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 }); }; diff --git a/src/global.ts b/src/global.ts index 2fe02b5..9dc6cb0 100644 --- a/src/global.ts +++ b/src/global.ts @@ -1,3 +1,13 @@ +/** + * @module global + * + * Global state management for Madrone integrations and reactive object tracking. + * + * Madrone uses a plugin architecture where "integrations" provide the actual + * reactivity implementation. This module manages the global integration registry + * and provides utilities for tracking object access patterns. + */ + import { Integration } from '@/interfaces'; // ///////////////////////////////// @@ -7,33 +17,105 @@ import { Integration } from '@/interfaces'; const GLOBAL_INTEGRATIONS = new Set(); let CURRENT_INTEGRATION: Integration; -export function getIntegrations() { +/** + * Returns all currently registered integrations. + * + * Integrations are the pluggable backends that provide reactivity. + * Multiple integrations can be registered, though typically only one is used. + * + * @returns An array of all registered Integration instances + * + * @example + * ```ts + * import { getIntegrations } from 'madrone'; + * + * const integrations = getIntegrations(); + * console.log(`${integrations.length} integrations registered`); + * ``` + */ +export function getIntegrations(): Array { return [...GLOBAL_INTEGRATIONS] as Array; } -function getLastIntegration() { +function getLastIntegration(): Integration | undefined { const integrations = getIntegrations(); return integrations.at(-1); } -function setCurrentIntegration() { +function setCurrentIntegration(): void { CURRENT_INTEGRATION = getLastIntegration(); } -export function addIntegration(integration: Integration) { +/** + * Registers a new integration with Madrone. + * + * The most recently added integration becomes the active one used by + * `auto()`, `define()`, and other reactive primitives. Integrations + * provide methods for creating reactive properties, computed values, + * and watchers. + * + * @param integration - The integration to register + * + * @example + * ```ts + * import Madrone, { MadroneState } from 'madrone'; + * + * // Register the built-in state integration + * Madrone.use(MadroneState); + * + * // Or register directly + * addIntegration(MadroneState); + * ``` + */ +export function addIntegration(integration: Integration): void { if (!integration) return; GLOBAL_INTEGRATIONS.add(integration); setCurrentIntegration(); } -export function removeIntegration(integration) { +/** + * Removes a previously registered integration. + * + * After removal, the most recently added remaining integration becomes active. + * If no integrations remain, reactive operations will throw errors. + * + * @param integration - The integration to remove + * + * @example + * ```ts + * import { removeIntegration, MadroneState } from 'madrone'; + * + * // Remove when switching integrations or cleaning up + * removeIntegration(MadroneState); + * ``` + */ +export function removeIntegration(integration: Integration): void { GLOBAL_INTEGRATIONS.delete(integration); setCurrentIntegration(); } -export function getIntegration() { +/** + * Returns the currently active integration. + * + * This is the integration that will be used by `auto()`, `define()`, + * `watch()`, and other reactive primitives. Returns undefined if no + * integration has been registered. + * + * @returns The current active Integration, or undefined if none registered + * + * @example + * ```ts + * import { getIntegration } from 'madrone'; + * + * const integration = getIntegration(); + * if (!integration) { + * console.warn('No integration configured - call Madrone.use() first'); + * } + * ``` + */ +export function getIntegration(): Integration | undefined { return CURRENT_INTEGRATION; } @@ -43,19 +125,72 @@ export function getIntegration() { const STATS_ACCESS = new WeakMap(); -/** Get the raw value of an object (without the proxy) */ -export function toRaw(obj: T) { +/** + * Unwraps a reactive proxy to get the underlying raw object. + * + * When you create reactive state with `auto()` or `Reactive()`, the returned + * object is actually a Proxy. This function returns the original object + * without the reactive wrapper, which is useful for: + * - Comparing object identity + * - Passing to external libraries that don't work with Proxies + * - Debugging reactive behavior + * + * @typeParam T - The type of the object + * @param obj - The potentially reactive object to unwrap + * @returns The raw underlying object without reactive proxy + * + * @example + * ```ts + * import { auto, toRaw } from 'madrone'; + * + * const original = { count: 0 }; + * const reactive = auto(original); + * + * console.log(reactive === original); // false (reactive is a Proxy) + * console.log(toRaw(reactive) === original); // true + * ``` + */ +export function toRaw(obj: T): T { const getRawItem = getIntegration()?.toRaw ?? (() => obj); return getRawItem(obj); } -/** Mark an object as accessed */ -export function objectAccessed(obj: object) { +/** + * Records that a reactive object was accessed at the current time. + * + * This is called internally by reactive getters to track when objects + * are read. Used for debugging and performance analysis. + * + * @param obj - The object that was accessed + * @internal + */ +export function objectAccessed(obj: object): void { STATS_ACCESS.set(toRaw(obj), Date.now()); } -/** The last time any reactive property was accessed on a given object */ -export function lastAccessed(obj: object) { +/** + * Returns the timestamp of when a reactive object was last accessed. + * + * Useful for debugging reactive behavior or implementing features like + * "last viewed" timestamps without additional tracking code. + * + * @param obj - The reactive object to check + * @returns Unix timestamp (milliseconds) of last access, or undefined if never accessed + * + * @example + * ```ts + * import { auto, lastAccessed } from 'madrone'; + * + * const state = auto({ count: 0 }); + * + * console.log(lastAccessed(state)); // undefined (not accessed yet) + * + * const value = state.count; // access the property + * + * console.log(lastAccessed(state)); // 1702345678901 (timestamp) + * ``` + */ +export function lastAccessed(obj: object): number | undefined { return STATS_ACCESS.get(toRaw(obj)); } diff --git a/src/integrations/MadroneState.ts b/src/integrations/MadroneState.ts index 392119c..efcee80 100644 --- a/src/integrations/MadroneState.ts +++ b/src/integrations/MadroneState.ts @@ -1,3 +1,24 @@ +/** + * @module MadroneState + * + * Standalone reactivity integration using Madrone's built-in reactive system. + * + * This is the default integration for Madrone, providing a complete reactivity + * implementation based on JavaScript Proxies. It's the recommended integration + * for non-Vue applications. + * + * @example + * ```ts + * import Madrone, { MadroneState, auto } from 'madrone'; + * + * // Initialize with the integration + * Madrone.use(MadroneState); + * + * // Now use reactive features + * const state = auto({ count: 0 }); + * ``` + */ + import { objectAccessed } from '@/global'; import { Integration, @@ -11,11 +32,33 @@ import { import { ReactiveOptions } from '@/reactivity/interfaces'; import { ObservableHooksType } from '@/reactivity/Observer'; +/** + * Configuration options for MadroneState integration. + * + * Allows customizing the behavior of reactive properties and computed values. + * + * @typeParam T - The type of computed values (for typing onChange callbacks) + */ export type MadroneStateOptions = { + /** Options passed to Reactive() for property creation */ reactive?: ReactiveOptions, + /** Hooks for computed property lifecycle events */ computed?: ObservableHooksType, }; +/** + * Creates a property descriptor for a computed property. + * + * The descriptor can be used with Object.defineProperty or stored + * for later use. If caching is enabled, creates a Computed observable + * that tracks dependencies automatically. + * + * @typeParam T - The computed value type + * @param name - Property name (used for debugging) + * @param config - Computed property configuration + * @param options - Optional hooks for lifecycle events + * @returns A PropertyDescriptor with reactive getter/setter + */ export function describeComputed( name: string, config: MadroneComputedDescriptor, @@ -62,6 +105,17 @@ export function describeComputed( }; } +/** + * Creates a property descriptor for a reactive property. + * + * The descriptor can be used with Object.defineProperty or stored + * for later use. Creates a Reactive proxy internally to track changes. + * + * @param name - Property name (used for debugging) + * @param config - Reactive property configuration + * @param options - Optional hooks for lifecycle events + * @returns A PropertyDescriptor with reactive getter/setter + */ export function describeProperty( name: string, config: MadronePropertyDescriptor, @@ -99,6 +153,16 @@ export function describeProperty( }; } +/** + * Defines a computed property directly on an object. + * + * Shorthand for calling describeComputed and Object.defineProperty. + * + * @param target - The object to define the property on + * @param name - The property name + * @param config - Computed configuration + * @param options - Integration-specific options + */ export function defineComputed( target: object, name: string, @@ -108,6 +172,16 @@ export function defineComputed( Object.defineProperty(target, name, describeComputed(name, config, options as MadroneStateOptions)); } +/** + * Defines a reactive property directly on an object. + * + * Shorthand for calling describeProperty and Object.defineProperty. + * + * @param target - The object to define the property on + * @param name - The property name + * @param config - Reactive property configuration + * @param options - Integration-specific options + */ export function defineProperty( target: object, name: string, @@ -117,6 +191,19 @@ export function defineProperty( Object.defineProperty(target, name, describeProperty(name, config, options as MadroneStateOptions)); } +/** + * The standalone MadroneState integration. + * + * Provides a complete reactivity system using JavaScript Proxies. + * This is the recommended integration for non-Vue applications. + * + * @example + * ```ts + * import Madrone, { MadroneState } from 'madrone'; + * + * Madrone.use(MadroneState); + * ``` + */ const MadroneState: Integration = { toRaw, watch: Watcher, @@ -127,4 +214,10 @@ const MadroneState: Integration = { }; export default MadroneState; + +/** + * Creates a watcher that reacts to changes in reactive expressions. + * + * Re-exported from the reactivity module for convenience. + */ export { Watcher as watch } from '@/reactivity'; diff --git a/src/integrations/MadroneVue3.ts b/src/integrations/MadroneVue3.ts index 29938ae..31593c4 100644 --- a/src/integrations/MadroneVue3.ts +++ b/src/integrations/MadroneVue3.ts @@ -1,3 +1,29 @@ +/** + * @module MadroneVue3 + * + * Vue 3 integration for Madrone, bridging Madrone's reactivity with Vue's system. + * + * This integration allows you to use Madrone's composition patterns and decorators + * while having reactivity work seamlessly with Vue 3's rendering system. Vue + * components will automatically re-render when Madrone state changes. + * + * @example + * ```ts + * import Madrone from 'madrone'; + * import MadroneVue3 from 'madrone/integrations/MadroneVue3'; + * import { reactive, toRaw } from 'vue'; + * + * // Initialize with Vue 3's reactive system + * Madrone.use(MadroneVue3({ reactive, toRaw })); + * + * // Now Madrone state works with Vue components + * const store = auto({ + * count: 0, + * get doubled() { return this.count * 2; } + * }); + * ``` + */ + import { objectAccessed } from '@/global'; import { ReactiveOptions } from '@/reactivity/interfaces'; import { ObservableHooksType } from '@/reactivity/Observer'; @@ -11,11 +37,52 @@ const FORBIDDEN = new Set(['__proto__', '__ob__']); const VALUE = 'value'; // reactive setter -const reactiveSet = (item) => { +const reactiveSet = (item: { value: number }) => { item[VALUE] += 1; }; -export default function MadroneVue3({ reactive, toRaw } = {} as any): Integration { +/** + * Creates a Vue 3-compatible integration for Madrone. + * + * This factory function creates an integration that bridges Madrone's + * reactivity with Vue 3's reactive system. Changes to Madrone state + * will trigger Vue component re-renders. + * + * @param options - Vue 3 reactivity functions + * @param options.reactive - Vue's `reactive()` function from 'vue' + * @param options.toRaw - Vue's `toRaw()` function from 'vue' + * @returns An Integration compatible with Madrone.use() + * + * @example + * ```ts + * import Madrone from 'madrone'; + * import MadroneVue3 from 'madrone/integrations/MadroneVue3'; + * import { reactive, toRaw } from 'vue'; + * + * // Set up the integration + * Madrone.use(MadroneVue3({ reactive, toRaw })); + * + * // Create reactive state + * class CounterStore { + * @reactive count = 0; + * + * @computed get doubled() { + * return this.count * 2; + * } + * + * increment() { + * this.count++; + * } + * } + * + * // Use in Vue components - changes automatically trigger re-renders + * const store = new CounterStore(); + * ``` + */ +export default function MadroneVue3({ reactive, toRaw } = {} as { + reactive: (target: T) => T, + toRaw: (proxy: T) => T, +}): Integration { const obToRaw = toRaw ?? ((val) => val); // store all reactive properties const reactiveMappings = new WeakMap>(); diff --git a/src/interfaces.ts b/src/interfaces.ts index 290e046..c0a53a4 100644 --- a/src/interfaces.ts +++ b/src/interfaces.ts @@ -1,60 +1,220 @@ +/** + * @module interfaces + * + * Core type definitions for Madrone's reactivity system. + * + * These interfaces define the contracts for property descriptors, + * integrations, and configuration options used throughout Madrone. + */ + +/** + * Extended property descriptor with Madrone-specific options. + * + * Extends the standard JavaScript PropertyDescriptor with additional + * options for controlling reactive behavior. + */ export interface MadroneDescriptor extends PropertyDescriptor { - /** Cache a computed property */ + /** + * Whether to cache computed property values. + * + * When true (default), computed values are cached and only recalculated + * when dependencies change. Set to false to recalculate on every access. + * + * @default true + */ cache?: boolean, - /** Define a deeply reactive property */ + + /** + * Whether to make nested objects/arrays reactive. + * + * When true (default), all nested objects and arrays within this property + * will also be wrapped in reactive proxies. Set to false for shallow + * reactivity where only top-level changes trigger updates. + * + * @default true + */ deep?: boolean, } + +/** + * Descriptor options for computed (getter-based) properties. + * + * Computed properties derive their value from other reactive state + * and automatically update when dependencies change. + */ export type MadroneComputedDescriptor = Pick< MadroneDescriptor, 'get' | 'set' | 'cache' | 'enumerable' | 'configurable' >; + +/** + * Descriptor options for reactive (value-based) properties. + * + * Reactive properties hold state that triggers updates when changed. + */ export type MadronePropertyDescriptor = Pick< MadroneDescriptor, 'configurable' | 'enumerable' | 'value' | 'deep' >; +/** + * A map of property names to their Madrone descriptors. + * + * Used when configuring multiple properties at once with `auto()`. + */ export interface MadroneDescriptorMap { [key: string]: MadroneDescriptor, } +/** + * Descriptor options available for decorator configuration. + * + * Excludes value-related fields since decorators work with + * class property definitions, not initial values. + */ export type DecoratorDescriptorType = Omit; + +/** + * Configuration options for decorator functions. + */ export type DecoratorOptionType = { + /** Property descriptor overrides */ descriptors?: DecoratorDescriptorType, }; +/** + * Options for the `watch()` function. + */ export type WatcherOptions = { + /** + * Whether to call the handler immediately with the current value. + * + * When true, the handler is invoked once immediately after setting + * up the watcher, with the current value and undefined as the old value. + * + * @default false + */ immediate?: boolean, }; +/** + * Integration-specific options passed to property definition methods. + * + * Different integrations may use these options differently based on + * their underlying reactivity implementation. + */ export interface IntegrationOptions { + /** Options passed to reactive property creation */ reactive?: unknown, + /** Options passed to computed property creation */ computed?: unknown, } +/** + * Interface that all Madrone integrations must implement. + * + * Integrations provide the actual reactivity implementation. Madrone + * ships with `MadroneState` (standalone) and `MadroneVue3` (Vue 3 integration). + * + * @example + * ```ts + * // Creating a custom integration + * const MyIntegration: Integration = { + * defineProperty(target, name, config, options) { + * // Make the property reactive using your reactivity system + * }, + * defineComputed(target, name, config, options) { + * // Create a computed property with caching + * }, + * toRaw(target) { + * // Return the unwrapped object + * }, + * watch(scope, handler, options) { + * // Set up a reactive watcher + * return () => { }; // cleanup function + * } + * }; + * ``` + */ export interface Integration { + /** + * Defines a reactive property on an object. + * + * @param target - The object to define the property on + * @param name - The property name + * @param config - Property configuration + * @param options - Integration-specific options + */ defineProperty: ( target: object, name: string, config: MadronePropertyDescriptor, options?: IntegrationOptions ) => void, + + /** + * Defines a computed property on an object. + * + * @param target - The object to define the property on + * @param name - The property name + * @param config - Computed configuration with getter/setter + * @param options - Integration-specific options + */ defineComputed: ( target: object, name: string, config: MadroneComputedDescriptor, options?: IntegrationOptions ) => void, + + /** + * Unwraps a reactive proxy to get the raw object. + * + * @param target - The potentially reactive object + * @returns The underlying raw object + */ toRaw?: (target: T) => T, + + /** + * Creates a watcher that reacts to changes in reactive state. + * + * @param scope - Function that accesses reactive state to watch + * @param handler - Callback invoked when watched state changes + * @param options - Watcher configuration + * @returns A function to stop watching + */ watch?: ( scope: () => T, handler: (val: T, old?: T) => void, options?: WatcherOptions ) => () => void, + + /** + * Creates a property descriptor for a computed property. + * + * Used when you need the descriptor without immediately defining it. + * + * @param name - The property name (for debugging) + * @param config - Computed configuration + * @param options - Integration-specific options + * @returns A standard PropertyDescriptor + */ describeComputed?: ( name: string, config: MadroneComputedDescriptor, options?: IntegrationOptions ) => PropertyDescriptor, + + /** + * Creates a property descriptor for a reactive property. + * + * Used when you need the descriptor without immediately defining it. + * + * @param name - The property name (for debugging) + * @param config - Property configuration + * @param options - Integration-specific options + * @returns A standard PropertyDescriptor + */ describeProperty?: ( name: string, config: MadronePropertyDescriptor, diff --git a/src/reactivity/Computed.ts b/src/reactivity/Computed.ts index 9c971b0..dba4368 100644 --- a/src/reactivity/Computed.ts +++ b/src/reactivity/Computed.ts @@ -1,9 +1,53 @@ +/** + * @module Computed + * + * Creates cached, auto-updating computed values from reactive dependencies. + */ + import Observer, { ObservableOptions } from './Observer'; /** - * Create a new computed instance - * @param options the computed options - * @returns the created instance + * Creates a computed value that caches its result and auto-updates when dependencies change. + * + * A computed value is defined by a getter function. The result is cached and only + * recalculated when one of its reactive dependencies changes. This provides efficient + * derived state that stays in sync with source data. + * + * @typeParam T - The type of the computed value + * @param options - Configuration for the computed value + * @param options.get - Getter function that computes the value + * @param options.set - Optional setter function for writable computed values + * @param options.name - Optional name for debugging + * @param options.cache - Whether to cache the value (default: true) + * @returns An ObservableItem with a `.value` property + * + * @example + * ```ts + * import { Reactive, Computed } from 'madrone/reactivity'; + * + * const state = Reactive({ firstName: 'John', lastName: 'Doe' }); + * + * const fullName = Computed({ + * get: () => `${state.firstName} ${state.lastName}`, + * name: 'fullName' + * }); + * + * console.log(fullName.value); // 'John Doe' + * + * state.firstName = 'Jane'; + * console.log(fullName.value); // 'Jane Doe' (automatically updated) + * ``` + * + * @example + * ```ts + * // Writable computed + * const doubleCount = Computed({ + * get: () => state.count * 2, + * set: (val) => { state.count = val / 2; } + * }); + * + * doubleCount.value = 10; // Sets state.count to 5 + * ``` */ export default function Computed(options: ObservableOptions) { return Observer(options); diff --git a/src/reactivity/Observer.ts b/src/reactivity/Observer.ts index 57af983..8c16f99 100644 --- a/src/reactivity/Observer.ts +++ b/src/reactivity/Observer.ts @@ -1,41 +1,105 @@ +/** + * @module Observer + * + * Low-level observable implementation with dependency tracking. + * + * Observer is the foundation of Madrone's reactivity system. It tracks + * which reactive properties are accessed during computation and schedules + * updates when those dependencies change. + */ + import { OBSERVER_SYMBOL, dependTracker, observerClear, schedule, trackerChanged, } from './global'; -const GLOBAL_STACK: Array> = []; - -export function getCurrentObserver() { +const GLOBAL_STACK: Array> = []; + +/** + * Returns the currently running Observer, if any. + * + * Used internally to track which Observer should be notified when + * reactive properties are accessed. + * + * @returns The current Observer or undefined if none is running + * @internal + */ +export function getCurrentObserver(): ObservableItem | undefined { return GLOBAL_STACK.at(-1); } +/** + * Lifecycle hooks that can be called on an Observer. + */ export enum OBSERVER_HOOK { + /** Called when the computed value is read */ onGet = 'onGet', + /** Called when a writable computed is set */ onSet = 'onSet', + /** Called asynchronously after dependencies change */ onChange = 'onChange', + /** Called synchronously when dependencies change (before async scheduling) */ onImmediateChange = 'onImmediateChange', } +/** + * Type for Observer lifecycle hook callbacks. + * @typeParam T - The type of the observed value + */ export type ObservableHookType = (obs: ObservableItem) => void; +/** + * Collection of lifecycle hooks for an Observer. + * @typeParam T - The type of the observed value + */ export type ObservableHooksType = { + /** Called when the value is accessed */ onGet?: ObservableHookType, + /** Called when the value is set (writable computed only) */ onSet?: ObservableHookType, + /** Called asynchronously after dependencies change */ onChange?: ObservableHookType, + /** Called synchronously when dependencies change */ onImmediateChange?: ObservableHookType, }; +/** + * Configuration options for creating an Observer. + * @typeParam T - The type of the observed value + */ export type ObservableOptions = { + /** Getter function that computes the value */ get: () => T, + /** Optional name for debugging */ name?: string, + /** Optional setter for writable computed values */ set?: (val: T) => void, + /** Whether to cache the computed value (default: true) */ cache?: boolean, } & ObservableHooksType; +/** + * Core observable class that tracks dependencies and caches computed values. + * + * ObservableItem wraps a getter function and automatically tracks which + * reactive properties are accessed when the getter runs. When those + * dependencies change, the cached value is invalidated and change hooks + * are called. + * + * @typeParam T - The type of the observed value + */ class ObservableItem { + /** + * Factory method to create a new ObservableItem. + * @internal + */ static create(...args: ConstructorParameters>) { return new ObservableItem(...args); } + /** + * Creates a new ObservableItem. + * @param options - Configuration options + */ constructor(options: ObservableOptions) { this.name = options.name; this.get = options.get; @@ -52,14 +116,19 @@ class ObservableItem { }; } + /** Name for debugging purposes */ name: string; + /** Whether this observer is still active */ alive: boolean; + /** Whether the cached value needs to be recomputed */ dirty: boolean; + /** The previous value (available during onChange) */ prev: T; + /** Whether to cache the computed value */ cache: boolean; private cachedVal: T; - private hooks: Record) => any>; + private hooks: Record) => unknown>; private get: () => T; private set: (val: T) => void; @@ -143,6 +212,28 @@ class ObservableItem { export { ObservableItem }; -export default function Observer(...args: Parameters>) { +/** + * Creates a new Observer that tracks dependencies and caches computed values. + * + * This is the low-level API for creating reactive computations. Most users + * should use `Computed` or `Watcher` instead, which provide more convenient + * interfaces on top of Observer. + * + * @typeParam T - The type of the observed value + * @param args - Configuration options for the observer + * @returns An ObservableItem instance + * + * @example + * ```ts + * const obs = Observer({ + * get: () => state.count * 2, + * onChange: (o) => console.log('Changed to:', o.value) + * }); + * + * console.log(obs.value); // Runs getter, tracks dependencies + * state.count = 5; // Triggers onChange + * ``` + */ +export default function Observer(...args: Parameters>) { return ObservableItem.create(...args); } diff --git a/src/reactivity/Reactive.ts b/src/reactivity/Reactive.ts index 8ecb823..1070898 100644 --- a/src/reactivity/Reactive.ts +++ b/src/reactivity/Reactive.ts @@ -1,3 +1,9 @@ +/** + * @module Reactive + * + * Core reactivity primitive that wraps objects in reactive Proxies. + */ + import typeHandlers from './typeHandlers'; import { addReactive, isReactiveTarget, isReactive, getReactive, @@ -5,10 +11,45 @@ import { import { ReactiveOptions } from './interfaces'; /** - * Observe an object - * @param {Object} target the object to observe - * @param {Object} options the observation options - * @returns {Proxy} a proxied version of the object that can be observed + * Wraps an object in a reactive Proxy that tracks property access and mutations. + * + * When properties are read, they become dependencies of any active Observer. + * When properties are written, all dependent Observers are notified to update. + * + * Supports objects, arrays, Maps, and Sets. By default, reactivity is deep - + * nested objects are also wrapped in Proxies when accessed. + * + * @typeParam T - The type of object being made reactive + * @param target - The object to make reactive + * @param options - Configuration options for reactive behavior + * @returns A reactive Proxy wrapping the target object + * + * @example + * ```ts + * import { Reactive, Watcher } from 'madrone/reactivity'; + * + * const state = Reactive({ count: 0, nested: { value: 1 } }); + * + * // Watcher tracks `count` as a dependency + * Watcher( + * () => state.count, + * (val) => console.log(`Count: ${val}`) + * ); + * + * state.count = 5; // Triggers watcher + * state.nested.value = 2; // Also reactive (deep by default) + * ``` + * + * @example + * ```ts + * // With Maps and Sets + * const set = Reactive(new Set([1, 2, 3])); + * const map = Reactive(new Map([['key', 'value']])); + * + * // All operations are reactive + * set.add(4); + * map.set('newKey', 'newValue'); + * ``` */ export default function Reactive(target: T, options?: ReactiveOptions): T { // if we've already made an Reactive from the target, return the existing one @@ -32,6 +73,20 @@ export default function Reactive(target: T, options?: Reactive return proxy; } -Reactive.getStringType = (obj) => Object.prototype.toString.call(obj).slice(8, -1).toLowerCase(); -Reactive.hasHandler = (type) => !!typeHandlers[type]; -Reactive.typeHandler = (type, hooks) => typeHandlers[type]?.(hooks); +/** + * Gets the type string for an object (e.g., 'object', 'array', 'set', 'map'). + * @internal + */ +Reactive.getStringType = (obj: unknown): string => Object.prototype.toString.call(obj).slice(8, -1).toLowerCase(); + +/** + * Checks if a handler exists for the given type. + * @internal + */ +Reactive.hasHandler = (type: string): boolean => !!typeHandlers[type]; + +/** + * Gets the Proxy handler for the given type. + * @internal + */ +Reactive.typeHandler = (type: string, hooks: ReactiveOptions): ProxyHandler => typeHandlers[type]?.(hooks); diff --git a/src/reactivity/Watcher.ts b/src/reactivity/Watcher.ts index 74069ef..b0c3bd2 100644 --- a/src/reactivity/Watcher.ts +++ b/src/reactivity/Watcher.ts @@ -1,18 +1,68 @@ +/** + * @module Watcher + * + * Watches reactive expressions and runs callbacks when they change. + */ + import { WatcherOptions } from '@/interfaces'; import Observer from './Observer'; /** - * Watch an observable for changes - * @param get the getter function returning the item to watch - * @param handler the callback whenever the value returned from `get` changes - * @param {Boolean} [options.deep] deeply watch the value - * @returns a disposer + * Watches a reactive expression and calls a handler when its value changes. + * + * The getter function is called immediately to establish dependencies. + * Whenever any reactive property accessed within the getter changes, + * the function re-runs and the handler is called with the new and old values. + * + * @typeParam T - The type of the watched value + * @param get - Function that returns the value to watch. All reactive + * properties accessed become dependencies. + * @param handler - Callback invoked when the watched value changes + * @param options - Optional configuration + * @param options.immediate - If true, calls handler immediately with current value + * @returns A disposer function that stops watching when called + * + * @example + * ```ts + * import { Reactive, Watcher } from 'madrone/reactivity'; + * + * const state = Reactive({ count: 0 }); + * + * // Watch a single property + * const stop = Watcher( + * () => state.count, + * (newVal, oldVal) => console.log(`Count: ${oldVal} → ${newVal}`) + * ); + * + * state.count = 5; // logs: "Count: 0 → 5" + * stop(); // Stop watching + * ``` + * + * @example + * ```ts + * // Watch a computed expression + * const stop = Watcher( + * () => state.items.filter(i => i.active).length, + * (count) => console.log(`${count} active items`) + * ); + * ``` + * + * @example + * ```ts + * // Immediate execution + * Watcher( + * () => state.count, + * (val) => console.log(`Count is ${val}`), + * { immediate: true } + * ); + * // Immediately logs: "Count is 0" + * ``` */ export default function Watcher( get: () => T, - handler: (val?: T, old?: T) => any, + handler: (val?: T, old?: T) => unknown, options?: WatcherOptions -) { +): () => void { const obs = Observer({ get, onChange: ({ value, prev }) => handler(value, prev), diff --git a/src/reactivity/global.ts b/src/reactivity/global.ts index 2305414..e180c52 100644 --- a/src/reactivity/global.ts +++ b/src/reactivity/global.ts @@ -1,47 +1,81 @@ +/** + * @module reactivity/global + * + * Global state and dependency tracking for the reactivity system. + * + * This module manages the core data structures that enable automatic + * dependency tracking and change notification. It maintains mappings + * between reactive proxies, their targets, and the observers that + * depend on them. + * + * @internal + */ + import { getCurrentObserver, ObservableItem } from './Observer'; // constants +/** Symbol used to track when an object's keys change */ export const KEYS_SYMBOL = Symbol('keys'); +/** Symbol used for observer dependency tracking */ export const OBSERVER_SYMBOL = Symbol('computed'); type DependencyKey = string | symbol | ObservableItem; -/** Mapping from target object to its proxy */ +/** Mapping from target object to its reactive proxy */ const TARGET_TO_PROXY = new WeakMap(); -/** Mapping from proxy to the object it proxies */ +/** Mapping from reactive proxy to its underlying target object */ const PROXY_TO_TARGET = new WeakMap(); -/** Mapping from proxy to the observers that depend on it */ +/** Mapping from reactive proxy to the observers that depend on it */ const PROXY_TO_OBSERVERS = new WeakMap< object, Map>> >(); -/** Mapping from observer to its dependencies */ +/** Mapping from observer to the proxies it depends on */ const OBSERVER_TO_PROXIES = new WeakMap< ObservableItem, Map> >(); -/** List of scheduled tasks */ +/** Queue of tasks waiting to be executed */ let TASK_QUEUE: (() => void)[] = []; -/** The id of the timeout that will handle all scheduled tasks */ +/** Scheduler ID to prevent multiple schedulers from running */ let SCHEDULER_ID: symbol | null = null; -/** Check if the current target has a proxy associated with it */ +/** Checks if the given object has a reactive proxy associated with it */ export const isReactiveTarget = (target: object): boolean => TARGET_TO_PROXY.has(target); -/** Check if the current proxy has a target object */ + +/** Checks if the given object is a reactive proxy */ export const isReactive = (trk: object): boolean => PROXY_TO_TARGET.has(trk); + +/** Gets the reactive proxy for a target object */ export const getReactive = (target: T): T | undefined => TARGET_TO_PROXY.get(target) as T; + +/** Gets the underlying target for a reactive proxy */ export const getTarget = (tracker: T): T | undefined => PROXY_TO_TARGET.get(tracker) as T; + +/** Gets the proxy for an object, whether passed a target or proxy */ export const getProxy = (targetOrProxy: T): T | undefined => ( isReactive(targetOrProxy) ? targetOrProxy : getReactive(targetOrProxy) ); + +/** + * Unwraps a reactive proxy to get the raw underlying object. + * + * If the object is not a proxy, returns it unchanged. + */ export const toRaw = (targetOrProxy: T): T => ( isReactive(targetOrProxy) ? getTarget(targetOrProxy) : targetOrProxy ); +/** Gets all dependencies for an observer */ export const getDependencies = (observer: ObservableItem) => OBSERVER_TO_PROXIES.get(observer); -/** Get the list of items that are observing a given proxy */ + +/** Gets all observers watching a given proxy */ export const getObservers = (tracker: object) => PROXY_TO_OBSERVERS.get(getProxy(tracker)); +/** + * Registers a target/proxy pair in the tracking system. + * @internal + */ export const addReactive = (target: T, proxy: T): void => { TARGET_TO_PROXY.set(target, proxy); PROXY_TO_TARGET.set(proxy, target); @@ -64,6 +98,15 @@ const doTasksIfNeeded = (): void => { } }; +/** + * Schedules a task to run asynchronously in the next microtask. + * + * Tasks are batched and executed together. Used to batch multiple + * change notifications into a single update cycle. + * + * @param task - The function to execute + * @internal + */ export const schedule = (task: () => void): void => { TASK_QUEUE.push(task); doTasksIfNeeded(); diff --git a/src/reactivity/interfaces.ts b/src/reactivity/interfaces.ts index a74a327..5ca99c9 100644 --- a/src/reactivity/interfaces.ts +++ b/src/reactivity/interfaces.ts @@ -1,30 +1,74 @@ +/** + * @module reactivity/interfaces + * + * Type definitions for the low-level reactivity system. + */ + +/** + * Options passed to type handler hooks when reactive operations occur. + * + * @typeParam T - The type of the reactive target + */ export type TypeHandlerOptions = { + /** Name for debugging */ name?: string, + /** Whether deep reactivity is enabled */ deep?: boolean, + /** The Proxy receiver */ receiver?: T, + /** The raw target object */ target?: T, + /** The property key being accessed/modified */ key?: PropertyKey, + /** The value being set */ value?: unknown, + /** Whether the operation changed the object's keys */ keysChanged?: boolean, + /** Whether the operation changed a value */ valueChanged?: boolean, }; +/** + * Hook function called on reactive operations. + * @typeParam T - The type of the reactive target + */ export type HandlerHookType = (options: TypeHandlerOptions) => void; + +/** + * Hook function that determines if a value should be wrapped in a Proxy. + * @typeParam T - The type of the reactive target + */ export type CheckProxyHookType = (options: { target: T, key: PropertyKey, value: unknown, }) => boolean; +/** + * Collection of hooks for reactive Proxy handlers. + * @typeParam T - The type of the reactive target + */ export type ReactiveHandlerHooks = { + /** Called when a property is read */ onGet: HandlerHookType, + /** Called when a property is set */ onSet: HandlerHookType, + /** Called when a property is deleted */ onDelete: HandlerHookType, + /** Called when `in` operator or `has` trap is triggered */ onHas: HandlerHookType, + /** Determines if a value should be wrapped in a reactive Proxy */ needsProxy: CheckProxyHookType, }; +/** + * Configuration options for creating reactive Proxies. + * + * @typeParam T - The type of the object being made reactive + */ export type ReactiveOptions = { + /** Name for debugging purposes */ name?: string, + /** Whether to recursively make nested objects reactive (default: true) */ deep?: boolean, } & Partial>; diff --git a/src/util.ts b/src/util.ts index 814ade5..8ad8a27 100644 --- a/src/util.ts +++ b/src/util.ts @@ -1,3 +1,12 @@ +/** + * @module util + * + * Utility functions for object composition and class mixins. + * + * These utilities support Madrone's composition-based architecture, + * allowing you to build complex objects from simpler pieces. + */ + type AnyObject = Record; type OptionalPropertyNames = { @@ -10,6 +19,13 @@ type SpreadProperties = { type Id = T extends infer U ? { [K in keyof U]: U[K] } : never; +/** + * Type utility that merges two object types, with right-hand properties + * taking precedence over left-hand properties. + * + * @typeParam L - The left (base) type + * @typeParam R - The right (override) type + */ export type SpreadTwo = Id< Pick> & Pick>> @@ -19,14 +35,49 @@ export type SpreadTwo = Id< type ObjectOrFactory = object | ((...args: unknown[]) => object); +/** + * Type utility that recursively merges an array of object types. + * + * Handles both plain objects and factory functions (whose return + * types are extracted and merged). + * + * @typeParam A - A tuple of object or factory types + */ export type Spread = A extends [infer L, ...infer R] ? SpreadTwo infer RT ? RT : L, Spread> : unknown; /** - * Merge multiple object definitions into a single new object definition - * @param types - * @returns The new object definition + * Merges multiple objects or factory functions into a single new object. + * + * Properties from later arguments override those from earlier ones. + * Factory functions are called and their return values are merged. + * All property descriptors (getters, setters, etc.) are preserved. + * + * @typeParam A - The types of objects/factories being merged + * @param types - Objects or factory functions to merge + * @returns A new object with all properties from all inputs + * + * @example + * ```ts + * import { merge } from 'madrone'; + * + * const base = { name: 'base', value: 1 }; + * const override = { value: 2, extra: true }; + * + * const merged = merge(base, override); + * // { name: 'base', value: 2, extra: true } + * ``` + * + * @example + * ```ts + * // With factory functions + * const createTimestamp = () => ({ createdAt: Date.now() }); + * const data = { name: 'item' }; + * + * const result = merge(createTimestamp, data); + * // { createdAt: 1702345678901, name: 'item' } + * ``` */ export function merge(...types: [...A]): Spread { const defs = {} as PropertyDescriptorMap; @@ -46,9 +97,43 @@ export function merge(...types: [...A]): Spread type Constructor = new (...args: unknown[]) => object; /** - * Extend the prototype of a base class with the prototypes of other classes. Mutates the base class. - * @param base Base class that the mixins will be mixed into. Any naming conflicts will prefer this base class. - * @param constructors List of mixin classes that will be applied to the base class. + * Applies mixin classes to a base class by merging their prototypes. + * + * 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. + * + * @param base - The base class to extend (will be mutated) + * @param mixins - Array of mixin classes whose prototypes will be merged in + * + * @example + * ```ts + * import { applyClassMixins } from 'madrone'; + * + * class Timestamped { + * createdAt = Date.now(); + * getAge() { + * return Date.now() - this.createdAt; + * } + * } + * + * class Serializable { + * toJSON() { + * return JSON.stringify(this); + * } + * } + * + * class Model { + * id: string; + * } + * + * // Add Timestamped and Serializable methods to Model + * applyClassMixins(Model, [Timestamped, Serializable]); + * + * const model = new Model(); + * model.toJSON(); // Works! + * model.getAge(); // Works! + * ``` */ export function applyClassMixins(base: Constructor, mixins: Constructor[]): void { Object.defineProperties( @@ -57,6 +142,18 @@ export function applyClassMixins(base: Constructor, mixins: Constructor[]): void ); } +/** + * Creates a property descriptor map with default descriptor settings. + * + * Takes an object and returns its property descriptors with specified + * defaults applied. Useful for copying properties with consistent settings. + * + * @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, defaults?: Partial From 912ad265f45c0730398d9e4930651ef79a4bd0d7 Mon Sep 17 00:00:00 2001 From: Tyler Reardon Date: Wed, 17 Dec 2025 10:36:47 -0800 Subject: [PATCH 06/22] process tasks in O(n) --- src/reactivity/global.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/reactivity/global.ts b/src/reactivity/global.ts index e180c52..875cd20 100644 --- a/src/reactivity/global.ts +++ b/src/reactivity/global.ts @@ -89,8 +89,9 @@ const doTasksIfNeeded = (): void => { TASK_QUEUE = []; - while (queue.length > 0) { - queue.shift()(); + // Process tasks in O(n) instead of O(n²) from shift() + for (const task of queue) { + task(); } SCHEDULER_ID = null; From 9454b08b5eecf8729d0273a18696dced797abe7b Mon Sep 17 00:00:00 2001 From: Tyler Reardon Date: Wed, 17 Dec 2025 10:39:50 -0800 Subject: [PATCH 07/22] queueMicrotask --- src/reactivity/global.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/reactivity/global.ts b/src/reactivity/global.ts index 875cd20..5703e51 100644 --- a/src/reactivity/global.ts +++ b/src/reactivity/global.ts @@ -84,7 +84,7 @@ export const addReactive = (target: T, proxy: T): void => { const doTasksIfNeeded = (): void => { if (SCHEDULER_ID === null) { SCHEDULER_ID = Symbol('scheduler'); - setTimeout(() => { + queueMicrotask(() => { const queue = TASK_QUEUE; TASK_QUEUE = []; From 1c3fada7484028dd9c4de540a663f8d87fdb6bba Mon Sep 17 00:00:00 2001 From: Tyler Reardon Date: Wed, 17 Dec 2025 11:09:43 -0800 Subject: [PATCH 08/22] preconfigured vue --- package.json | 15 +++++- src/global.ts | 4 +- src/integrations/MadroneState.ts | 5 +- src/integrations/MadroneVue3.ts | 67 ++++++++++++++------------ src/integrations/__spec__/vue3.spec.ts | 22 +++++++++ src/integrations/vue.ts | 46 ++++++++++++++++++ src/interfaces.ts | 2 +- src/reactivity/Reactive.ts | 2 +- src/reactivity/typeHandlers.ts | 2 +- tsconfig.json | 3 +- tsconfig.types.json | 3 +- vite.config.ts | 18 ++++++- 12 files changed, 147 insertions(+), 42 deletions(-) create mode 100644 src/integrations/vue.ts diff --git a/package.json b/package.json index 4f04f8c..373a5bb 100644 --- a/package.json +++ b/package.json @@ -13,11 +13,24 @@ "types": "./types/index.d.ts", "import": "./dist/core.js", "require": "./dist/core.umd.cjs" + }, + "./vue": { + "types": "./types/integrations/vue.d.ts", + "import": "./dist/vue.js", + "require": "./dist/vue.umd.cjs" + } + }, + "peerDependencies": { + "vue": "^3.0.0" + }, + "peerDependenciesMeta": { + "vue": { + "optional": true } }, "files": [ "dist/*.js", - "dist/*.mjs", + "dist/*.cjs", "types/**/*.d.ts" ], "repository": { diff --git a/src/global.ts b/src/global.ts index 9dc6cb0..46a6041 100644 --- a/src/global.ts +++ b/src/global.ts @@ -150,8 +150,8 @@ const STATS_ACCESS = new WeakMap(); * console.log(toRaw(reactive) === original); // true * ``` */ -export function toRaw(obj: T): T { - const getRawItem = getIntegration()?.toRaw ?? (() => obj); +export function toRaw(obj: T): T { + const getRawItem = getIntegration()?.toRaw ?? ((o: T) => o); return getRawItem(obj); } diff --git a/src/integrations/MadroneState.ts b/src/integrations/MadroneState.ts index efcee80..50aee6e 100644 --- a/src/integrations/MadroneState.ts +++ b/src/integrations/MadroneState.ts @@ -121,8 +121,9 @@ export function describeProperty( config: MadronePropertyDescriptor, options?: MadroneStateOptions ): PropertyDescriptor { - const tg = { value: config.value }; - const atom = Reactive(tg, { + type Atom = { value: unknown }; + const tg: Atom = { value: config.value }; + const atom = Reactive(tg, { name, onGet: options?.reactive?.onGet, onHas: options?.reactive?.onHas, diff --git a/src/integrations/MadroneVue3.ts b/src/integrations/MadroneVue3.ts index 31593c4..3e08cf1 100644 --- a/src/integrations/MadroneVue3.ts +++ b/src/integrations/MadroneVue3.ts @@ -41,6 +41,16 @@ const reactiveSet = (item: { value: number }) => { item[VALUE] += 1; }; +/** + * Options for creating a Vue 3 integration. + */ +export interface MadroneVue3Options { + /** Vue's `reactive()` function from 'vue' */ + reactive: (target: T) => unknown; + /** Vue's `toRaw()` function from 'vue' */ + toRaw: (proxy: T) => T; +} + /** * Creates a Vue 3-compatible integration for Madrone. * @@ -48,41 +58,38 @@ const reactiveSet = (item: { value: number }) => { * reactivity with Vue 3's reactive system. Changes to Madrone state * will trigger Vue component re-renders. * + * For simpler setup, use the pre-configured `madrone/integrations/vue` module instead. + * * @param options - Vue 3 reactivity functions * @param options.reactive - Vue's `reactive()` function from 'vue' * @param options.toRaw - Vue's `toRaw()` function from 'vue' * @returns An Integration compatible with Madrone.use() + * @throws Error if reactive function is not provided * * @example * ```ts + * // Option 1: Use the pre-configured module (recommended) * import Madrone from 'madrone'; - * import MadroneVue3 from 'madrone/integrations/MadroneVue3'; - * import { reactive, toRaw } from 'vue'; - * - * // Set up the integration - * Madrone.use(MadroneVue3({ reactive, toRaw })); - * - * // Create reactive state - * class CounterStore { - * @reactive count = 0; - * - * @computed get doubled() { - * return this.count * 2; - * } - * - * increment() { - * this.count++; - * } - * } + * import MadroneVue from 'madrone/integrations/vue'; + * Madrone.use(MadroneVue); * - * // Use in Vue components - changes automatically trigger re-renders - * const store = new CounterStore(); + * // Option 2: Manual configuration + * import Madrone from 'madrone'; + * import createMadroneVue3 from 'madrone/integrations/MadroneVue3'; + * import { reactive, toRaw } from 'vue'; + * Madrone.use(createMadroneVue3({ reactive, toRaw })); * ``` */ -export default function MadroneVue3({ reactive, toRaw } = {} as { - reactive: (target: T) => T, - toRaw: (proxy: T) => T, -}): Integration { +export default function MadroneVue3(options: MadroneVue3Options): Integration { + if (!options?.reactive || typeof options.reactive !== 'function') { + throw new Error( + 'MadroneVue3 requires Vue\'s reactive function. ' + + 'Either use "madrone/integrations/vue" for automatic setup, ' + + 'or pass { reactive, toRaw } from "vue".' + ); + } + + const { reactive, toRaw } = options; const obToRaw = toRaw ?? ((val) => val); // store all reactive properties const reactiveMappings = new WeakMap>(); @@ -99,7 +106,7 @@ export default function MadroneVue3({ reactive, toRaw } = {} as { let keyItem = item.get(key); if (!keyItem) { - keyItem = reactive({ [VALUE]: 0 }); + keyItem = reactive({ [VALUE]: 0 }) as { value: number }; item.set(key, keyItem); } @@ -155,25 +162,25 @@ export default function MadroneVue3({ reactive, toRaw } = {} as { }, }; - const options = { + const integrationOptions = { computed: computedOptions, reactive: reactiveOptions, }; function describeComputed(name, config) { - return MadroneState.describeComputed(name, config, options); + return MadroneState.describeComputed(name, config, integrationOptions); } function describeProperty(name, config) { - return MadroneState.describeProperty(name, config, options); + return MadroneState.describeProperty(name, config, integrationOptions); } function defineComputed(target, name, config) { - return MadroneState.defineComputed(target, name, config, options); + return MadroneState.defineComputed(target, name, config, integrationOptions); } function defineProperty(target, name, config) { - return MadroneState.defineProperty(target, name, config, options); + return MadroneState.defineProperty(target, name, config, integrationOptions); } return { diff --git a/src/integrations/__spec__/vue3.spec.ts b/src/integrations/__spec__/vue3.spec.ts index bd93726..4cccf6d 100644 --- a/src/integrations/__spec__/vue3.spec.ts +++ b/src/integrations/__spec__/vue3.spec.ts @@ -1,5 +1,7 @@ +import { describe, it, expect } from 'vitest'; import * as Vue from 'vue3'; import MadroneVue3 from '../MadroneVue3'; +import MadroneVue from '../vue'; import testAll from './testAll'; import testVue from './testVue'; @@ -9,3 +11,23 @@ testAll('Vue3', integration); testVue('Vue3', integration, { create: (...args: Parameters) => Vue.createApp(...args).mount(document.createElement('div')), }); + +describe('MadroneVue pre-configured module', () => { + it('exports a valid integration', () => { + expect(MadroneVue).toBeDefined(); + expect(MadroneVue.defineProperty).toBeTypeOf('function'); + expect(MadroneVue.defineComputed).toBeTypeOf('function'); + expect(MadroneVue.watch).toBeTypeOf('function'); + expect(MadroneVue.toRaw).toBeTypeOf('function'); + }); +}); + +describe('MadroneVue3 error handling', () => { + it('throws helpful error when called without arguments', () => { + expect(() => MadroneVue3(undefined as never)).toThrow('MadroneVue3 requires Vue\'s reactive function'); + }); + + it('throws helpful error when called with empty object', () => { + expect(() => MadroneVue3({} as never)).toThrow('MadroneVue3 requires Vue\'s reactive function'); + }); +}); diff --git a/src/integrations/vue.ts b/src/integrations/vue.ts new file mode 100644 index 0000000..f168dd5 --- /dev/null +++ b/src/integrations/vue.ts @@ -0,0 +1,46 @@ +/** + * @module vue + * + * Pre-configured Vue 3 integration for Madrone. + * + * This module automatically imports Vue's reactivity functions, so you don't + * need to pass them manually. Just import and use directly. + * + * @example + * ```ts + * import Madrone from 'madrone'; + * import MadroneVue from 'madrone/integrations/vue'; + * + * Madrone.use(MadroneVue); + * + * // That's it! Madrone now works with Vue's reactivity + * ``` + */ + +import { reactive, toRaw } from 'vue'; +import createMadroneVue3 from './MadroneVue3'; + +/** + * Pre-configured Vue 3 integration. + * + * Uses Vue's `reactive` and `toRaw` functions automatically. + * This is the recommended way to use Madrone with Vue 3. + * + * @example + * ```ts + * import Madrone from 'madrone'; + * import MadroneVue from 'madrone/integrations/vue'; + * + * // Simple one-liner setup + * Madrone.use(MadroneVue); + * + * // Create reactive state that works with Vue components + * const store = auto({ + * count: 0, + * get doubled() { return this.count * 2; } + * }); + * ``` + */ +const MadroneVue = createMadroneVue3({ reactive, toRaw }); + +export default MadroneVue; diff --git a/src/interfaces.ts b/src/interfaces.ts index c0a53a4..32f7c4d 100644 --- a/src/interfaces.ts +++ b/src/interfaces.ts @@ -173,7 +173,7 @@ export interface Integration { * @param target - The potentially reactive object * @returns The underlying raw object */ - toRaw?: (target: T) => T, + toRaw?: (target: T) => T, /** * Creates a watcher that reacts to changes in reactive state. diff --git a/src/reactivity/Reactive.ts b/src/reactivity/Reactive.ts index 1070898..613b318 100644 --- a/src/reactivity/Reactive.ts +++ b/src/reactivity/Reactive.ts @@ -66,7 +66,7 @@ export default function Reactive(target: T, options?: Reactive // if not, return the original if (!Reactive.hasHandler(type)) return target; - const proxy = new Proxy(target, Reactive.typeHandler(type, newOptions)); + const proxy = new Proxy(target, Reactive.typeHandler(type, newOptions)) as T; addReactive(target, proxy); diff --git a/src/reactivity/typeHandlers.ts b/src/reactivity/typeHandlers.ts index ccb6b94..6be338f 100644 --- a/src/reactivity/typeHandlers.ts +++ b/src/reactivity/typeHandlers.ts @@ -142,7 +142,7 @@ const arrayHandler = (options) => ({ */ const wrapIfDeep = (value: T, options: ReactiveOptions): T => { if (options?.deep && value && typeof value === 'object') { - return Reactive(value, options); + return Reactive(value as object, options) as T; } return value; }; diff --git a/tsconfig.json b/tsconfig.json index e514d14..ade205d 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -9,7 +9,8 @@ "outDir": "./dist", "baseUrl": "./", "paths": { - "@/*": ["./src/*"] + "@/*": ["./src/*"], + "vue": ["./node_modules/vue3"] } }, "include": ["src/**/*"], diff --git a/tsconfig.types.json b/tsconfig.types.json index 4bcbc26..88a44c8 100644 --- a/tsconfig.types.json +++ b/tsconfig.types.json @@ -5,5 +5,6 @@ "emitDeclarationOnly": true, "declarationMap": true, "outDir": "./types" - } + }, + "exclude": ["node_modules", "**/__spec__/**", "**/*.spec.ts"] } diff --git a/vite.config.ts b/vite.config.ts index 1a72b6f..e687276 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -4,10 +4,22 @@ import { defineConfig } from 'vite'; export default defineConfig({ build: { lib: { - // Could also be a dictionary or array of multiple entry points - entry: resolve(__dirname, 'src/index.ts'), + entry: { + core: resolve(__dirname, 'src/index.ts'), + vue: resolve(__dirname, 'src/integrations/vue.ts'), + }, name: 'madrone', }, + rollupOptions: { + // Mark vue as external - users must have it installed + external: ['vue'], + output: { + // Provide global variable name for UMD builds + globals: { + vue: 'Vue', + }, + }, + }, }, test: { environment: 'happy-dom', @@ -15,6 +27,8 @@ export default defineConfig({ resolve: { alias: { '@': resolve(__dirname, './src'), + // Alias 'vue' to 'vue3' package for testing (users will have 'vue' installed) + 'vue': 'vue3', }, }, }); From 998eefd0d6858e385ec08bb28d40c5c0c8717e13 Mon Sep 17 00:00:00 2001 From: Tyler Reardon Date: Wed, 17 Dec 2025 11:19:02 -0800 Subject: [PATCH 09/22] fix endless retries for computed if error thrown --- src/reactivity/Observer.ts | 21 +++--- src/reactivity/__spec__/observer.spec.ts | 84 ++++++++++++++++++++++++ 2 files changed, 97 insertions(+), 8 deletions(-) diff --git a/src/reactivity/Observer.ts b/src/reactivity/Observer.ts index 8c16f99..85632e8 100644 --- a/src/reactivity/Observer.ts +++ b/src/reactivity/Observer.ts @@ -150,13 +150,13 @@ class ObservableItem { this.prev = undefined; } - private wrap(cb: () => CBType) { + private wrap(cb: () => CBType): CBType { GLOBAL_STACK.push(this); - - const val = cb(); - - GLOBAL_STACK.pop(); - return val; + try { + return cb(); + } finally { + GLOBAL_STACK.pop(); + } } setDirty() { @@ -182,8 +182,13 @@ class ObservableItem { const val = this.wrap(() => { if ((this.cache && this.dirty) || !this.cache) { - this.cachedVal = this.get(); - this.dirty = false; + try { + this.cachedVal = this.get(); + } finally { + // Always reset dirty to prevent infinite retry loops on persistent errors. + // If the getter throws, we'll rethrow but won't be stuck dirty. + this.dirty = false; + } } return this.cachedVal; diff --git a/src/reactivity/__spec__/observer.spec.ts b/src/reactivity/__spec__/observer.spec.ts index 406b3b4..89915c3 100644 --- a/src/reactivity/__spec__/observer.spec.ts +++ b/src/reactivity/__spec__/observer.spec.ts @@ -212,4 +212,88 @@ describe('Observer', () => { expect(newValues).toEqual([false, true]); expect(oldValues).toEqual([null, false]); }); + + describe('error handling', () => { + it('propagates errors from getter', () => { + const obs = Observer({ + get: () => { + throw new Error('getter error'); + }, + }); + + expect(() => obs.value).toThrow('getter error'); + }); + + it('does not get stuck in dirty state after error', () => { + let shouldThrow = true; + let callCount = 0; + const obs = Observer({ + get: () => { + callCount += 1; + if (shouldThrow) { + throw new Error('temporary error'); + } + + return 'success'; + }, + }); + + // First access throws + expect(() => obs.value).toThrow('temporary error'); + expect(callCount).toEqual(1); + + // Subsequent access should return cached value (undefined), not retry + shouldThrow = false; + expect(obs.value).toBeUndefined(); + expect(callCount).toEqual(1); // Not called again because dirty was reset + }); + + it('recovers when dependencies change after error', () => { + const tracked = Reactive({ shouldThrow: true }); + let callCount = 0; + const obs = Observer({ + get: () => { + callCount += 1; + if (tracked.shouldThrow) { + throw new Error('conditional error'); + } + + return 'success'; + }, + }); + + // First access throws + expect(() => obs.value).toThrow('conditional error'); + expect(callCount).toEqual(1); + + // Change dependency - should mark dirty again + tracked.shouldThrow = false; + + // Now it should work + expect(obs.value).toEqual('success'); + expect(callCount).toEqual(2); + }); + + it('does not corrupt observer stack on error', () => { + const tracked = Reactive({ value: 1 }); + + const failingObs = Observer({ + get: () => { + throw new Error('fail'); + }, + }); + + const workingObs = Observer({ + get: () => tracked.value * 2, + }); + + // Failing observer throws + expect(() => failingObs.value).toThrow('fail'); + + // Working observer should still work correctly + expect(workingObs.value).toEqual(2); + tracked.value = 5; + expect(workingObs.value).toEqual(10); + }); + }); }); From c9c585266026e181d5993acd5ee3bfe1f90bf493 Mon Sep 17 00:00:00 2001 From: Tyler Reardon Date: Wed, 17 Dec 2025 11:19:19 -0800 Subject: [PATCH 10/22] lintfix --- src/integrations/MadroneState.ts | 1 + src/integrations/MadroneVue3.ts | 4 ++-- src/reactivity/Observer.ts | 1 + src/reactivity/__spec__/observer.spec.ts | 2 ++ 4 files changed, 6 insertions(+), 2 deletions(-) diff --git a/src/integrations/MadroneState.ts b/src/integrations/MadroneState.ts index 50aee6e..bdfd662 100644 --- a/src/integrations/MadroneState.ts +++ b/src/integrations/MadroneState.ts @@ -122,6 +122,7 @@ export function describeProperty( options?: MadroneStateOptions ): PropertyDescriptor { type Atom = { value: unknown }; + const tg: Atom = { value: config.value }; const atom = Reactive(tg, { name, diff --git a/src/integrations/MadroneVue3.ts b/src/integrations/MadroneVue3.ts index 3e08cf1..a3fc4d0 100644 --- a/src/integrations/MadroneVue3.ts +++ b/src/integrations/MadroneVue3.ts @@ -46,9 +46,9 @@ const reactiveSet = (item: { value: number }) => { */ export interface MadroneVue3Options { /** Vue's `reactive()` function from 'vue' */ - reactive: (target: T) => unknown; + reactive: (target: T) => unknown, /** Vue's `toRaw()` function from 'vue' */ - toRaw: (proxy: T) => T; + toRaw: (proxy: T) => T, } /** diff --git a/src/reactivity/Observer.ts b/src/reactivity/Observer.ts index 85632e8..e8e21e0 100644 --- a/src/reactivity/Observer.ts +++ b/src/reactivity/Observer.ts @@ -152,6 +152,7 @@ class ObservableItem { private wrap(cb: () => CBType): CBType { GLOBAL_STACK.push(this); + try { return cb(); } finally { diff --git a/src/reactivity/__spec__/observer.spec.ts b/src/reactivity/__spec__/observer.spec.ts index 89915c3..2680a01 100644 --- a/src/reactivity/__spec__/observer.spec.ts +++ b/src/reactivity/__spec__/observer.spec.ts @@ -230,6 +230,7 @@ describe('Observer', () => { const obs = Observer({ get: () => { callCount += 1; + if (shouldThrow) { throw new Error('temporary error'); } @@ -254,6 +255,7 @@ describe('Observer', () => { const obs = Observer({ get: () => { callCount += 1; + if (tracked.shouldThrow) { throw new Error('conditional error'); } From 65cb7f7c6640e5ae3b67b273e1ee58a910f1684a Mon Sep 17 00:00:00 2001 From: Tyler Reardon Date: Wed, 17 Dec 2025 11:33:17 -0800 Subject: [PATCH 11/22] scheduling improvements --- src/reactivity/__spec__/scheduler.spec.ts | 116 ++++++++++++++++++++++ src/reactivity/global.ts | 15 +-- 2 files changed, 124 insertions(+), 7 deletions(-) create mode 100644 src/reactivity/__spec__/scheduler.spec.ts diff --git a/src/reactivity/__spec__/scheduler.spec.ts b/src/reactivity/__spec__/scheduler.spec.ts new file mode 100644 index 0000000..073d902 --- /dev/null +++ b/src/reactivity/__spec__/scheduler.spec.ts @@ -0,0 +1,116 @@ +import { describe, it, expect } from 'vitest'; +import { schedule } from '../global'; +import { delay } from '@/test/util'; + +describe('scheduler', () => { + it('executes tasks asynchronously', async () => { + const order: number[] = []; + + order.push(1); + schedule(() => order.push(3)); + order.push(2); + + expect(order).toEqual([1, 2]); + await delay(); + expect(order).toEqual([1, 2, 3]); + }); + + it('batches multiple tasks into one microtask', async () => { + const order: number[] = []; + + schedule(() => order.push(1)); + schedule(() => order.push(2)); + schedule(() => order.push(3)); + + expect(order).toEqual([]); + await delay(); + expect(order).toEqual([1, 2, 3]); + }); + + it('processes tasks scheduled during execution in same batch', async () => { + const order: number[] = []; + let batchCount = 0; + + // Track when batches start/end by scheduling a marker at the end + const markBatchEnd = () => { + batchCount += 1; + }; + + schedule(() => { + order.push(1); + // Schedule another task during execution + schedule(() => { + order.push(2); + // Schedule yet another task + schedule(() => { + order.push(3); + }); + }); + }); + schedule(markBatchEnd); + + await delay(); + + expect(order).toEqual([1, 2, 3]); + // All tasks should complete in one batch + expect(batchCount).toEqual(1); + }); + + it('handles deeply nested task scheduling', async () => { + const order: number[] = []; + const depth = 10; + + const scheduleNested = (n: number) => { + order.push(n); + if (n < depth) { + schedule(() => scheduleNested(n + 1)); + } + }; + + schedule(() => scheduleNested(1)); + + await delay(); + + expect(order).toEqual([1, 2, 3, 4, 5, 6, 7, 8, 9, 10]); + }); + + it('handles multiple tasks each scheduling follow-ups', async () => { + const order: string[] = []; + + schedule(() => { + order.push('a1'); + schedule(() => order.push('a2')); + }); + + schedule(() => { + order.push('b1'); + schedule(() => order.push('b2')); + }); + + schedule(() => { + order.push('c1'); + schedule(() => order.push('c2')); + }); + + await delay(); + + // Initial tasks run in order, then their follow-ups + expect(order).toEqual(['a1', 'b1', 'c1', 'a2', 'b2', 'c2']); + }); + + it('starts new batch after previous completes', async () => { + const order: number[] = []; + + schedule(() => order.push(1)); + + await delay(); + expect(order).toEqual([1]); + + // New batch after previous completed + schedule(() => order.push(2)); + + await delay(); + expect(order).toEqual([1, 2]); + }); + +}); diff --git a/src/reactivity/global.ts b/src/reactivity/global.ts index 5703e51..72b8f1d 100644 --- a/src/reactivity/global.ts +++ b/src/reactivity/global.ts @@ -85,13 +85,14 @@ const doTasksIfNeeded = (): void => { if (SCHEDULER_ID === null) { SCHEDULER_ID = Symbol('scheduler'); queueMicrotask(() => { - const queue = TASK_QUEUE; - - TASK_QUEUE = []; - - // Process tasks in O(n) instead of O(n²) from shift() - for (const task of queue) { - task(); + // Process until queue is truly empty, including tasks added during execution + while (TASK_QUEUE.length > 0) { + const queue = TASK_QUEUE; + TASK_QUEUE = []; + + for (const task of queue) { + task(); + } } SCHEDULER_ID = null; From 38a72a302f291464fc8c68e6d11043d04e5e8e6a Mon Sep 17 00:00:00 2001 From: Tyler Reardon Date: Wed, 17 Dec 2025 11:34:26 -0800 Subject: [PATCH 12/22] lintfix --- src/reactivity/__spec__/scheduler.spec.ts | 2 +- src/reactivity/global.ts | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/src/reactivity/__spec__/scheduler.spec.ts b/src/reactivity/__spec__/scheduler.spec.ts index 073d902..1506172 100644 --- a/src/reactivity/__spec__/scheduler.spec.ts +++ b/src/reactivity/__spec__/scheduler.spec.ts @@ -62,6 +62,7 @@ describe('scheduler', () => { const scheduleNested = (n: number) => { order.push(n); + if (n < depth) { schedule(() => scheduleNested(n + 1)); } @@ -112,5 +113,4 @@ describe('scheduler', () => { await delay(); expect(order).toEqual([1, 2]); }); - }); diff --git a/src/reactivity/global.ts b/src/reactivity/global.ts index 72b8f1d..3dcc3a0 100644 --- a/src/reactivity/global.ts +++ b/src/reactivity/global.ts @@ -88,6 +88,7 @@ const doTasksIfNeeded = (): void => { // Process until queue is truly empty, including tasks added during execution while (TASK_QUEUE.length > 0) { const queue = TASK_QUEUE; + TASK_QUEUE = []; for (const task of queue) { From ddc9793bb75d6e37ab44283278903b4cbbb3d5eb Mon Sep 17 00:00:00 2001 From: Tyler Reardon Date: Wed, 17 Dec 2025 11:40:15 -0800 Subject: [PATCH 13/22] observer memory leak fix --- src/reactivity/Observer.ts | 7 +- src/reactivity/__spec__/observer.spec.ts | 83 ++++++++++++++++++++++++ src/reactivity/global.ts | 25 ++++++- 3 files changed, 110 insertions(+), 5 deletions(-) diff --git a/src/reactivity/Observer.ts b/src/reactivity/Observer.ts index e8e21e0..a1ac11a 100644 --- a/src/reactivity/Observer.ts +++ b/src/reactivity/Observer.ts @@ -9,7 +9,7 @@ */ import { - OBSERVER_SYMBOL, dependTracker, observerClear, schedule, trackerChanged, + OBSERVER_SYMBOL, dependTracker, observerClearAll, schedule, trackerChanged, } from './global'; const GLOBAL_STACK: Array> = []; @@ -143,7 +143,7 @@ class ObservableItem { * @returns {void} */ dispose() { - observerClear(this, OBSERVER_SYMBOL); + observerClearAll(this); this.alive = false; this.dirty = false; this.cachedVal = undefined; @@ -183,6 +183,9 @@ class ObservableItem { const val = this.wrap(() => { if ((this.cache && this.dirty) || !this.cache) { + // Clear old dependencies before re-running to prevent stale deps + observerClearAll(this); + try { this.cachedVal = this.get(); } finally { diff --git a/src/reactivity/__spec__/observer.spec.ts b/src/reactivity/__spec__/observer.spec.ts index 2680a01..b40deaa 100644 --- a/src/reactivity/__spec__/observer.spec.ts +++ b/src/reactivity/__spec__/observer.spec.ts @@ -213,6 +213,89 @@ describe('Observer', () => { expect(oldValues).toEqual([null, false]); }); + describe('dependency cleanup', () => { + it('clears stale dependencies when they change dynamically', () => { + let aAccessCount = 0; + let bAccessCount = 0; + + const state = Reactive({ + useA: true, + get a() { + aAccessCount += 1; + return 1; + }, + get b() { + bAccessCount += 1; + return 2; + }, + }); + + const obs = Observer({ + get: () => (state.useA ? state.a : state.b), + }); + + // First run: depends on useA and a + expect(obs.value).toEqual(1); + expect(aAccessCount).toEqual(1); + expect(bAccessCount).toEqual(0); + + // Change to use b instead + state.useA = false; + expect(obs.value).toEqual(2); + expect(aAccessCount).toEqual(1); + expect(bAccessCount).toEqual(1); + + // Now changing a should NOT trigger recomputation + // (if deps were cleaned, a is no longer tracked) + aAccessCount = 0; + bAccessCount = 0; + + // Access value to confirm it's cached + expect(obs.value).toEqual(2); + expect(aAccessCount).toEqual(0); + expect(bAccessCount).toEqual(0); + }); + + it('does not retain references to unused reactive objects', () => { + const state1 = Reactive({ value: 1 }); + const state2 = Reactive({ value: 2 }); + const switcher = Reactive({ useFirst: true }); + + const obs = Observer({ + get: () => (switcher.useFirst ? state1.value : state2.value), + }); + + // First run: depends on switcher and state1 + expect(obs.value).toEqual(1); + + // Switch to state2 + switcher.useFirst = false; + expect(obs.value).toEqual(2); + + // Changing state1 should not affect the observer anymore + let recomputeCount = 0; + const obs2 = Observer({ + get: () => { + recomputeCount += 1; + return obs.value; + }, + }); + + expect(obs2.value).toEqual(2); + expect(recomputeCount).toEqual(1); + + // state1 change should not trigger obs or obs2 + state1.value = 100; + expect(obs2.value).toEqual(2); + expect(recomputeCount).toEqual(1); + + // state2 change should trigger both + state2.value = 200; + expect(obs2.value).toEqual(200); + expect(recomputeCount).toEqual(2); + }); + }); + describe('error handling', () => { it('propagates errors from getter', () => { const obs = Observer({ diff --git a/src/reactivity/global.ts b/src/reactivity/global.ts index 3dcc3a0..b7b1758 100644 --- a/src/reactivity/global.ts +++ b/src/reactivity/global.ts @@ -116,8 +116,8 @@ export const schedule = (task: () => void): void => { }; /** - * Clear all of the current dependencies an observer has - * @param obs the observable to clear it's dependencies + * Clear a specific dependency key for an observer + * @param obs the observable to clear dependencies for * @param key the key to clear */ export const observerClear = ( @@ -129,7 +129,7 @@ export const observerClear = ( if (trackers) { for (const trk of trackers) { - PROXY_TO_OBSERVERS.get(trk)?.delete(obs); + PROXY_TO_OBSERVERS.get(trk)?.get(key)?.delete(obs); } trackers.clear(); @@ -141,6 +141,25 @@ export const observerClear = ( } }; +/** + * Clear ALL dependencies for an observer. + * Called before re-running to ensure stale dependencies are removed. + * @param obs the observable to clear all dependencies for + */ +export const observerClearAll = (obs: ObservableItem): void => { + const proxies = OBSERVER_TO_PROXIES.get(obs); + + if (!proxies) return; + + for (const [key, trackers] of proxies) { + for (const trk of trackers) { + PROXY_TO_OBSERVERS.get(trk)?.get(key)?.delete(obs); + } + } + + OBSERVER_TO_PROXIES.delete(obs); +}; + /** * Make an observer depend on a trackable item * @param trk the trackable item we're depending on From 1f636f8015fc34897dd59bca2385e345dc806cbb Mon Sep 17 00:00:00 2001 From: Tyler Reardon Date: Wed, 17 Dec 2025 12:29:23 -0800 Subject: [PATCH 14/22] improve string type check speed --- src/reactivity/Reactive.ts | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/src/reactivity/Reactive.ts b/src/reactivity/Reactive.ts index 613b318..e9d5eff 100644 --- a/src/reactivity/Reactive.ts +++ b/src/reactivity/Reactive.ts @@ -75,9 +75,18 @@ export default function Reactive(target: T, options?: Reactive /** * Gets the type string for an object (e.g., 'object', 'array', 'set', 'map'). + * Uses fast instanceof checks instead of Object.prototype.toString. * @internal */ -Reactive.getStringType = (obj: unknown): string => Object.prototype.toString.call(obj).slice(8, -1).toLowerCase(); +Reactive.getStringType = (obj: unknown): string => { + // Fast path for non-objects - return type that won't match any handler + if (obj === null) return 'null'; + if (typeof obj !== 'object') return typeof obj; + if (Array.isArray(obj)) return 'array'; + if (obj instanceof Map) return 'map'; + if (obj instanceof Set) return 'set'; + return 'object'; +}; /** * Checks if a handler exists for the given type. From 450800feccb0d21f16d9968b8b962d03d1ac7968 Mon Sep 17 00:00:00 2001 From: Tyler Reardon Date: Wed, 17 Dec 2025 12:29:36 -0800 Subject: [PATCH 15/22] lintfix --- src/reactivity/Reactive.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/reactivity/Reactive.ts b/src/reactivity/Reactive.ts index e9d5eff..00bb2a9 100644 --- a/src/reactivity/Reactive.ts +++ b/src/reactivity/Reactive.ts @@ -81,9 +81,13 @@ export default function Reactive(target: T, options?: Reactive Reactive.getStringType = (obj: unknown): string => { // Fast path for non-objects - return type that won't match any handler if (obj === null) return 'null'; + if (typeof obj !== 'object') return typeof obj; + if (Array.isArray(obj)) return 'array'; + if (obj instanceof Map) return 'map'; + if (obj instanceof Set) return 'set'; return 'object'; }; From d2e07e6360d0894ef2aec850cdced3b12658e10d Mon Sep 17 00:00:00 2001 From: Tyler Reardon Date: Wed, 17 Dec 2025 12:37:31 -0800 Subject: [PATCH 16/22] avoid making objects if not needed --- src/reactivity/typeHandlers.ts | 72 +++++++++++++++++----------------- 1 file changed, 36 insertions(+), 36 deletions(-) diff --git a/src/reactivity/typeHandlers.ts b/src/reactivity/typeHandlers.ts index 6be338f..d240228 100644 --- a/src/reactivity/typeHandlers.ts +++ b/src/reactivity/typeHandlers.ts @@ -26,15 +26,15 @@ const makeOptions = (handlerOptions: TypeHandlerOptions) => { const optionGet = (options: ReactiveOptions, target, key, receiver) => { dependTarget(target, key); - options?.onGet?.( - makeOptions({ - name: options.name, - target, - key, - receiver, - }) - ); + + // Only allocate options object if callback exists + if (options?.onGet) { + options.onGet(makeOptions({ + name: options.name, target, key, receiver, + })); + } }; + const optionSet = (options: ReactiveOptions, target, key, value) => { const curr = target[key]; const isArray = Array.isArray(target); @@ -55,40 +55,33 @@ const optionSet = (options: ReactiveOptions, target, key, value) => { valueChanged = true; } - if (keysChanged || valueChanged) { - options?.onSet?.( - makeOptions({ - name: options.name, - target, - key, - value, - keysChanged, - valueChanged, - }) - ); + // Only allocate options object if callback exists and something changed + if ((keysChanged || valueChanged) && options?.onSet) { + options.onSet(makeOptions({ + name: options.name, target, key, value, keysChanged, valueChanged, + })); } }; + const optionDelete = (options: ReactiveOptions, target, key) => { targetChanged(target, key); targetChanged(target, KEYS_SYMBOL); - options?.onDelete?.( - makeOptions({ - name: options.name, - target, - key, - keysChanged: true, - }) - ); + + // Only allocate options object if callback exists + if (options?.onDelete) { + options.onDelete(makeOptions({ + name: options.name, target, key, keysChanged: true, + })); + } }; + const optionHasOwnKeys = (options: ReactiveOptions, target, key?) => { dependTarget(target, KEYS_SYMBOL); - options?.onHas?.( - makeOptions({ - name: options.name, - target, - key, - }) - ); + + // Only allocate options object if callback exists + if (options?.onHas) { + options.onHas(makeOptions({ name: options.name, target, key })); + } }; function defaultHandlers(options: ReactiveOptions) { @@ -100,9 +93,16 @@ function defaultHandlers(options: ReactiveOptions) { const value = Reflect.get(target, prop, receiver); + // Check conditions in order of cost (cheapest first) + // 1. deep mode enabled (property access) + // 2. value is an object (typeof check) + // 3. needsProxy allows it (function call, usually returns true) + // 4. property is configurable (expensive getOwnPropertyDescriptor) if ( - needsProxy({ target, key: prop, value }) - && options?.deep + options?.deep + && value + && typeof value === 'object' + && needsProxy({ target, key: prop, value }) && Object.getOwnPropertyDescriptor(target, prop)?.configurable ) { return Reactive(value, options); From e9f171dc3d25d17564ebc3e8536c403a2dfbae6b Mon Sep 17 00:00:00 2001 From: Tyler Reardon Date: Wed, 17 Dec 2025 14:14:09 -0800 Subject: [PATCH 17/22] reactivity fixes --- src/reactivity/Reactive.ts | 45 ++++++ src/reactivity/__spec__/reactive.spec.ts | 159 ++++++++++++++++++++++ src/reactivity/__spec__/scheduler.spec.ts | 79 ++++++++++- src/reactivity/global.ts | 29 ++-- 4 files changed, 301 insertions(+), 11 deletions(-) diff --git a/src/reactivity/Reactive.ts b/src/reactivity/Reactive.ts index 00bb2a9..817ac52 100644 --- a/src/reactivity/Reactive.ts +++ b/src/reactivity/Reactive.ts @@ -78,6 +78,22 @@ export default function Reactive(target: T, options?: Reactive * Uses fast instanceof checks instead of Object.prototype.toString. * @internal */ +/** + * Checks if an object is a plain object (created via {} or Object.create(null)). + * Class instances, built-in objects, and objects from external libraries + * should NOT be deeply proxied as they may have internal slots or methods + * that require the original object as `this`. + */ +const isPlainObject = (obj: object): boolean => { + const proto = Object.getPrototypeOf(obj); + + // Object.create(null) has no prototype + if (proto === null) return true; + + // Plain objects have Object.prototype as their prototype + return proto === Object.prototype; +}; + Reactive.getStringType = (obj: unknown): string => { // Fast path for non-objects - return type that won't match any handler if (obj === null) return 'null'; @@ -89,6 +105,35 @@ Reactive.getStringType = (obj: unknown): string => { if (obj instanceof Map) return 'map'; if (obj instanceof Set) return 'set'; + + // These types can't be safely proxied - their methods require the real object as `this` + // or they have internal slots that proxies can't access + if ( + obj instanceof Promise + || obj instanceof WeakMap + || obj instanceof WeakSet + || obj instanceof Date + || obj instanceof RegExp + || obj instanceof Error + // ArrayBuffer and typed arrays have internal slots + || obj instanceof ArrayBuffer + || ArrayBuffer.isView(obj) + ) { + return 'native'; + } + + // Check for DOM nodes (if in browser environment) + if (typeof Node !== 'undefined' && obj instanceof Node) { + return 'native'; + } + + // Only proxy plain objects - class instances from external libraries + // often have methods that require the real object as `this` or use + // internal slots that proxies can't access (e.g., socket.io, axios, etc.) + if (!isPlainObject(obj)) { + return 'native'; + } + return 'object'; }; diff --git a/src/reactivity/__spec__/reactive.spec.ts b/src/reactivity/__spec__/reactive.spec.ts index d6afd44..27b25f1 100644 --- a/src/reactivity/__spec__/reactive.spec.ts +++ b/src/reactivity/__spec__/reactive.spec.ts @@ -31,6 +31,165 @@ describe('Reactive', () => { expect(obs.nested === obs2).toEqual(true); }); + describe('class instance handling', () => { + it('does not wrap class instances in Proxy', () => { + class SomeClass { + value = 42; + } + + const instance = new SomeClass(); + const state = Reactive({ instance }); + + // The class instance should NOT be proxied (deep wrapping stops at class instances) + expect(isReactive(state.instance)).toBe(false); + expect(state.instance).toBe(instance); + expect(state.instance.value).toBe(42); + }); + + it('proxies plain objects but not nested class instances', () => { + class MyClass { + data = 'test'; + } + + const nested = new MyClass(); + const state = Reactive({ + plain: { value: 1 }, + classInstance: nested, + }); + + // Plain nested object IS proxied + expect(isReactive(state.plain)).toBe(true); + // Class instance is NOT proxied + expect(isReactive(state.classInstance)).toBe(false); + expect(state.classInstance).toBe(nested); + }); + + it('works with Object.create(null) objects', () => { + const nullProto = Object.create(null); + + nullProto.value = 1; + + const state = Reactive({ data: nullProto }); + + // Object.create(null) should be treated as plain object and proxied + expect(isReactive(state.data)).toBe(true); + }); + }); + + describe('built-in object handling', () => { + it('does not wrap Date in Proxy', () => { + const date = new Date(); + const state = Reactive({ date }); + + expect(isReactive(state.date)).toBe(false); + expect(state.date).toBe(date); + expect(state.date.getTime()).toBe(date.getTime()); + }); + + it('does not wrap RegExp in Proxy', () => { + const regex = /test/gi; + const state = Reactive({ regex }); + + expect(isReactive(state.regex)).toBe(false); + expect(state.regex).toBe(regex); + expect(state.regex.test('test')).toBe(true); + }); + + it('does not wrap Error in Proxy', () => { + const error = new Error('test error'); + const state = Reactive({ error }); + + expect(isReactive(state.error)).toBe(false); + expect(state.error).toBe(error); + expect(state.error.message).toBe('test error'); + }); + + it('does not wrap WeakMap in Proxy', () => { + const weakMap = new WeakMap(); + const key = {}; + + weakMap.set(key, 'value'); + + const state = Reactive({ weakMap }); + + expect(isReactive(state.weakMap)).toBe(false); + expect(state.weakMap).toBe(weakMap); + expect(state.weakMap.get(key)).toBe('value'); + }); + + it('does not wrap WeakSet in Proxy', () => { + const weakSet = new WeakSet(); + const item = {}; + + weakSet.add(item); + + const state = Reactive({ weakSet }); + + expect(isReactive(state.weakSet)).toBe(false); + expect(state.weakSet).toBe(weakSet); + expect(state.weakSet.has(item)).toBe(true); + }); + }); + + describe('Promise handling', () => { + it('does not wrap Promises in Proxy', () => { + const promise = Promise.resolve('test'); + const state = Reactive({ promise }); + + // The promise should be the same instance, not a proxy + expect(state.promise).toBe(promise); + expect(isReactive(state.promise)).toBe(false); + }); + + it('allows .then() to be called on nested Promises', async () => { + const state = Reactive({ + promise: Promise.resolve('success'), + }); + + const result = await state.promise.then((val) => val.toUpperCase()); + + expect(result).toBe('SUCCESS'); + }); + + it('allows Promise.all with nested Promises', async () => { + const state = Reactive({ + promises: [ + Promise.resolve(1), + Promise.resolve(2), + Promise.resolve(3), + ], + }); + + const results = await Promise.all(state.promises); + + expect(results).toEqual([1, 2, 3]); + }); + + it('allows async/await with nested Promises', async () => { + const state = Reactive({ + getData: () => Promise.resolve({ data: 'hello' }), + }); + + const result = await state.getData(); + + expect(result.data).toBe('hello'); + }); + + it('allows .catch() and .finally() on nested Promises', async () => { + let finallyCalled = false; + const state = Reactive({ + promise: Promise.reject(new Error('test error')), + }); + + const result = await state.promise + .catch((error) => error.message) + .finally(() => { finallyCalled = true; }); + + expect(result).toBe('test error'); + expect(finallyCalled).toBe(true); + }); + }); + describe('object', () => { it('creates nested Reactives', () => { const object = { one: { two: { string: 'hello' } } }; diff --git a/src/reactivity/__spec__/scheduler.spec.ts b/src/reactivity/__spec__/scheduler.spec.ts index 1506172..59600f4 100644 --- a/src/reactivity/__spec__/scheduler.spec.ts +++ b/src/reactivity/__spec__/scheduler.spec.ts @@ -1,4 +1,6 @@ -import { describe, it, expect } from 'vitest'; +import { + describe, it, expect, vi, beforeEach, afterEach, +} from 'vitest'; import { schedule } from '../global'; import { delay } from '@/test/util'; @@ -113,4 +115,79 @@ describe('scheduler', () => { await delay(); expect(order).toEqual([1, 2]); }); + + describe('error handling', () => { + let consoleErrorSpy: ReturnType; + + beforeEach(() => { + consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + }); + + afterEach(() => { + consoleErrorSpy.mockRestore(); + }); + + it('continues processing tasks after one throws', async () => { + const order: number[] = []; + + schedule(() => order.push(1)); + schedule(() => { throw new Error('task error'); }); + schedule(() => order.push(3)); + + await delay(); + + // Tasks before and after the error should still run + expect(order).toEqual([1, 3]); + expect(consoleErrorSpy).toHaveBeenCalledTimes(1); + }); + + it('allows scheduling new tasks after error', async () => { + const order: number[] = []; + + schedule(() => { throw new Error('first batch error'); }); + + await delay(); + + // Scheduler should recover and allow new tasks + schedule(() => order.push(1)); + schedule(() => order.push(2)); + + await delay(); + + expect(order).toEqual([1, 2]); + expect(consoleErrorSpy).toHaveBeenCalledTimes(1); + }); + + it('processes nested tasks after parent throws', async () => { + const order: number[] = []; + + schedule(() => { + order.push(1); + schedule(() => order.push(2)); + throw new Error('error after scheduling nested task'); + }); + schedule(() => order.push(3)); + + await delay(); + + // Task 1 ran, threw, but 2 and 3 should still run + expect(order).toEqual([1, 3, 2]); + expect(consoleErrorSpy).toHaveBeenCalledTimes(1); + }); + + it('handles multiple errors in same batch', async () => { + const order: number[] = []; + + schedule(() => order.push(1)); + schedule(() => { throw new Error('error 1'); }); + schedule(() => order.push(2)); + schedule(() => { throw new Error('error 2'); }); + schedule(() => order.push(3)); + + await delay(); + + expect(order).toEqual([1, 2, 3]); + expect(consoleErrorSpy).toHaveBeenCalledTimes(2); + }); + }); }); diff --git a/src/reactivity/global.ts b/src/reactivity/global.ts index b7b1758..153d46b 100644 --- a/src/reactivity/global.ts +++ b/src/reactivity/global.ts @@ -85,18 +85,27 @@ const doTasksIfNeeded = (): void => { if (SCHEDULER_ID === null) { SCHEDULER_ID = Symbol('scheduler'); queueMicrotask(() => { - // Process until queue is truly empty, including tasks added during execution - while (TASK_QUEUE.length > 0) { - const queue = TASK_QUEUE; - - TASK_QUEUE = []; - - for (const task of queue) { - task(); + try { + // Process until queue is truly empty, including tasks added during execution + while (TASK_QUEUE.length > 0) { + const queue = TASK_QUEUE; + + TASK_QUEUE = []; + + for (const task of queue) { + try { + task(); + } catch (error) { + // Log error but continue processing other tasks + // eslint-disable-next-line no-console + console.error('Unhandled error in scheduled task:', error); + } + } } + } finally { + // Always reset scheduler ID so future tasks can be scheduled + SCHEDULER_ID = null; } - - SCHEDULER_ID = null; }); } }; From 32369e12c7306a03eaf5b4b320e598af14e63171 Mon Sep 17 00:00:00 2001 From: Tyler Reardon Date: Wed, 17 Dec 2025 14:15:59 -0800 Subject: [PATCH 18/22] lintfix --- src/reactivity/__spec__/reactive.spec.ts | 1 + src/reactivity/__spec__/scheduler.spec.ts | 16 ++++++++++++---- 2 files changed, 13 insertions(+), 4 deletions(-) diff --git a/src/reactivity/__spec__/reactive.spec.ts b/src/reactivity/__spec__/reactive.spec.ts index 27b25f1..de1dd10 100644 --- a/src/reactivity/__spec__/reactive.spec.ts +++ b/src/reactivity/__spec__/reactive.spec.ts @@ -1,3 +1,4 @@ +/* eslint-disable max-classes-per-file */ import { describe, it, expect } from 'vitest'; import Reactive from '../Reactive'; import { isReactiveTarget, isReactive } from '../global'; diff --git a/src/reactivity/__spec__/scheduler.spec.ts b/src/reactivity/__spec__/scheduler.spec.ts index 59600f4..49518e5 100644 --- a/src/reactivity/__spec__/scheduler.spec.ts +++ b/src/reactivity/__spec__/scheduler.spec.ts @@ -131,7 +131,9 @@ describe('scheduler', () => { const order: number[] = []; schedule(() => order.push(1)); - schedule(() => { throw new Error('task error'); }); + schedule(() => { + throw new Error('task error'); + }); schedule(() => order.push(3)); await delay(); @@ -144,7 +146,9 @@ describe('scheduler', () => { it('allows scheduling new tasks after error', async () => { const order: number[] = []; - schedule(() => { throw new Error('first batch error'); }); + schedule(() => { + throw new Error('first batch error'); + }); await delay(); @@ -179,9 +183,13 @@ describe('scheduler', () => { const order: number[] = []; schedule(() => order.push(1)); - schedule(() => { throw new Error('error 1'); }); + schedule(() => { + throw new Error('error 1'); + }); schedule(() => order.push(2)); - schedule(() => { throw new Error('error 2'); }); + schedule(() => { + throw new Error('error 2'); + }); schedule(() => order.push(3)); await delay(); From 0a4ed5ee07951e2d0057d03a2f9a27357c36e2d5 Mon Sep 17 00:00:00 2001 From: Tyler Reardon Date: Wed, 17 Dec 2025 15:22:27 -0800 Subject: [PATCH 19/22] unit tests --- src/__spec__/global.spec.ts | 269 +++++++++++++++++++++++ src/__spec__/util.spec.ts | 193 ++++++++++++++++ src/reactivity/__spec__/observer.spec.ts | 225 +++++++++++++++++++ src/reactivity/__spec__/reactive.spec.ts | 268 ++++++++++++++++++++++ 4 files changed, 955 insertions(+) create mode 100644 src/__spec__/global.spec.ts create mode 100644 src/__spec__/util.spec.ts diff --git a/src/__spec__/global.spec.ts b/src/__spec__/global.spec.ts new file mode 100644 index 0000000..ec1c333 --- /dev/null +++ b/src/__spec__/global.spec.ts @@ -0,0 +1,269 @@ +import { + describe, it, expect, beforeEach, afterEach, +} from 'vitest'; +import { + addIntegration, + removeIntegration, + getIntegration, + getIntegrations, + toRaw, + objectAccessed, + lastAccessed, +} from '../global'; +import { Integration } from '../interfaces'; + +const noop = () => {}; +const noopWatcher = () => noop; +const emptyDescriptor = () => ({}); + +function createMockIntegration(overrides?: Partial): Integration { + return { + describeProperty: emptyDescriptor, + defineProperty: noop, + describeComputed: emptyDescriptor, + defineComputed: noop, + watch: noopWatcher, + ...overrides, + }; +} + +describe('integration registry', () => { + let mockIntegration: Integration; + let mockIntegration2: Integration; + + beforeEach(() => { + // Clear any existing integrations + for (const integration of getIntegrations()) { + removeIntegration(integration); + } + + mockIntegration = createMockIntegration(); + mockIntegration2 = createMockIntegration(); + }); + + afterEach(() => { + // Clean up + for (const integration of getIntegrations()) { + removeIntegration(integration); + } + }); + + describe('addIntegration', () => { + it('adds integration to registry', () => { + expect(getIntegrations()).toHaveLength(0); + + addIntegration(mockIntegration); + + expect(getIntegrations()).toHaveLength(1); + expect(getIntegrations()).toContain(mockIntegration); + }); + + it('sets added integration as current', () => { + addIntegration(mockIntegration); + + expect(getIntegration()).toBe(mockIntegration); + }); + + it('handles null/undefined gracefully', () => { + addIntegration(null as unknown as Integration); + addIntegration(undefined as unknown as Integration); + + expect(getIntegrations()).toHaveLength(0); + }); + + it('does not add duplicate integrations', () => { + addIntegration(mockIntegration); + addIntegration(mockIntegration); + + expect(getIntegrations()).toHaveLength(1); + }); + }); + + describe('removeIntegration', () => { + it('removes integration from registry', () => { + addIntegration(mockIntegration); + expect(getIntegrations()).toHaveLength(1); + + removeIntegration(mockIntegration); + + expect(getIntegrations()).toHaveLength(0); + }); + + it('updates current integration when removed', () => { + addIntegration(mockIntegration); + addIntegration(mockIntegration2); + expect(getIntegration()).toBe(mockIntegration2); + + removeIntegration(mockIntegration2); + + expect(getIntegration()).toBe(mockIntegration); + }); + + it('sets current to undefined when all removed', () => { + addIntegration(mockIntegration); + removeIntegration(mockIntegration); + + expect(getIntegration()).toBeUndefined(); + }); + }); + + describe('getIntegration', () => { + it('returns undefined when no integrations', () => { + expect(getIntegration()).toBeUndefined(); + }); + + it('returns the last added integration', () => { + addIntegration(mockIntegration); + addIntegration(mockIntegration2); + + expect(getIntegration()).toBe(mockIntegration2); + }); + }); + + describe('getIntegrations', () => { + it('returns empty array when no integrations', () => { + expect(getIntegrations()).toEqual([]); + }); + + it('returns all registered integrations', () => { + addIntegration(mockIntegration); + addIntegration(mockIntegration2); + + const integrations = getIntegrations(); + + expect(integrations).toHaveLength(2); + expect(integrations).toContain(mockIntegration); + expect(integrations).toContain(mockIntegration2); + }); + + it('returns a copy of the integrations array', () => { + addIntegration(mockIntegration); + + const integrations = getIntegrations(); + + integrations.push(mockIntegration2); + + expect(getIntegrations()).toHaveLength(1); + }); + }); +}); + +describe('toRaw', () => { + let mockIntegration: Integration; + + beforeEach(() => { + // Clear any existing integrations + for (const integration of getIntegrations()) { + removeIntegration(integration); + } + }); + + afterEach(() => { + for (const integration of getIntegrations()) { + removeIntegration(integration); + } + }); + + it('returns object as-is when no integration', () => { + const obj = { test: 1 }; + const result = toRaw(obj); + + expect(result).toBe(obj); + }); + + it('returns object as-is when integration has no toRaw', () => { + mockIntegration = createMockIntegration(); + addIntegration(mockIntegration); + + const obj = { test: 1 }; + const result = toRaw(obj); + + expect(result).toBe(obj); + }); + + it('uses integration toRaw when available', () => { + const rawObj = { original: true }; + const proxyObj = { proxied: true }; + + mockIntegration = createMockIntegration({ + toRaw: (obj) => (obj === proxyObj ? rawObj : obj), + }); + addIntegration(mockIntegration); + + expect(toRaw(proxyObj)).toBe(rawObj); + expect(toRaw(rawObj)).toBe(rawObj); + }); +}); + +describe('objectAccessed / lastAccessed', () => { + let mockIntegration: Integration; + + beforeEach(() => { + for (const integration of getIntegrations()) { + removeIntegration(integration); + } + + mockIntegration = createMockIntegration({ + toRaw: (obj) => obj, + }); + addIntegration(mockIntegration); + }); + + afterEach(() => { + for (const integration of getIntegrations()) { + removeIntegration(integration); + } + }); + + it('returns undefined for never-accessed objects', () => { + const obj = { test: 1 }; + + expect(lastAccessed(obj)).toBeUndefined(); + }); + + it('records access timestamp', () => { + const obj = { test: 1 }; + const before = Date.now(); + + objectAccessed(obj); + + const timestamp = lastAccessed(obj); + + expect(timestamp).toBeGreaterThanOrEqual(before); + expect(timestamp).toBeLessThanOrEqual(Date.now()); + }); + + it('updates timestamp on subsequent accesses', async () => { + const obj = { test: 1 }; + + objectAccessed(obj); + + const first = lastAccessed(obj); + + // Wait a small amount to ensure different timestamps + await new Promise((resolve) => { + setTimeout(resolve, 5); + }); + + objectAccessed(obj); + + const second = lastAccessed(obj); + + expect(second).toBeGreaterThan(first); + }); + + it('tracks different objects independently', () => { + const obj1 = { id: 1 }; + const obj2 = { id: 2 }; + + objectAccessed(obj1); + + expect(lastAccessed(obj1)).toBeDefined(); + expect(lastAccessed(obj2)).toBeUndefined(); + + objectAccessed(obj2); + + expect(lastAccessed(obj1)).toBeDefined(); + expect(lastAccessed(obj2)).toBeDefined(); + }); +}); diff --git a/src/__spec__/util.spec.ts b/src/__spec__/util.spec.ts new file mode 100644 index 0000000..e1cd270 --- /dev/null +++ b/src/__spec__/util.spec.ts @@ -0,0 +1,193 @@ +/* eslint-disable max-classes-per-file */ +import { describe, it, expect } from 'vitest'; +import { applyClassMixins, getDefaultDescriptors } from '../util'; + +describe('applyClassMixins', () => { + it('applies mixin methods to base class', () => { + class Timestamped { + getAge() { + return 'age'; + } + } + + class Base { + id = 'base'; + } + + applyClassMixins(Base, [Timestamped]); + + const instance = new Base() as Base & Timestamped; + + expect(instance.id).toBe('base'); + expect(instance.getAge()).toBe('age'); + }); + + it('applies multiple mixins', () => { + class MixinA { + methodA() { + return 'A'; + } + } + + class MixinB { + methodB() { + return 'B'; + } + } + + class Base { + baseMethod() { + return 'base'; + } + } + + applyClassMixins(Base, [MixinA, MixinB]); + + const instance = new Base() as Base & MixinA & MixinB; + + expect(instance.baseMethod()).toBe('base'); + expect(instance.methodA()).toBe('A'); + expect(instance.methodB()).toBe('B'); + }); + + it('preserves base class methods over mixin methods', () => { + class Mixin { + shared() { + return 'mixin'; + } + } + + class Base { + shared() { + return 'base'; + } + } + + applyClassMixins(Base, [Mixin]); + + const instance = new Base(); + + expect(instance.shared()).toBe('base'); + }); + + it('applies getters and setters from mixins', () => { + class Mixin { + private _value = 0; + + get computed() { + return this._value * 2; + } + + set computed(val: number) { + this._value = val; + } + } + + class Base { + name = 'base'; + } + + applyClassMixins(Base, [Mixin]); + + const instance = new Base() as Base & Mixin; + + instance.computed = 5; + expect(instance.computed).toBe(10); + }); + + it('works with empty mixins array', () => { + class Base { + value = 42; + } + + applyClassMixins(Base, []); + + const instance = new Base(); + + expect(instance.value).toBe(42); + }); +}); + +describe('getDefaultDescriptors', () => { + it('returns property descriptors with defaults', () => { + const obj = { + name: 'test', + value: 42, + }; + + const descriptors = getDefaultDescriptors(obj); + + expect(descriptors.name.value).toBe('test'); + expect(descriptors.name.configurable).toBe(true); + expect(descriptors.name.enumerable).toBe(false); + + expect(descriptors.value.value).toBe(42); + expect(descriptors.value.configurable).toBe(true); + expect(descriptors.value.enumerable).toBe(false); + }); + + it('applies custom defaults', () => { + const obj = { prop: 'value' }; + + const descriptors = getDefaultDescriptors(obj, { + enumerable: true, + writable: false, + }); + + expect(descriptors.prop.configurable).toBe(true); + expect(descriptors.prop.enumerable).toBe(true); + expect(descriptors.prop.writable).toBe(false); + }); + + it('handles getters and setters', () => { + let internal = 0; + const obj = { + get computed() { + return internal; + }, + set computed(val: number) { + internal = val; + }, + }; + + const descriptors = getDefaultDescriptors(obj); + + expect(typeof descriptors.computed.get).toBe('function'); + expect(typeof descriptors.computed.set).toBe('function'); + expect(descriptors.computed.configurable).toBe(true); + expect(descriptors.computed.enumerable).toBe(false); + }); + + it('handles symbols as keys', () => { + const sym = Symbol('test'); + const obj = { [sym]: 'symbol value' }; + + const descriptors = getDefaultDescriptors(obj); + + expect(descriptors[sym].value).toBe('symbol value'); + expect(descriptors[sym].configurable).toBe(true); + }); + + it('handles empty objects', () => { + const descriptors = getDefaultDescriptors({}); + + expect(Object.keys(descriptors).length).toBe(0); + }); + + it('can define properties on new object using returned descriptors', () => { + const source = { + name: 'original', + getValue() { + return 100; + }, + }; + + const descriptors = getDefaultDescriptors(source, { enumerable: true }); + const target = {}; + + Object.defineProperties(target, descriptors); + + expect((target as typeof source).name).toBe('original'); + expect((target as typeof source).getValue()).toBe(100); + }); +}); diff --git a/src/reactivity/__spec__/observer.spec.ts b/src/reactivity/__spec__/observer.spec.ts index b40deaa..c4ef8f4 100644 --- a/src/reactivity/__spec__/observer.spec.ts +++ b/src/reactivity/__spec__/observer.spec.ts @@ -381,4 +381,229 @@ describe('Observer', () => { expect(workingObs.value).toEqual(10); }); }); + + describe('cache: false', () => { + it('recomputes value on every access when cache is false', () => { + let counter = 0; + const obs = Observer({ + cache: false, + get: () => { + counter += 1; + return 'value'; + }, + }); + + expect(obs.value).toEqual('value'); + expect(obs.value).toEqual('value'); + expect(obs.value).toEqual('value'); + expect(counter).toEqual(3); + }); + + it('still tracks dependencies with cache false', () => { + let counter = 0; + const tracked = Reactive({ value: 1 }); + const obs = Observer({ + cache: false, + get: () => { + counter += 1; + return tracked.value * 2; + }, + }); + + expect(obs.value).toEqual(2); + expect(counter).toEqual(1); + + tracked.value = 5; + expect(obs.value).toEqual(10); + expect(counter).toEqual(2); + }); + + it('calls onChange when dependencies change with cache false', async () => { + const tracked = Reactive({ value: 1 }); + let changeCount = 0; + const obs = Observer({ + cache: false, + get: () => tracked.value, + onChange: () => { + changeCount += 1; + }, + }); + + expect(obs.value).toEqual(1); + tracked.value = 2; + expect(obs.value).toEqual(2); + await delay(); + expect(changeCount).toEqual(1); + }); + }); + + describe('hooks', () => { + describe('onGet', () => { + it('calls onGet when value is accessed', () => { + let getCalled = false; + const obs = Observer({ + get: () => 'value', + onGet: () => { + getCalled = true; + }, + }); + + expect(getCalled).toBe(false); + expect(obs.value).toEqual('value'); + expect(getCalled).toBe(true); + }); + + it('calls onGet on every access (cached)', () => { + let getCount = 0; + const obs = Observer({ + get: () => 'value', + onGet: () => { + getCount += 1; + }, + }); + + expect(obs.value).toEqual('value'); + expect(obs.value).toEqual('value'); + expect(obs.value).toEqual('value'); + expect(getCount).toEqual(3); + }); + + it('passes observer instance to onGet', () => { + let receivedObs = null; + const obs = Observer({ + get: () => 'value', + onGet: (o) => { + receivedObs = o; + }, + }); + + expect(obs.value).toEqual('value'); + expect(receivedObs).toBe(obs); + }); + }); + + describe('onImmediateChange', () => { + it('calls onImmediateChange synchronously when dependency changes', () => { + const tracked = Reactive({ value: 1 }); + let immediateCount = 0; + let changeCount = 0; + const obs = Observer({ + get: () => tracked.value, + onImmediateChange: () => { + immediateCount += 1; + }, + onChange: () => { + changeCount += 1; + }, + }); + + expect(obs.value).toEqual(1); + expect(immediateCount).toEqual(0); + expect(changeCount).toEqual(0); + + tracked.value = 2; + + // onImmediateChange is called synchronously + expect(immediateCount).toEqual(1); + // onChange is scheduled asynchronously + expect(changeCount).toEqual(0); + }); + + it('provides access to prev value in onImmediateChange', () => { + const tracked = Reactive({ value: 1 }); + let prevValue = null; + const obs = Observer({ + get: () => tracked.value, + onImmediateChange: (o) => { + prevValue = o.prev; + }, + }); + + expect(obs.value).toEqual(1); + tracked.value = 2; + expect(prevValue).toEqual(1); + }); + }); + }); + + describe('writable computed', () => { + it('allows setting value with custom setter', () => { + const data = Reactive({ firstName: 'John', lastName: 'Doe' }); + const obs = Observer({ + get: () => `${data.firstName} ${data.lastName}`, + set: (val: string) => { + const [first, last] = val.split(' '); + + data.firstName = first; + data.lastName = last; + }, + }); + + expect(obs.value).toEqual('John Doe'); + + obs.value = 'Jane Smith'; + + expect(obs.value).toEqual('Jane Smith'); + expect(data.firstName).toEqual('Jane'); + expect(data.lastName).toEqual('Smith'); + }); + + it('throws when setting value without setter', () => { + const obs = Observer({ + name: 'testComputed', + get: () => 'value', + }); + + expect(() => { + obs.value = 'new value'; + }).toThrow('No setter defined for "testComputed"'); + }); + + it('calls onSet hook when value is set', () => { + let setCalled = false; + let receivedObs = null; + const data = Reactive({ value: 1 }); + const obs = Observer({ + get: () => data.value, + set: (val: number) => { + data.value = val; + }, + onSet: (o) => { + setCalled = true; + receivedObs = o; + }, + }); + + expect(setCalled).toBe(false); + obs.value = 42; + expect(setCalled).toBe(true); + expect(receivedObs).toBe(obs); + }); + + it('updates cached value after set triggers dependency change', () => { + const data = Reactive({ value: 1 }); + const obs = Observer({ + get: () => data.value * 2, + set: (val: number) => { + data.value = val / 2; + }, + }); + + expect(obs.value).toEqual(2); + obs.value = 10; + expect(obs.value).toEqual(10); + expect(data.value).toEqual(5); + }); + }); + + describe('name property', () => { + it('stores name for debugging', () => { + const obs = Observer({ + name: 'myComputed', + get: () => 'value', + }); + + expect(obs.name).toEqual('myComputed'); + }); + }); }); diff --git a/src/reactivity/__spec__/reactive.spec.ts b/src/reactivity/__spec__/reactive.spec.ts index de1dd10..286a296 100644 --- a/src/reactivity/__spec__/reactive.spec.ts +++ b/src/reactivity/__spec__/reactive.spec.ts @@ -253,4 +253,272 @@ describe('Reactive', () => { expect(valueArray).toEqual(['hello world', { foobar: 'baz' }, 'baz']); }); }); + + describe('onDelete callback', () => { + it('calls onDelete when property is deleted', () => { + let deleteCount = 0; + let deletedKey = null; + const obj = { a: 1, b: 2 }; + const state = Reactive<{ a?: number, b?: number }>(obj, { + onDelete: ({ key }) => { + deleteCount += 1; + deletedKey = key; + }, + }); + + delete state.a; + + expect(deleteCount).toBe(1); + expect(deletedKey).toBe('a'); + expect(state.a).toBeUndefined(); + }); + + it('provides keysChanged flag in onDelete', () => { + let keysChanged = false; + const obj = { a: 1 }; + const state = Reactive<{ a?: number }>(obj, { + onDelete: (opts) => { + keysChanged = opts.keysChanged; + }, + }); + + delete state.a; + + expect(keysChanged).toBe(true); + }); + }); + + describe('onHas callback', () => { + it('calls onHas when checking property existence with "in"', () => { + let hasCount = 0; + const obj = { a: 1 }; + const state = Reactive(obj, { + onHas: () => { + hasCount += 1; + }, + }); + + expect('a' in state).toBe(true); + expect(hasCount).toBe(1); + + expect('b' in state).toBe(false); + expect(hasCount).toBe(2); + }); + + it('calls onHas when using Object.keys', () => { + let hasCount = 0; + const obj = { a: 1, b: 2 }; + const state = Reactive(obj, { + onHas: () => { + hasCount += 1; + }, + }); + + const keys = Object.keys(state); + + expect(keys).toEqual(['a', 'b']); + expect(hasCount).toBe(1); + }); + }); + + describe('custom needsProxy', () => { + it('allows custom needsProxy function to prevent proxying', () => { + const obj = { + shouldProxy: { value: 1 }, + shouldNotProxy: { value: 2 }, + }; + const state = Reactive(obj, { + deep: true, + needsProxy: ({ key }) => key !== 'shouldNotProxy', + }); + + expect(isReactive(state.shouldProxy)).toBe(true); + expect(isReactive(state.shouldNotProxy)).toBe(false); + }); + + it('needsProxy receives target, key, and value', () => { + let receivedTarget = null; + let receivedKey = null; + let receivedValue = null; + const nested = { inner: true }; + const obj = { nested }; + const state = Reactive(obj, { + deep: true, + needsProxy: ({ target, key, value }) => { + receivedTarget = target; + receivedKey = key; + receivedValue = value; + return true; + }, + }); + + // Trigger the needsProxy check by accessing nested + expect(state.nested).toBeDefined(); + expect(receivedTarget).toBe(obj); + expect(receivedKey).toBe('nested'); + expect(receivedValue).toBe(nested); + }); + }); + + describe('symbols as keys', () => { + it('tracks symbol keys', () => { + const sym = Symbol('test'); + const obj = { [sym]: 'value' }; + const state = Reactive(obj); + + expect(state[sym]).toBe('value'); + }); + + it('notifies observers when symbol key changes', () => { + const sym = Symbol('test'); + const obj = { [sym]: 1 }; + const state = Reactive(obj); + + expect(state[sym]).toBe(1); + state[sym] = 2; + expect(state[sym]).toBe(2); + }); + + it('handles well-known symbols correctly', () => { + const obj = { + [Symbol.toStringTag]: 'CustomObject', + }; + const state = Reactive(obj); + + expect(state[Symbol.toStringTag]).toBe('CustomObject'); + expect(Object.prototype.toString.call(state)).toBe('[object CustomObject]'); + }); + }); + + describe('frozen and sealed objects', () => { + it('does not deeply proxy frozen objects due to non-configurable properties', () => { + const frozen = Object.freeze({ value: 1 }); + const state = Reactive({ data: frozen }); + + // Frozen object's properties are not configurable, so deep proxying is prevented + // The frozen object itself is returned (not proxied further) + expect(isReactive(state)).toBe(true); + expect(state.data.value).toBe(1); + }); + + it('does not deeply proxy sealed objects due to non-configurable properties', () => { + const sealed = Object.seal({ value: 1 }); + const state = Reactive({ data: sealed }); + + // Sealed object's properties are not configurable, so deep proxying is prevented + expect(isReactive(state)).toBe(true); + expect(state.data.value).toBe(1); + }); + + it('handles objects with non-configurable properties', () => { + const obj = {}; + + Object.defineProperty(obj, 'fixed', { + value: 42, + configurable: false, + enumerable: true, + }); + + const state = Reactive({ data: obj }); + + // Non-configurable property should return raw value + expect(state.data.fixed).toBe(42); + }); + }); + + describe('shallow reactivity (deep: false)', () => { + it('does not proxy nested objects when deep is false', () => { + const nested = { inner: 1 }; + const obj = { nested }; + const state = Reactive(obj, { deep: false }); + + expect(isReactive(state)).toBe(true); + expect(isReactive(state.nested)).toBe(false); + expect(state.nested).toBe(nested); + }); + + it('still tracks top-level property changes with onSet', () => { + let setCount = 0; + const obj = { value: 1 }; + const state = Reactive(obj, { + deep: false, + onSet: () => { + setCount += 1; + }, + }); + + state.value = 2; + expect(setCount).toBe(1); + expect(state.value).toBe(2); + }); + }); + + describe('edge cases', () => { + it('handles null prototype objects', () => { + const nullProto = Object.create(null); + + nullProto.key = 'value'; + + const state = Reactive(nullProto); + + expect(state.key).toBe('value'); + expect(isReactive(state)).toBe(true); + }); + + it('handles objects with getters', () => { + let computeCount = 0; + const obj = { + _value: 1, + get computed() { + computeCount += 1; + return this._value * 2; + }, + }; + const state = Reactive(obj); + + expect(state.computed).toBe(2); + expect(computeCount).toBe(1); + state._value = 5; + expect(state.computed).toBe(10); + expect(computeCount).toBe(2); + }); + + it('handles objects with setters', () => { + const obj = { + _value: 1, + get value() { + return this._value; + }, + set value(v) { + this._value = v * 2; + }, + }; + const state = Reactive(obj); + + state.value = 5; + expect(state._value).toBe(10); + expect(state.value).toBe(10); + }); + + it('handles circular references', () => { + const obj: { self?: object, value: number } = { value: 1 }; + + obj.self = obj; + + const state = Reactive(obj); + + expect(state.value).toBe(1); + expect(state.self).toBe(state); + expect((state.self as typeof obj).value).toBe(1); + }); + + it('preserves array methods on reactive arrays', () => { + const arr = [1, 2, 3]; + const state = Reactive({ arr }); + + expect(state.arr.map((x) => x * 2)).toEqual([2, 4, 6]); + expect(state.arr.filter((x) => x > 1)).toEqual([2, 3]); + expect(state.arr.reduce((sum, x) => sum + x, 0)).toBe(6); + }); + }); }); From 23b115803a283bedbaf07b04126e3df568174039 Mon Sep 17 00:00:00 2001 From: Tyler Reardon Date: Wed, 17 Dec 2025 15:47:30 -0800 Subject: [PATCH 20/22] simplify string type check --- src/reactivity/Reactive.ts | 34 ++++------------------------------ 1 file changed, 4 insertions(+), 30 deletions(-) diff --git a/src/reactivity/Reactive.ts b/src/reactivity/Reactive.ts index 817ac52..f557a7b 100644 --- a/src/reactivity/Reactive.ts +++ b/src/reactivity/Reactive.ts @@ -95,7 +95,6 @@ const isPlainObject = (obj: object): boolean => { }; Reactive.getStringType = (obj: unknown): string => { - // Fast path for non-objects - return type that won't match any handler if (obj === null) return 'null'; if (typeof obj !== 'object') return typeof obj; @@ -106,35 +105,10 @@ Reactive.getStringType = (obj: unknown): string => { if (obj instanceof Set) return 'set'; - // These types can't be safely proxied - their methods require the real object as `this` - // or they have internal slots that proxies can't access - if ( - obj instanceof Promise - || obj instanceof WeakMap - || obj instanceof WeakSet - || obj instanceof Date - || obj instanceof RegExp - || obj instanceof Error - // ArrayBuffer and typed arrays have internal slots - || obj instanceof ArrayBuffer - || ArrayBuffer.isView(obj) - ) { - return 'native'; - } - - // Check for DOM nodes (if in browser environment) - if (typeof Node !== 'undefined' && obj instanceof Node) { - return 'native'; - } - - // Only proxy plain objects - class instances from external libraries - // often have methods that require the real object as `this` or use - // internal slots that proxies can't access (e.g., socket.io, axios, etc.) - if (!isPlainObject(obj)) { - return 'native'; - } - - return 'object'; + // Only proxy plain objects - class instances, built-ins (Date, Promise, etc.), + // and library objects have methods that require the real object as `this` + // or use internal slots that proxies can't access + return isPlainObject(obj) ? 'object' : 'native'; }; /** From 50c3cf9a08aca53442b445b1c6a5f3a7fbcce66d Mon Sep 17 00:00:00 2001 From: Tyler Reardon Date: Wed, 17 Dec 2025 15:51:03 -0800 Subject: [PATCH 21/22] fix imports --- src/decorate.ts | 6 +++--- src/global.ts | 12 ++++++------ src/integrations/MadroneState.ts | 4 ++-- src/integrations/MadroneVue3.ts | 12 ++++++------ src/integrations/vue.ts | 8 ++++---- src/reactivity/Computed.ts | 2 +- src/reactivity/Reactive.ts | 2 +- src/reactivity/Watcher.ts | 2 +- src/util.ts | 4 ++-- 9 files changed, 26 insertions(+), 26 deletions(-) diff --git a/src/decorate.ts b/src/decorate.ts index 0df31b6..1c9b3ba 100644 --- a/src/decorate.ts +++ b/src/decorate.ts @@ -10,7 +10,7 @@ * * @example * ```ts - * import { reactive, computed } from 'madrone'; + * import { reactive, computed } from '@madronejs/core'; * * class Counter { * @reactive count = 0; @@ -160,7 +160,7 @@ function decorateComputed( * * @example * ```ts - * import { reactive, computed } from 'madrone'; + * import { reactive, computed } from '@madronejs/core'; * * class ShoppingCart { * @reactive items: Array<{ price: number }> = []; @@ -270,7 +270,7 @@ function decorateReactive(target: object, key: string, options?: DecoratorOption * * @example * ```ts - * import { reactive, computed, watch } from 'madrone'; + * import { reactive, computed, watch } from '@madronejs/core'; * * class User { * @reactive name = 'Anonymous'; diff --git a/src/global.ts b/src/global.ts index 46a6041..9db4eeb 100644 --- a/src/global.ts +++ b/src/global.ts @@ -27,7 +27,7 @@ let CURRENT_INTEGRATION: Integration; * * @example * ```ts - * import { getIntegrations } from 'madrone'; + * import { getIntegrations } from '@madronejs/core'; * * const integrations = getIntegrations(); * console.log(`${integrations.length} integrations registered`); @@ -59,7 +59,7 @@ function setCurrentIntegration(): void { * * @example * ```ts - * import Madrone, { MadroneState } from 'madrone'; + * import Madrone, { MadroneState } from '@madronejs/core'; * * // Register the built-in state integration * Madrone.use(MadroneState); @@ -85,7 +85,7 @@ export function addIntegration(integration: Integration): void { * * @example * ```ts - * import { removeIntegration, MadroneState } from 'madrone'; + * import { removeIntegration, MadroneState } from '@madronejs/core'; * * // Remove when switching integrations or cleaning up * removeIntegration(MadroneState); @@ -107,7 +107,7 @@ export function removeIntegration(integration: Integration): void { * * @example * ```ts - * import { getIntegration } from 'madrone'; + * import { getIntegration } from '@madronejs/core'; * * const integration = getIntegration(); * if (!integration) { @@ -141,7 +141,7 @@ const STATS_ACCESS = new WeakMap(); * * @example * ```ts - * import { auto, toRaw } from 'madrone'; + * import { auto, toRaw } from '@madronejs/core'; * * const original = { count: 0 }; * const reactive = auto(original); @@ -180,7 +180,7 @@ export function objectAccessed(obj: object): void { * * @example * ```ts - * import { auto, lastAccessed } from 'madrone'; + * import { auto, lastAccessed } from '@madronejs/core'; * * const state = auto({ count: 0 }); * diff --git a/src/integrations/MadroneState.ts b/src/integrations/MadroneState.ts index bdfd662..46c0215 100644 --- a/src/integrations/MadroneState.ts +++ b/src/integrations/MadroneState.ts @@ -9,7 +9,7 @@ * * @example * ```ts - * import Madrone, { MadroneState, auto } from 'madrone'; + * import Madrone, { MadroneState, auto } from '@madronejs/core'; * * // Initialize with the integration * Madrone.use(MadroneState); @@ -201,7 +201,7 @@ export function defineProperty( * * @example * ```ts - * import Madrone, { MadroneState } from 'madrone'; + * import Madrone, { MadroneState } from '@madronejs/core'; * * Madrone.use(MadroneState); * ``` diff --git a/src/integrations/MadroneVue3.ts b/src/integrations/MadroneVue3.ts index a3fc4d0..2cf22db 100644 --- a/src/integrations/MadroneVue3.ts +++ b/src/integrations/MadroneVue3.ts @@ -9,8 +9,8 @@ * * @example * ```ts - * import Madrone from 'madrone'; - * import MadroneVue3 from 'madrone/integrations/MadroneVue3'; + * import Madrone from '@madronejs/core'; + * import { MadroneVue3 } from '@madronejs/core'; * import { reactive, toRaw } from 'vue'; * * // Initialize with Vue 3's reactive system @@ -69,13 +69,13 @@ export interface MadroneVue3Options { * @example * ```ts * // Option 1: Use the pre-configured module (recommended) - * import Madrone from 'madrone'; - * import MadroneVue from 'madrone/integrations/vue'; + * import Madrone from '@madronejs/core'; + * import { MadroneVue } from '@madronejs/core'; * Madrone.use(MadroneVue); * * // Option 2: Manual configuration - * import Madrone from 'madrone'; - * import createMadroneVue3 from 'madrone/integrations/MadroneVue3'; + * import Madrone from '@madronejs/core'; + * import { MadroneVue3 as createMadroneVue3 } from '@madronejs/core'; * import { reactive, toRaw } from 'vue'; * Madrone.use(createMadroneVue3({ reactive, toRaw })); * ``` diff --git a/src/integrations/vue.ts b/src/integrations/vue.ts index f168dd5..a57a74d 100644 --- a/src/integrations/vue.ts +++ b/src/integrations/vue.ts @@ -8,8 +8,8 @@ * * @example * ```ts - * import Madrone from 'madrone'; - * import MadroneVue from 'madrone/integrations/vue'; + * import Madrone from '@madronejs/core'; + * import { MadroneVue } from '@madronejs/core'; * * Madrone.use(MadroneVue); * @@ -28,8 +28,8 @@ import createMadroneVue3 from './MadroneVue3'; * * @example * ```ts - * import Madrone from 'madrone'; - * import MadroneVue from 'madrone/integrations/vue'; + * import Madrone from '@madronejs/core'; + * import { MadroneVue } from '@madronejs/core'; * * // Simple one-liner setup * Madrone.use(MadroneVue); diff --git a/src/reactivity/Computed.ts b/src/reactivity/Computed.ts index dba4368..9044170 100644 --- a/src/reactivity/Computed.ts +++ b/src/reactivity/Computed.ts @@ -23,7 +23,7 @@ import Observer, { ObservableOptions } from './Observer'; * * @example * ```ts - * import { Reactive, Computed } from 'madrone/reactivity'; + * import { Reactive, Computed } from '@madronejs/core'; * * const state = Reactive({ firstName: 'John', lastName: 'Doe' }); * diff --git a/src/reactivity/Reactive.ts b/src/reactivity/Reactive.ts index f557a7b..b671371 100644 --- a/src/reactivity/Reactive.ts +++ b/src/reactivity/Reactive.ts @@ -26,7 +26,7 @@ import { ReactiveOptions } from './interfaces'; * * @example * ```ts - * import { Reactive, Watcher } from 'madrone/reactivity'; + * import { Reactive, Watcher } from '@madronejs/core'; * * const state = Reactive({ count: 0, nested: { value: 1 } }); * diff --git a/src/reactivity/Watcher.ts b/src/reactivity/Watcher.ts index b0c3bd2..2b77ac6 100644 --- a/src/reactivity/Watcher.ts +++ b/src/reactivity/Watcher.ts @@ -24,7 +24,7 @@ import Observer from './Observer'; * * @example * ```ts - * import { Reactive, Watcher } from 'madrone/reactivity'; + * import { Reactive, Watcher } from '@madronejs/core'; * * const state = Reactive({ count: 0 }); * diff --git a/src/util.ts b/src/util.ts index 8ad8a27..8b08b9b 100644 --- a/src/util.ts +++ b/src/util.ts @@ -60,7 +60,7 @@ export type Spread = A extends [infer L, ...infer * * @example * ```ts - * import { merge } from 'madrone'; + * import { merge } from '@madronejs/core'; * * const base = { name: 'base', value: 1 }; * const override = { value: 2, extra: true }; @@ -108,7 +108,7 @@ type Constructor = new (...args: unknown[]) => object; * * @example * ```ts - * import { applyClassMixins } from 'madrone'; + * import { applyClassMixins } from '@madronejs/core'; * * class Timestamped { * createdAt = Date.now(); From 1df176148949ea6cc2324b33a3809b578eab13fd Mon Sep 17 00:00:00 2001 From: Tyler Reardon Date: Wed, 17 Dec 2025 16:04:38 -0800 Subject: [PATCH 22/22] update lockfile --- pnpm-lock.yaml | 38 +++++++------------------------------- 1 file changed, 7 insertions(+), 31 deletions(-) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5d26087..d426107 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -7,6 +7,10 @@ settings: importers: .: + dependencies: + vue: + specifier: ^3.0.0 + version: 3.5.13(typescript@5.9.3) devDependencies: '@eslint/compat': specifier: ~1.4.0 @@ -80,20 +84,11 @@ packages: resolution: {integrity: sha512-D2hP9eA+Sqx1kBZgzxZh0y1trbuU+JoDkiEwqhQ36nodYqJwyEIhPSdMNd7lOm/4io72luTPWH20Yda0xOuUow==} engines: {node: '>=6.9.0'} - '@babel/parser@7.27.2': - resolution: {integrity: sha512-QYLs8299NA7WM/bZAdp+CviYYkVoYXlDW2rzliy3chxd1PQjej7JORuMJDJXJUb9g0TT+B99EwaVLKmX+sPXWw==} - engines: {node: '>=6.0.0'} - hasBin: true - '@babel/parser@7.28.0': resolution: {integrity: sha512-jVZGvOxOuNSsuQuLRTh13nU0AogFlw32w/MT+LV6D3sP5WdbW61E77RnkbaO2dUvmPAYrBDJXGn5gGS6tH4j8g==} engines: {node: '>=6.0.0'} hasBin: true - '@babel/types@7.27.1': - resolution: {integrity: sha512-+EzkxvLNfiUeKMgy/3luqfsCWFRXLb7U6wNQTk60tovuckwB15B191tJWvpp4HjiQWdJkCxO3Wbvc6jlk3Xb2Q==} - engines: {node: '>=6.9.0'} - '@babel/types@7.28.1': resolution: {integrity: sha512-x0LvFTekgSX+83TI28Y9wYPUfzrnl2aT5+5QLnO6v7mSJYtEEevuDRN0F0uSHRk1G1IWZC43o00Y0xDDrpBGPQ==} engines: {node: '>=6.9.0'} @@ -1619,10 +1614,6 @@ packages: resolution: {integrity: sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==} engines: {node: '>= 0.4'} - postcss@8.5.3: - resolution: {integrity: sha512-dle9A3yYxlBSrt8Fu+IpjGT8SY8hN0mlaA6GY8t0P5PjIOZemULz/E2Bnm/2dcUOena75OTNkHI76uZBNUUq3A==} - engines: {node: ^10 || ^12 || >=14} - postcss@8.5.6: resolution: {integrity: sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==} engines: {node: ^10 || ^12 || >=14} @@ -2077,19 +2068,10 @@ snapshots: '@babel/helper-validator-identifier@7.27.1': {} - '@babel/parser@7.27.2': - dependencies: - '@babel/types': 7.27.1 - '@babel/parser@7.28.0': dependencies: '@babel/types': 7.28.1 - '@babel/types@7.27.1': - dependencies: - '@babel/helper-string-parser': 7.27.1 - '@babel/helper-validator-identifier': 7.27.1 - '@babel/types@7.28.1': dependencies: '@babel/helper-string-parser': 7.27.1 @@ -2577,7 +2559,7 @@ snapshots: '@vue/compiler-core@3.5.13': dependencies: - '@babel/parser': 7.27.2 + '@babel/parser': 7.28.0 '@vue/shared': 3.5.13 entities: 4.5.0 estree-walker: 2.0.2 @@ -2590,14 +2572,14 @@ snapshots: '@vue/compiler-sfc@3.5.13': dependencies: - '@babel/parser': 7.27.2 + '@babel/parser': 7.28.0 '@vue/compiler-core': 3.5.13 '@vue/compiler-dom': 3.5.13 '@vue/compiler-ssr': 3.5.13 '@vue/shared': 3.5.13 estree-walker: 2.0.2 magic-string: 0.30.17 - postcss: 8.5.3 + postcss: 8.5.6 source-map-js: 1.2.1 '@vue/compiler-ssr@3.5.13': @@ -3714,12 +3696,6 @@ snapshots: possible-typed-array-names@1.1.0: {} - postcss@8.5.3: - dependencies: - nanoid: 3.3.11 - picocolors: 1.1.1 - source-map-js: 1.2.1 - postcss@8.5.6: dependencies: nanoid: 3.3.11