diff --git a/.gitignore b/.gitignore index fd6ae34..d61228c 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,4 @@ node_modules /types /docs /coverage +.claude 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 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/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 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/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 a33d326..1c9b3ba 100644 --- a/src/decorate.ts +++ b/src/decorate.ts @@ -1,30 +1,85 @@ +/** + * @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 '@madronejs/core'; + * + * class Counter { + * @reactive count = 0; + * + * @computed get doubled() { + * return this.count * 2; + * } + * } + * ``` + */ + import { getIntegration } from '@/global'; 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>(); + +/** + * 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) { 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 +89,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 +106,7 @@ function computedIfNeeded( cache: true, }); setTargetObserved(target, key); + return true; } @@ -58,24 +114,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; }; @@ -87,18 +144,64 @@ 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 '@madronejs/core'; + * + * class ShoppingCart { + * @reactive items: Array<{ price: number }> = []; + * + * @computed get total() { + * return this.items.reduce((sum, item) => sum + item.price, 0); + * } + * + * @computed get isEmpty() { + * return this.items.length === 0; + * } + * } + * + * const cart = new ShoppingCart(); + * cart.items.push({ price: 10 }); + * console.log(cart.total); // 10 (computed once) + * console.log(cart.total); // 10 (cached, no recalculation) + * ``` */ -export function computed(target: any, key: string, descriptor: PropertyDescriptor) { +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: any, key: string, descriptor: PropertyDescriptor) => decorateComputed( + return (target: object, key: string, descriptor: PropertyDescriptor) => decorateComputed( target, key, descriptor, @@ -110,7 +213,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 +223,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 +239,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 +255,87 @@ 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 + * Decorator that makes a class property reactive. + * + * When the property value changes, any computed properties or watchers + * that depend on it will automatically update. By default, reactivity + * is deep - nested objects and arrays will also be reactive. + * + * The decorator lazily initializes reactivity on first access, making + * it efficient for classes with many properties that may not all be used. + * + * @param target - The class prototype (or constructor for static properties) + * @param key - The property name + * + * @example + * ```ts + * import { reactive, computed, watch } from '@madronejs/core'; + * + * class User { + * @reactive name = 'Anonymous'; + * @reactive preferences = { theme: 'dark' }; + * + * @computed get greeting() { + * return `Hello, ${this.name}!`; + * } + * } + * + * const user = new User(); + * + * watch( + * () => user.name, + * (name) => console.log(`Name changed to ${name}`) + * ); + * + * user.name = 'Alice'; // Triggers watcher, updates greeting + * user.preferences.theme = 'light'; // Deep reactivity works + * ``` */ -export function reactive(target: any, key: string) { +export function reactive(target: object, key: string): void { return decorateReactive(target, key); } -reactive.shallow = function configureReactive(target: any, key: string) { +/** + * 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: any, key: string) => decorateReactive(target, key, { descriptors: descriptorOverrides }); + return (target: object, key: string) => decorateReactive(target, key, { descriptors: descriptorOverrides }); }; diff --git a/src/global.ts b/src/global.ts index 2fe02b5..9db4eeb 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 '@madronejs/core'; + * + * 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 '@madronejs/core'; + * + * // 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 '@madronejs/core'; + * + * // 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 '@madronejs/core'; + * + * 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) { - const getRawItem = getIntegration()?.toRaw ?? (() => obj); +/** + * 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 '@madronejs/core'; + * + * 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 ?? ((o: T) => o); 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 '@madronejs/core'; + * + * 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 06a2d7e..46c0215 100644 --- a/src/integrations/MadroneState.ts +++ b/src/integrations/MadroneState.ts @@ -1,28 +1,76 @@ +/** + * @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 '@madronejs/core'; + * + * // Initialize with the integration + * Madrone.use(MadroneState); + * + * // Now use reactive features + * const state = auto({ count: 0 }); + * ``` + */ + 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 = { +/** + * 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, }; -export function describeComputed( +/** + * 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, 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 +78,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); }; } @@ -55,13 +105,26 @@ 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, options?: MadroneStateOptions -) { - const tg = { value: config.value }; - const atom = Reactive(tg, { +): PropertyDescriptor { + type Atom = { value: unknown }; + + const tg: Atom = { value: config.value }; + const atom = Reactive(tg, { name, onGet: options?.reactive?.onGet, onHas: options?.reactive?.onHas, @@ -74,7 +137,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,20 +149,63 @@ 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)); +/** + * 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, + 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)); +/** + * 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, + config: MadronePropertyDescriptor, + options?: IntegrationOptions +): void { + 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 '@madronejs/core'; + * + * Madrone.use(MadroneState); + * ``` + */ const MadroneState: Integration = { toRaw, watch: Watcher, @@ -110,4 +216,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..2cf22db 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 '@madronejs/core'; + * import { MadroneVue3 } from '@madronejs/core'; + * 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,59 @@ 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 { +/** + * 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. + * + * 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. + * + * 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 '@madronejs/core'; + * import { MadroneVue } from '@madronejs/core'; + * Madrone.use(MadroneVue); + * + * // Option 2: Manual configuration + * import Madrone from '@madronejs/core'; + * import { MadroneVue3 as createMadroneVue3 } from '@madronejs/core'; + * import { reactive, toRaw } from 'vue'; + * Madrone.use(createMadroneVue3({ reactive, toRaw })); + * ``` + */ +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>(); @@ -32,7 +106,7 @@ export default function MadroneVue3({ reactive, toRaw } = {} as any): Integratio let keyItem = item.get(key); if (!keyItem) { - keyItem = reactive({ [VALUE]: 0 }); + keyItem = reactive({ [VALUE]: 0 }) as { value: number }; item.set(key, keyItem); } @@ -88,25 +162,25 @@ export default function MadroneVue3({ reactive, toRaw } = {} as any): Integratio }, }; - 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..a57a74d --- /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 '@madronejs/core'; + * import { MadroneVue } from '@madronejs/core'; + * + * 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 '@madronejs/core'; + * import { MadroneVue } from '@madronejs/core'; + * + * // 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 f68c0a1..32f7c4d 100644 --- a/src/interfaces.ts +++ b/src/interfaces.ts @@ -1,58 +1,223 @@ +/** + * @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: any, + target: object, name: string, config: MadronePropertyDescriptor, - options?: any - ) => any, + 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: any, + target: object, name: string, config: MadroneComputedDescriptor, - options?: any - ) => any, - toRaw?: (target: T) => T, + 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: () => any, - handler: (val: T, old?: T) => any, + 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?: any + 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, - options?: any + options?: IntegrationOptions ) => PropertyDescriptor, } diff --git a/src/reactivity/Computed.ts b/src/reactivity/Computed.ts index 9c971b0..9044170 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 '@madronejs/core'; + * + * 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..a1ac11a 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, + OBSERVER_SYMBOL, dependTracker, observerClearAll, 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; @@ -74,20 +143,21 @@ class ObservableItem { * @returns {void} */ dispose() { - observerClear(this, OBSERVER_SYMBOL); + observerClearAll(this); this.alive = false; this.dirty = false; this.cachedVal = undefined; 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() { @@ -113,8 +183,16 @@ class ObservableItem { const val = this.wrap(() => { if ((this.cache && this.dirty) || !this.cache) { - this.cachedVal = this.get(); - this.dirty = false; + // Clear old dependencies before re-running to prevent stale deps + observerClearAll(this); + + 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; @@ -143,6 +221,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..b671371 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 '@madronejs/core'; + * + * 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 @@ -25,13 +66,59 @@ 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); 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'). + * 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 => { + 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'; + + // 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'; +}; + +/** + * 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..2b77ac6 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 '@madronejs/core'; + * + * 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/__spec__/observer.spec.ts b/src/reactivity/__spec__/observer.spec.ts index 406b3b4..c4ef8f4 100644 --- a/src/reactivity/__spec__/observer.spec.ts +++ b/src/reactivity/__spec__/observer.spec.ts @@ -212,4 +212,398 @@ describe('Observer', () => { expect(newValues).toEqual([false, true]); 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({ + 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); + }); + }); + + 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__/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.spec.ts b/src/reactivity/__spec__/reactive.spec.ts index d6afd44..286a296 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'; @@ -31,6 +32,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' } } }; @@ -93,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); + }); + }); }); 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/__spec__/scheduler.spec.ts b/src/reactivity/__spec__/scheduler.spec.ts new file mode 100644 index 0000000..49518e5 --- /dev/null +++ b/src/reactivity/__spec__/scheduler.spec.ts @@ -0,0 +1,201 @@ +import { + describe, it, expect, vi, beforeEach, afterEach, +} 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]); + }); + + 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 1c92ae2..153d46b 100644 --- a/src/reactivity/global.ts +++ b/src/reactivity/global.ts @@ -1,82 +1,144 @@ +/** + * @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'); -/** Mapping from target object to its proxy */ -const TARGET_TO_PROXY = new WeakMap(); -/** Mapping from proxy to the object it proxies */ -const PROXY_TO_TARGET = new WeakMap(); -/** Mapping from proxy to the observers that depend on it */ +type DependencyKey = string | symbol | ObservableItem; + +/** Mapping from target object to its reactive proxy */ +const TARGET_TO_PROXY = new WeakMap(); +/** Mapping from reactive proxy to its underlying target object */ +const PROXY_TO_TARGET = new WeakMap(); +/** Mapping from reactive proxy to the observers that depend on it */ const PROXY_TO_OBSERVERS = new WeakMap< object, - Map, Set>> + Map>> >(); -/** Mapping from observer to its dependencies */ +/** Mapping from observer to the proxies it depends on */ const OBSERVER_TO_PROXIES = new WeakMap< - ObservableItem, - Map, Set> + 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 */ -let SCHEDULER_ID = null; - -/** Check if the current target has a proxy associated with it */ -export const isReactiveTarget = (target) => 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); -/** Get the list of items that are observing a given proxy */ -export const getObservers = (tracker) => PROXY_TO_OBSERVERS.get(getProxy(tracker)); - -export const addReactive = (target, proxy) => { +/** Scheduler ID to prevent multiple schedulers from running */ +let SCHEDULER_ID: symbol | null = null; + +/** Checks if the given object has a reactive proxy associated with it */ +export const isReactiveTarget = (target: object): boolean => TARGET_TO_PROXY.has(target); + +/** 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); + +/** 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); }; -const doTasksIfNeeded = () => { + +const doTasksIfNeeded = (): void => { if (SCHEDULER_ID === null) { SCHEDULER_ID = Symbol('scheduler'); - setTimeout(() => { - const queue = TASK_QUEUE; - - TASK_QUEUE = []; - - while (queue.length > 0) { - queue.shift()(); + queueMicrotask(() => { + 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; }); } }; -export const schedule = (task) => { + +/** + * 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(); }; /** - * 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} + * 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 = ( - 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)?.get(key)?.delete(obs); } trackers.clear(); @@ -88,13 +150,31 @@ 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 {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 +209,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 +222,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 +242,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..5ca99c9 100644 --- a/src/reactivity/interfaces.ts +++ b/src/reactivity/interfaces.ts @@ -1,30 +1,74 @@ -export type TypeHandlerOptions = { +/** + * @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, - receiver?: any, + /** The Proxy receiver */ + receiver?: T, + /** The raw target object */ target?: T, - key?: keyof T, - value?: T[keyof 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, }; -export type HandlerHookType = (options: TypeHandlerOptions) => void; -export type CheckProxyHookType = (options: { +/** + * 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: keyof T, - value: T[keyof T], + key: PropertyKey, + value: unknown, }) => boolean; -export type ReactiveHandlerHooks = { +/** + * 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, }; -export type ReactiveOptions = { - name?: any, +/** + * 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/reactivity/typeHandlers.ts b/src/reactivity/typeHandlers.ts index 488eb38..d240228 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, @@ -34,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); @@ -63,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) { @@ -108,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); @@ -118,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); }, @@ -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 as object, options) as T; + } + 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: object) => { + // 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: unknown) => { + 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: unknown) => { + const hadValue = target.has(value); -// if (key === 'has') { -// return wrap(target, key, ({ getValue }) => { -// const value = getValue(); + target.add(value); -// dependTarget(target); -// options?.onGet?.(makeOptions({ options, target, value })); + if (!hadValue) { + targetChanged(target, KEYS_SYMBOL); + options?.onSet?.(makeOptions({ + name: options.name, + target, + key, + value, + keysChanged: true, + })); + } -// if (options?.deep) { -// return Reactive(value, options); -// } + return receiver; // Return proxy for chaining + }; + } + + // Handle delete - triggers change + if (key === 'delete') { + return (value: unknown) => { + const hadValue = target.has(value); + const deleted = target.delete(value); -// return value; -// }); -// } + if (hadValue) { + targetChanged(target, KEYS_SYMBOL); + options?.onDelete?.(makeOptions({ + name: options.name, + target, + key, + value, + keysChanged: true, + })); + } -// if (['add', 'clear', 'delete'].includes(key)) { -// const hookName = key === 'add' ? 'onSet' : 'onDelete'; + return deleted; + }; + } -// return wrap(target, key, ({ getValue }) => { -// const value = getValue(); + // Handle clear - triggers change + if (key === 'clear') { + return () => { + const hadValues = target.size > 0; -// targetChanged(target); -// options?.[hookName]?.(makeOptions({ options, target, value })); + target.clear(); -// return value; -// }); -// } + if (hadValues) { + targetChanged(target, KEYS_SYMBOL); + options?.onDelete?.(makeOptions({ + name: options.name, + target, + key, + keysChanged: true, + })); + } + }; + } -// if (key === 'forEach') { -// return wrap(target, key, ({ method, args }) => { -// const [cb] = args; + // Handle forEach - tracks dependency, wraps values + if (key === 'forEach') { + return (cb: (value: unknown, key: unknown, set: Set) => void, thisArg?: unknown) => { + dependTarget(target, KEYS_SYMBOL); + options?.onGet?.(makeOptions({ + name: options.name, + target, + key, + })); -// dependTarget(target); + for (const [val1, val2] of target.entries()) { + cb.call(thisArg, wrapIfDeep(val1, options), wrapIfDeep(val2, options), receiver); + } + }; + } -// method.call(target, (val1, val2, theSet) => { -// cb( -// Reactive(val1, options), -// Reactive(val2, options), -// Reactive(theSet, options) -// ); -// }); -// }); -// } + // 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 (key in proto) { -// return proto[key].bind(target); -// } + return reactiveIterator(target.values(), options, (v) => wrapIfDeep(v, options)); + }; + } -// return Reflect.get(target, key, receiver); -// }, -// }); -// const mapHandler = (options) => {}; -// const weaksetHandler = (options) => {}; -// const weakmapHandler = (options) => {}; + // Handle entries + if (key === 'entries') { + return () => { + dependTarget(target, KEYS_SYMBOL); + options?.onGet?.(makeOptions({ + name: options.name, + target, + key, + })); + + return reactiveIterator( + target.entries(), + options, + ([v1, v2]) => [wrapIfDeep(v1, options), wrapIfDeep(v2, 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, (v) => wrapIfDeep(v, options)); + }; + } + + // Handle Symbol.toStringTag + if (key === Symbol.toStringTag) { + return 'Set'; + } + + return Reflect.get(target, key, receiver); + }, +}); + +const mapHandler = (options: ReactiveOptions) => ({ + get: (target: Map, key: PropertyKey, receiver: object) => { + // 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 key + if (key === 'has') { + return (mapKey: unknown) => { + dependTarget(target, mapKey as string | symbol); + options?.onGet?.(makeOptions({ + name: options.name, + target, + key: mapKey as PropertyKey, + })); + + return target.has(mapKey); + }; + } + + // Handle get - tracks dependency on the key + if (key === 'get') { + return (mapKey: unknown) => { + dependTarget(target, mapKey as string | symbol); + options?.onGet?.(makeOptions({ + name: options.name, + target, + key: mapKey as PropertyKey, + })); + + return wrapIfDeep(target.get(mapKey), options); + }; + } + + // Handle set - triggers change + if (key === 'set') { + return (mapKey: unknown, value: unknown) => { + 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 as string | symbol); + options?.onSet?.(makeOptions({ + name: options.name, + target, + key: mapKey as PropertyKey, + value, + keysChanged: !hadKey, + valueChanged: oldValue !== value, + })); + } + + return receiver; // Return proxy for chaining + }; + } + + // Handle delete - triggers change + if (key === 'delete') { + return (mapKey: unknown) => { + const hadKey = target.has(mapKey); + const deleted = target.delete(mapKey); + + if (hadKey) { + targetChanged(target, KEYS_SYMBOL); + targetChanged(target, mapKey as string | symbol); + options?.onDelete?.(makeOptions({ + name: options.name, + target, + key: mapKey as PropertyKey, + 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 as string | symbol); + } + + options?.onDelete?.(makeOptions({ + name: options.name, + target, + key, + keysChanged: true, + })); + } + }; + } + + // Handle forEach - tracks dependency, wraps values + if (key === 'forEach') { + return (cb: (value: unknown, key: unknown, map: Map) => void, thisArg?: unknown) => { + 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, }); diff --git a/src/util.ts b/src/util.ts index 93efd1d..8b08b9b 100644 --- a/src/util.ts +++ b/src/util.ts @@ -1,5 +1,16 @@ +/** + * @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 = { - [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 = { @@ -8,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>> @@ -15,21 +33,58 @@ 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); + +/** + * 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 '@madronejs/core'; + * + * 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 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 +94,76 @@ 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. + * 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 '@madronejs/core'; + * + * 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: 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?) { +/** + * 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 +): 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; } } 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', }, }, });