diff --git a/AGENTS.md b/AGENTS.md index b750316..64423b5 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -40,7 +40,7 @@ Single-file core: `src/index.ts` (~1400 lines). Zero external dependencies. Prox ## Versioning Epoch Semantic Versioning: `{EPOCH * 1000 + MAJOR}.MINOR.PATCH` -- Current: v2001.0.0 (`package.json` and `jsr.json` must match) +- Current: v2001.0.1 (`package.json` and `jsr.json` must match) Commit format: `(): ` — imperative, present tense, no period, under 72 chars. Types: `epoch`, `breaking`, `feat`, `fix`, `perf`, `refactor`, `style`, `test`, `docs`, `chore` diff --git a/README.md b/README.md index 79ea167..938cc8f 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,7 @@ [![pm:jsr](https://img.shields.io/badge/jsr-F7DF1E?style=flat-square&logo=jsr&logoColor=black)](https://jsr.io/@nerdalytics/beacon) [![pm:vlt](https://img.shields.io/badge/vlt-1A1A2E?style=flat-square&logoColor=white)](https://vlt.sh/) [![registry:npm:version](https://img.shields.io/npm/v/@nerdalytics/beacon.svg)](https://www.npmjs.com/package/@nerdalytics/beacon) -[![Socket Badge](https://badge.socket.dev/npm/package/@nerdalytics/beacon/2001.0.0)](https://socket.dev/npm/package/@nerdalytics/beacon/overview/2001.0.0) +[![Socket Badge](https://badge.socket.dev/npm/package/@nerdalytics/beacon/2001.0.1)](https://socket.dev/npm/package/@nerdalytics/beacon/overview/2001.0.0) [![tech:nodejs](https://img.shields.io/badge/Node.js-339933?style=for-the-badge&logo=nodedotjs&logoColor=white)](https://nodejs.org/) [![language:typescript](https://img.shields.io/badge/TypeScript-007ACC?style=for-the-badge&logo=typescript&logoColor=white)](https://typescriptlang.org/) diff --git a/jsr.json b/jsr.json index ef84503..c89e942 100644 --- a/jsr.json +++ b/jsr.json @@ -10,5 +10,5 @@ "publish": { "include": ["LICENSE", "README.md", "src/**/*.ts"] }, - "version": "2001.0.0" + "version": "2001.0.1" } diff --git a/package-lock.json b/package-lock.json index 119c13b..ea2e85f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@nerdalytics/beacon", - "version": "2001.0.0", + "version": "2001.0.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@nerdalytics/beacon", - "version": "2001.0.0", + "version": "2001.0.1", "license": "MIT", "devDependencies": { "@biomejs/biome": "2.4.11", diff --git a/package.json b/package.json index 077bed7..053f9a2 100644 --- a/package.json +++ b/package.json @@ -69,6 +69,6 @@ }, "sideEffects": false, "type": "module", - "version": "2001.0.0", + "version": "2001.0.1", "types": "dist/src/index.d.ts" } diff --git a/scripts/benchmark.ts b/scripts/benchmark.ts index 6ea5d06..baac7b2 100644 --- a/scripts/benchmark.ts +++ b/scripts/benchmark.ts @@ -225,10 +225,7 @@ function stateReadNoEffect(): number { return performance.now() - start } -function stateWrite1Sub(): number { - const s = state({ - v: 0, - }) +function write1Sub(s: { v: number }): number { const cleanup: Unsubscribe = effect((): void => { void s.v }) @@ -241,6 +238,22 @@ function stateWrite1Sub(): number { return end - start } +const stateWrite1Sub = (): number => + write1Sub( + state({ + v: 0, + }) + ) + +const sealedWrite1Sub = (): number => + write1Sub( + state( + Object.seal({ + v: 0, + }) + ) + ) + function stateWrite100Subs(): number { const s = state({ v: 0, @@ -501,6 +514,17 @@ const suite: SuiteEntry[] = [ fn: disjointSubscribers, name: '100 subs disjoint props ', }, + { + header: '--- Non-extensible targets (subscriber set lives in the WeakMap fallback) ---', + }, + { + fn: sealedWrite1Sub, + name: 'sealed write 1 sub ', + }, + { + fn: stateNoSubscribers, + name: 'state no subs after seal ', + }, ] const totalSamples: number = RUN_COUNT * BENCH_RUNS diff --git a/src/AGENTS.md b/src/AGENTS.md index 21ace14..6708eca 100644 --- a/src/AGENTS.md +++ b/src/AGENTS.md @@ -32,6 +32,7 @@ Global mutable state driving the reactive system: | `settledCallbacks` | `Set` | `onSettled` subscribers | | `flushProcessed`, `settleProcessed` | `boolean` | Work happened since the last settle notification (a flush ran, or an async derive settled) | | `untilEvaluating`, `untilSawPending` | `boolean` | Set while an `until` predicate runs; records whether it read a pending derive | +| `hasFallbackSubs` | `boolean` | Armed when a target that carries `[PROXY]` (extensible at `state()` time) gets its subscriber set stored in `proxyCacheSubs`, i.e. it was sealed or frozen after `state()`; makes the hookless fast paths consult the WeakMap for every target without `[SUBSCRIBERS]`. Never reset | ## EffectFunction Properties @@ -65,7 +66,7 @@ Effect tracking data stored directly on the function object (O(1) property acces | Symbol | Purpose | |--------|---------| | `OWN_KEYS_SYMBOL` | Tracks key enumeration reads (ownKeys trap) | -| `SUBSCRIBERS` | Stores subscriber Set on target objects | +| `SUBSCRIBERS` | Stores the subscriber Set on target objects; absent until the first subscription and on targets that cannot take it (those keep the set in `proxyCacheSubs`) | | `PROXY` | Stores Proxy reference on target objects | | `HOOKS` | Stores StateHooks on extensible target objects; defined but never read (nested propagation is by closure) | @@ -77,7 +78,7 @@ Five handler factories, each returns a ProxyHandler method. `state()` without ho | Handler | Traps | Key behavior | |---------|-------|-------------| -| `createGetHandler()` | `get` | Delegates to `trackDependency` for reads, `getWrappedArrayMethod` for mutating methods, `resolveValue` for nested wrapping | +| `createGetHandler()` | `get` | Delegates to `trackDependency` for reads, `getWrappedArrayMethod` for mutating methods, `wrapNestedObject` for nested wrapping | | `createSetHandler()` | `set` | Delegates to `handleBatchFastPath` during batch, `performWrite` otherwise | | `createDeleteHandler()` | `deleteProperty` | Deletion with subscriber notification | | `createHasHandler()` | `has` | `in` operator tracking via `trackDependency` | @@ -92,13 +93,13 @@ Handler helpers: | `recordSilentRead` | Records into `rerunTempDeps`/`rerunTempReads` (and `buildingReadList`) without hooks or subscriber registration | | `readListMatchesRead` | Checks the next `[target, prop]` pair of the replayed read list | | `replayReadListToTempCollections` | Copies the matched read-list prefix into fresh temp collections when the fast path breaks | -| `resolveValue` | Wraps nested objects via `wrapNestedObject` → `state()` | +| `wrapNestedObject` | Returns the cached proxy for a nested object, or wraps it via `state()` | | `isInternalSymbol` | Guards access to `SUBSCRIBERS`, `PROXY`, `HOOKS` symbols | | `getWrappedArrayMethod` | Intercepts mutating array methods, delegates to cache strategies | | `wrapArrayMethod` | Wraps and caches a mutating array method in `arrayMethodCache` | | `performWrite` | Checks infinite loop, compares via `Object.is`, notifies subscribers | | `checkInfiniteLoop` | Throws if effect writes to a property it read (exempt: child effects, microtask effects) | -| `handleBatchFastPath` | Defers writes to `dirtyTargets` during batch (skips hooks + loop check) | +| `handleBatchFastPath` | Defers writes to `dirtyTargets` during batch (skips hooks + loop check); inlines the `findSubscribers` lookup | | `getArrayLengthBeforeMutation` | Captures array length before index assignment | | `notifyLengthChangeIfNeeded` | Notifies `length` subscribers if array grew/shrank | @@ -143,8 +144,7 @@ addPendingEffect(subscriber) |----------|---------| | `cleanupEffect` | Removes from subscribers, then `resetEffectTracking` | | `resetEffectTracking` | Dequeues from both pending sets, clears `__deps`/`__reads`/`__readList`/`__prevDeps`/`__prevReads` | -| `cleanupEffectCompletely` | `cleanupEffect` + iterative child cleanup (avoids stack overflow) | -| `cleanupChildEffect` | Cleans one child + pushes grandchildren onto iterative queue | +| `cleanupEffectCompletely` | One iterative loop over the effect and all descendants: `cleanupEffect`, unlink parent/children, deactivate (avoids stack overflow) | | `cleanupEffectOnError` | Emergency cleanup on error — removes from all subscriber sets | | `removeEffectFromSubscribers` | Removes effect from all subscriber sets in a dep set | @@ -163,8 +163,8 @@ addPendingEffect(subscriber) | Function | Purpose | |----------|---------| | `getSubscribers` | Resolves subscriber set: `[SUBSCRIBERS]` symbol → `proxyCacheSubs` → create new | -| `storeSubscriberSet` | Stores via `Object.defineProperty` (extensible) or `proxyCacheSubs` WeakMap (frozen) | -| `findSubscribers` | Read-only lookup: `[SUBSCRIBERS]` symbol or `proxyCacheSubs` | +| `storeSubscriberSet` | Stores via `Object.defineProperty` (extensible) or `proxyCacheSubs` WeakMap (non-extensible, or a Proxy target rejecting the define); arms `hasFallbackSubs` when a target that carries `[PROXY]` lands in the WeakMap | +| `findSubscribers` | Read-only lookup: `[SUBSCRIBERS]` symbol, else `proxyCacheSubs` only when `hasFallbackSubs` is set or the target has no `[PROXY]` (it never took symbols) | ### Batch Internals diff --git a/src/index.ts b/src/index.ts index 71eeb30..355509b 100644 --- a/src/index.ts +++ b/src/index.ts @@ -74,6 +74,9 @@ let microtaskFlushChain = 0 let effectErrorHandled = false let untilEvaluating = false let untilSawPending = false +// ponytail: process-wide flag, armed only by a target sealed or frozen after state() and before its first +// subscription; once armed, every hookless no-subscriber write pays a proxyCacheSubs miss (~+10% on that path) +let hasFallbackSubs = false // Proxy caching const proxyCache: WeakMap = new WeakMap() @@ -157,12 +160,15 @@ function storeSubscriberSet(target: object, subscriberSet: Set): value: subscriberSet, writable: false, }) - } else { - proxyCacheSubs.set(target, subscriberSet) + return } } catch { - proxyCacheSubs.set(target, subscriberSet) + // a Proxy target that rejects symbol defines } + proxyCacheSubs.set(target, subscriberSet) + // the hookless fast paths consult proxyCacheSubs only for targets without PROXY; a target that took PROXY at + // state() time and cannot take SUBSCRIBERS now was sealed or frozen in between, so arm the lookup for everyone + if ((target as ProxyObject)[PROXY] !== undefined) hasFallbackSubs = true } function getSubscribers(target: object): Set { @@ -298,8 +304,9 @@ function callHookSafe(hook: HookFunction | undefin } function findSubscribers(target: object): Set | undefined { - const t = target as SubscribersObject - return t[SUBSCRIBERS] ?? proxyCacheSubs.get(target) + const subs = (target as SubscribersObject)[SUBSCRIBERS] + if (subs !== undefined) return subs + return hasFallbackSubs || (target as ProxyObject)[PROXY] === undefined ? proxyCacheSubs.get(target) : undefined } function armMicrotaskFlush(): void { @@ -330,18 +337,13 @@ function armMicrotaskFlush(): void { } function addPendingEffect(subscriber: EffectFunction): void { - if (subscriber.__schedule === 'microtask') { - const prevMicro = pendingMicroEffects.size - pendingMicroEffects.add(subscriber) - if (pendingMicroEffects.size === prevMicro) return - callHookSafe(subscriber.__hooks?.onSchedule, subscriber.effectName) - armMicrotaskFlush() - return - } - const prevSize = pendingEffects.size - pendingEffects.add(subscriber) - if (pendingEffects.size === prevSize) return + const isMicro = subscriber.__schedule === 'microtask' + const queue = isMicro ? pendingMicroEffects : pendingEffects + const prevSize = queue.size + queue.add(subscriber) + if (queue.size === prevSize) return callHookSafe(subscriber.__hooks?.onSchedule, subscriber.effectName) + if (isMicro) armMicrotaskFlush() } function scheduleSubscriberWithProp(subscriber: EffectFunction, target: object, prop: PropertyKey): void { @@ -462,45 +464,20 @@ function cleanupEffect(effect: EffectFunction): void { resetEffectTracking(effect) } -function cleanupChildEffect(child: EffectFunction, toCleanup: EffectFunction[]): void { - cleanupEffect(child) - - const grandchildren = child.__children - if (grandchildren) { - for (const gc of grandchildren) toCleanup.push(gc) - grandchildren.clear() - child.__children = undefined - } - - child.__parent = undefined - child.__active = false -} - +// Iterative, not recursive: deep effect trees must not overflow the stack function cleanupEffectCompletely(effect: EffectFunction): void { - cleanupEffect(effect) - - const toCleanup: EffectFunction[] = [] - const children = effect.__children - if (children) { - for (const c of children) toCleanup.push(c) - children.clear() - effect.__children = undefined - } - - while (toCleanup.length > 0) { - cleanupChildEffect(toCleanup.pop() as EffectFunction, toCleanup) - } - - // Clean up parent relationship - const parent = effect.__parent - if (parent) { - const pchildren = parent.__children - pchildren?.delete(effect) + const pending: EffectFunction[] = [effect] + while (pending.length > 0) { + const eff = pending.pop() as EffectFunction + cleanupEffect(eff) + if (eff.__children) { + for (const c of eff.__children) pending.push(c) + eff.__children = undefined + } + eff.__parent?.__children?.delete(eff) + eff.__parent = undefined + eff.__active = false } - - // Final cleanup - effect.__parent = undefined - effect.__active = false } function setContainsAll(superset: Set, subset: Set): boolean { @@ -560,11 +537,6 @@ function createDeleteHandler( } } -function resolveValue(value: unknown, hooks: StateHooks | undefined): unknown { - if (value === null || typeof value !== 'object') return value - return wrapNestedObject(value as object, hooks) -} - function isInternalSymbol(prop: PropertyKey): boolean { return prop === SUBSCRIBERS || prop === PROXY || prop === HOOKS } @@ -622,7 +594,8 @@ function createGetHandler( const wrapped = getWrappedArrayMethod(rawTarget, prop, value) if (wrapped) return wrapped - return resolveValue(value, hooks as StateHooks | undefined) + if (value === null || typeof value !== 'object') return value + return wrapNestedObject(value as object, hooks as StateHooks | undefined) } } @@ -667,7 +640,12 @@ function handleBatchFastPath(rawTarget: ProxyTarget, prop: PropertyKey, value: u rawTarget[prop] = value - if (!(rawTarget as SubscribersObject)[SUBSCRIBERS]?.size) return true + // inlined, not findSubscribers: the call cost ~5% on the batch + derive benchmarks + let subs = (rawTarget as SubscribersObject)[SUBSCRIBERS] + if (subs === undefined && (hasFallbackSubs || (rawTarget as ProxyObject)[PROXY] === undefined)) { + subs = proxyCacheSubs.get(rawTarget) + } + if (!subs?.size) return true let props = dirtyTargets.get(rawTarget) if (!props) { @@ -703,7 +681,7 @@ function createSetHandler( if (!currentEffect) { const oldValue = rawTarget[prop] if (Object.is(oldValue, value)) return true - const subs = (rawTarget as SubscribersObject)[SUBSCRIBERS] + const subs = findSubscribers(rawTarget) if (!subs?.size) { rawTarget[prop] = value return true @@ -1124,13 +1102,11 @@ function clearBatchState(): void { function runDeferredEffects(): void { const len = deferredEffectCreations.length - if (len > 0) { - for (let i = 0; i < len; i++) { - const eff = deferredEffectCreations[i] - if (eff) eff() - } - deferredEffectCreations.length = 0 + for (let i = 0; i < len; i++) { + const eff = deferredEffectCreations[i] + if (eff) eff() } + deferredEffectCreations.length = 0 } function flushBatchEffects(): void { @@ -1214,13 +1190,15 @@ function isThenable(value: unknown): value is Promise { return typeof (value as { then?: unknown }).then === 'function' } +type DeriveInternal = { + lastValue: T | undefined | null + pending: boolean + value: T | undefined | null +} + function writeDeriveValue( - internalState: { - lastValue: T | undefined | null - }, - reactiveInternal: { - value: T | undefined | null - } | null, + internalState: DeriveInternal, + reactiveInternal: DeriveInternal | null, newValue: T ): void { if (!Object.is(newValue, internalState.lastValue)) { @@ -1232,12 +1210,8 @@ function writeDeriveValue( } function setDerivePending( - internalState: { - pending: boolean - }, - reactiveInternal: { - pending: boolean - } | null, + internalState: DeriveInternal, + reactiveInternal: DeriveInternal | null, pending: boolean ): void { if (internalState.pending === pending) return @@ -1251,14 +1225,8 @@ function setDerivePending( function computeDerive( computeFn: () => T | Promise, - internalState: { - lastValue: T | undefined | null - pending: boolean - }, - reactiveInternal: { - pending: boolean - value: T | undefined | null - } | null, + internalState: DeriveInternal, + reactiveInternal: DeriveInternal | null, onCompute: HookFunction<[T | undefined]> | undefined, onError: HookFunction<[Error]> | undefined, onAsync: (result: Promise) => void, @@ -1304,16 +1272,8 @@ export function derive(computeFn: () => T | Promise, hooks?: DeriveHooks { - if (dispose) return - reactiveInternal = state(internalState) - const internalEffectHooks: EffectHooks | undefined = onDependencyChange - ? { - onDependencyChange, - } - : undefined - dispose = effect( (): void => { if (!internalState.reactive || isComputing) return @@ -1354,23 +1314,13 @@ export function derive(computeFn: () => T | Promise, hooks?: DeriveHooks { - if (dispose) { - callHookSafe(onDeriveDispose) - dispose() - dispose = null - setDerivePending(internalState, reactiveInternal, false) - reactiveInternal = null - } - } - - if (internalState.reactive) { - createEffect() - } + createEffect() const readValue = (): T | undefined | null => { if (untilEvaluating) { @@ -1400,7 +1350,13 @@ export function derive(computeFn: () => T | Promise, hooks?: DeriveHooks( const cleanup = (): void => { if (timer !== undefined) clearTimeout(timer) if (signal) signal.removeEventListener('abort', onAbort) - const unsubscribe = stop - stop = (): void => {} - unsubscribe() + stop() } const fail = (reason: unknown): void => { if (done) return diff --git a/tests/AGENTS.md b/tests/AGENTS.md index c00197b..7f2724e 100644 --- a/tests/AGENTS.md +++ b/tests/AGENTS.md @@ -92,9 +92,9 @@ Test hooks instrumentation for each primitive. Each file verifies backward compa | File | Tests | Covers | |------|-------|--------| -| `state-hooks.test.ts` | 12 | `onRead`, `onWrite`, `onDelete`, `onHas`, `onOwnKeys`, nested propagation, array methods, multiple hooks | +| `state-hooks.test.ts` | 13 | `onRead`, `onWrite`, `onDelete`, `onHas`, `onOwnKeys`, nested propagation, nested proxy wrapping, array methods, multiple hooks | | `effect-hooks.test.ts` | 8 | `onRun`, `onDispose`, `onError`, `onDependencyAdd`, `onSchedule` | -| `derive-hooks.test.ts` | 8 | `onCompute`, `onCacheHit`, `onDispose`, `onError`, `onDependencyChange` | +| `derive-hooks.test.ts` | 11 | `onCompute`, `onCacheHit`, `onDispose`, `onError`, `onDependencyChange` | | `batch-hooks.test.ts` | 6 | `onBatchStart`, `onBatchEnd`, `onBatchError`, nested depth | | `hooks-compose.test.ts` | 7 | `composeHook` utility — undefined, single, array, forwarding, error isolation | diff --git a/tests/batch-integration.test.ts b/tests/batch-integration.test.ts index 80a82fb..5ac4cd7 100644 --- a/tests/batch-integration.test.ts +++ b/tests/batch-integration.test.ts @@ -162,6 +162,36 @@ describe('Batch Integration', { assert.deepStrictEqual(results, [2]) }) + it('runs effects created inside batch once at batch end and not again in later batches', (): void => { + const $state = state({ + value: 0, + }) + const runs: string[] = [] + let disposeA: () => void = (): void => {} + let disposeB: () => void = (): void => {} + + batch((): void => { + disposeA = effect((): void => { + runs.push(`a${$state.value}`) + }) + disposeB = effect((): void => { + runs.push(`b${$state.value}`) + }) + assert.deepStrictEqual(runs, []) + }) + + assert.deepStrictEqual(runs, ['a0', 'b0']) + + batch((): void => { + $state.value = 1 + }) + + assert.deepStrictEqual(runs, ['a0', 'b0', 'a1', 'b1']) + + disposeA() + disposeB() + }) + it('batches multiple state objects', (): void => { const $user = state({ age: 30, diff --git a/tests/cleanup.test.ts b/tests/cleanup.test.ts index 37608b9..284e255 100644 --- a/tests/cleanup.test.ts +++ b/tests/cleanup.test.ts @@ -87,6 +87,61 @@ describe('Cleanup', { assert.strictEqual(innerCount, 2) }) + it('cleans up grandchild effects when the root is disposed', (): void => { + const $root = state({ value: 0 }) + const $child = state({ value: 0 }) + const $grandchild = state({ value: 0 }) + const runs = { child: 0, grandchild: 0, root: 0 } + + const disposeRoot = effect((): void => { + runs.root++ + $root.value + effect((): void => { + runs.child++ + $child.value + effect((): void => { + runs.grandchild++ + $grandchild.value + }) + }) + }) + + $grandchild.value = 1 + assert.deepStrictEqual(runs, { child: 1, grandchild: 2, root: 1 }) + + disposeRoot() + + $root.value = 1 + $child.value = 1 + $grandchild.value = 2 + assert.deepStrictEqual(runs, { child: 1, grandchild: 2, root: 1 }) + }) + + it('disposes a nested effect while its parent stays active', (): void => { + const $parent = state({ value: 0 }) + const $child = state({ value: 0 }) + const runs = { child: 0, parent: 0 } + let disposeChild: () => void = (): void => undefined + + const disposeParent = effect((): void => { + runs.parent++ + $parent.value + disposeChild = effect((): void => { + runs.child++ + $child.value + }) + }) + + disposeChild() + $child.value = 1 + assert.deepStrictEqual(runs, { child: 1, parent: 1 }) + + $parent.value = 1 + assert.deepStrictEqual(runs, { child: 2, parent: 2 }) + + disposeParent() + }) + it('handles multiple disposals safely', (): void => { const $state = state({ value: 0, diff --git a/tests/derive-hooks.test.ts b/tests/derive-hooks.test.ts index 6a27d45..8bca14d 100644 --- a/tests/derive-hooks.test.ts +++ b/tests/derive-hooks.test.ts @@ -59,6 +59,47 @@ describe('Derive Hooks', { assert.strictEqual(disposed, true) }) + it('re-creates and disposes one effect across reactive toggles', () => { + let computes = 0 + let disposals = 0 + const $s = state({ + count: 1, + }) + const $d = derive(() => $s.count * 10, { + onCompute: () => { + computes++ + }, + onDispose: () => { + disposals++ + }, + }) + assert.strictEqual(computes, 1) + + $d.reactive = true + assert.strictEqual(computes, 1) + + $d.reactive = false + $d.reactive = false + assert.strictEqual(disposals, 1) + $s.count = 2 + assert.strictEqual(computes, 1) + assert.strictEqual($d.value, 10) + + $d.reactive = true + $d.reactive = true + assert.strictEqual(computes, 2) + assert.strictEqual($d.value, 20) + $s.count = 3 + assert.strictEqual(computes, 3) + assert.strictEqual($d.value, 30) + + $d.reactive = false + assert.strictEqual(disposals, 2) + $s.count = 4 + assert.strictEqual(computes, 3) + assert.strictEqual($d.value, 30) + }) + it('fires onError when computeFn throws and re-throws', () => { const errors: Error[] = [] assert.throws( @@ -101,6 +142,47 @@ describe('Derive Hooks', { $d.reactive = false }) + it('fires every onDependencyChange in an array after re-enabling reactivity', () => { + const changes: string[] = [] + const $s = state({ + count: 0, + }) + const $d = derive(() => $s.count * 2, { + onDependencyChange: [ + (_target: object, prop: PropertyKey) => { + changes.push(`a:${String(prop)}`) + }, + (_target: object, prop: PropertyKey) => { + changes.push(`b:${String(prop)}`) + }, + ], + }) + $d.reactive = false + $d.reactive = true + changes.length = 0 + $s.count = 1 + assert.deepStrictEqual(changes, ['a:count', 'b:count']) + assert.strictEqual($d.value, 2) + $d.reactive = false + }) + + it('recomputes on dependency writes when hooks omit onDependencyChange', () => { + const computed: (number | undefined)[] = [] + const $s = state({ + count: 1, + }) + const $d = derive(() => $s.count * 10, { + onCompute: (prev: number | undefined) => { + computed.push(prev) + }, + }) + $s.count = 2 + $s.count = 3 + assert.deepStrictEqual(computed, [undefined, 10, 20]) + assert.strictEqual($d.value, 30) + $d.reactive = false + }) + it('isolates hook errors from derive functionality', () => { const $s = state({ count: 1, diff --git a/tests/effect-scheduling.test.ts b/tests/effect-scheduling.test.ts index 08efc28..48795cd 100644 --- a/tests/effect-scheduling.test.ts +++ b/tests/effect-scheduling.test.ts @@ -309,6 +309,64 @@ describe('Effect Scheduling', { await turn() }) + it('onSchedule fires once per newly queued effect for sync and microtask effects', async (): Promise => { + const $s = state({ + a: 0, + b: 0, + }) + const scheduled: (string | undefined)[] = [] + const onSchedule = (name: string | undefined): void => { + scheduled.push(name) + } + let syncRuns = 0 + let microRuns = 0 + const stopSync = effect( + (): void => { + void $s.a + void $s.b + syncRuns++ + }, + 'sync', + { + onSchedule, + } + ) + const stopMicro = effect( + (): void => { + void $s.a + void $s.b + microRuns++ + }, + 'micro', + { + onSchedule, + }, + { + schedule: 'microtask', + } + ) + batch((): void => { + $s.a = 1 + $s.b = 1 + $s.a = 2 + }) + assert.deepEqual(scheduled, ['sync', 'micro']) + assert.equal(syncRuns, 2) + assert.equal(microRuns, 1) + $s.b = 2 + assert.deepEqual(scheduled, ['sync', 'micro', 'sync']) + assert.equal(syncRuns, 3) + await microtick() + assert.equal(microRuns, 2) + $s.a = 3 + assert.deepEqual(scheduled, ['sync', 'micro', 'sync', 'sync', 'micro']) + await microtick() + assert.equal(microRuns, 3) + stopSync() + stopMicro() + await turn() + }) + it('routes a microtask effect error to onError and keeps flushing its siblings', async (): Promise => { const $s = state({ n: 0, diff --git a/tests/property-deep-reactivity.test.ts b/tests/property-deep-reactivity.test.ts index ccada91..f22f271 100644 --- a/tests/property-deep-reactivity.test.ts +++ b/tests/property-deep-reactivity.test.ts @@ -6,7 +6,7 @@ import { batch, effect, state } from '../src/index.ts' /** * Property-based tests for deep reactivity at arbitrary nesting depths. * - * Beacon's proxy wrapping in resolveValue → wrapNestedObject is recursive: + * Beacon's proxy wrapping in the get handler → wrapNestedObject is recursive: * accessing a nested object property returns a proxy, which itself wraps * deeper objects on access. These tests verify that writing to the deepest * property at any nesting depth N triggers an effect reading that path. diff --git a/tests/property-frozen-sealed.test.ts b/tests/property-frozen-sealed.test.ts index 1e0e30e..5f201da 100644 --- a/tests/property-frozen-sealed.test.ts +++ b/tests/property-frozen-sealed.test.ts @@ -13,12 +13,10 @@ import { batch, derive, effect, state } from '../src/index.ts' * subscriber fallback path works correctly. * * Key behavior notes: - * - The set handler fast path (outside effects/batches) checks only - * rawTarget[SUBSCRIBERS], not proxyCacheSubs. So direct writes to - * sealed objects do not trigger effect notifications. - * - Writes from within effect execution go through performWrite → - * scheduleSubscribersForTarget → findSubscribers, which DOES check - * proxyCacheSubs. So cross-effect writes on sealed objects work. + * - Every write path resolves subscribers by checking rawTarget[SUBSCRIBERS] + * first and proxyCacheSubs second. So direct writes, batched writes and + * cross-effect writes to sealed objects all notify the effects that read + * the written property. * - Frozen objects cannot be written to (properties are non-writable). * * @see tests/PROPERTY_BASED_TESTING.md — Opportunity #11 @@ -272,8 +270,7 @@ describe('Property-Based: Frozen/Sealed Object Reactivity', { }) const $s = state(sealed) as SealedState - // Direct write outside any effect — the value DOES get stored - // even though effect notifications are skipped by the fast path + // Direct write outside any effect stores the value on the sealed target $s[key] = newValue assert.strictEqual($s[key], newValue, 'proxy should reflect updated value') assert.strictEqual(sealed[key], newValue, 'underlying sealed object should be updated') @@ -284,6 +281,152 @@ describe('Property-Based: Frozen/Sealed Object Reactivity', { ) }) + it('sealed objects: direct writes outside effects notify subscribers', (): void => { + fc.assert( + fc.property(shallowObjArb, intArb, (obj: SealedState, newValue: number): void => { + const key = firstKey(Object.keys(obj)) + fc.pre(!Object.is(obj[key], newValue)) + const $s = state(Object.seal({ ...obj })) as SealedState + + let runs = 0 + const dispose = effect((): void => { + runs++ + void $s[key] + }) + runs = 0 + + $s[key] = newValue + + assert.strictEqual(runs, 1, 'direct write should re-run the effect') + dispose() + }), + { + numRuns: 300, + } + ) + }) + + it('sealed objects: writes inside batch notify subscribers once after the batch', (): void => { + fc.assert( + fc.property(shallowObjArb, intArb, (obj: SealedState, newValue: number): void => { + const key = firstKey(Object.keys(obj)) + fc.pre(!Object.is(obj[key], newValue)) + const $s = state(Object.seal({ ...obj })) as SealedState + + let runs = 0 + const dispose = effect((): void => { + runs++ + void $s[key] + }) + runs = 0 + + batch((): void => { + $s[key] = newValue + assert.strictEqual(runs, 0, 'effect should not run inside the batch') + }) + + assert.strictEqual(runs, 1, 'batched write should re-run the effect exactly once') + dispose() + }), + { + numRuns: 300, + } + ) + }) + + it('sealed objects: writing an unchanged value does not notify', (): void => { + fc.assert( + fc.property(shallowObjArb, (obj: SealedState): void => { + const key = firstKey(Object.keys(obj)) + const $s = state(Object.seal({ ...obj })) as SealedState + + let runs = 0 + const dispose = effect((): void => { + runs++ + void $s[key] + }) + runs = 0 + + $s[key] = obj[key] as number + batch((): void => { + $s[key] = obj[key] as number + }) + + assert.strictEqual(runs, 0, 'unchanged value should not re-run the effect') + dispose() + }), + { + numRuns: 300, + } + ) + }) + + it('extensible targets that reject symbol properties notify via proxyCacheSubs', (): void => { + const target = new Proxy( + { a: 1 }, + { + defineProperty: (t: object, key: PropertyKey, desc: PropertyDescriptor): boolean => + typeof key !== 'symbol' && Reflect.defineProperty(t, key, desc), + } + ) + const $s = state(target) + + let runs = 0 + const dispose = effect((): void => { + runs++ + void $s.a + }) + runs = 0 + + $s.a = 2 + batch((): void => { + $s.a = 3 + }) + + assert.strictEqual(runs, 2, 'direct and batched writes should each re-run the effect') + dispose() + }) + + it('targets frozen after state() can still be subscribed', (): void => { + const raw = { a: 1 } + const $s = state(raw) + Object.freeze(raw) + + let runs = 0 + const dispose = effect((): void => { + runs++ + void $s.a + }) + + assert.strictEqual(runs, 1) + assert.strictEqual($s.a, 1) + dispose() + }) + + it('targets sealed after state() notify on direct and batched writes', (): void => { + const raw = { a: 1 } + const $s = state(raw) + Object.seal(raw) + + let runs = 0 + const dispose = effect((): void => { + runs++ + void $s.a + }) + runs = 0 + + $s.a = 2 + assert.strictEqual(runs, 1, 'direct write should re-run the effect') + + batch((): void => { + $s.a = 3 + $s.a = 4 + }) + assert.strictEqual(runs, 2, 'batched writes should re-run the effect once') + assert.strictEqual(raw.a, 4) + dispose() + }) + it('sealed arrays: mutating methods work via WeakMap method cache', (): void => { fc.assert( fc.property( diff --git a/tests/settle-observation.test.ts b/tests/settle-observation.test.ts index ccc2069..277149a 100644 --- a/tests/settle-observation.test.ts +++ b/tests/settle-observation.test.ts @@ -171,6 +171,63 @@ describe('Settle Observation', { await assert.rejects(p, /changed mind/) }) + it('until disposes its effect after the signal aborts', async (): Promise => { + const controller = new AbortController() + const $s = state({ + n: 0, + }) + const p = until((): boolean => $s.n > 0, { + signal: controller.signal, + }) + controller.abort(new Error('changed mind')) + await assert.rejects(p, /changed mind/) + let settled = 0 + const stopSettled = onSettled((): void => { + settled++ + }) + $s.n = 1 + await tick() + assert.equal(settled, 0) + stopSettled() + }) + + it('until disposes its effect after it times out', async (): Promise => { + const $s = state({ + n: 0, + }) + await assert.rejects( + until((): boolean => $s.n > 0, { + timeout: 20, + }), + /timed out/ + ) + let settled = 0 + const stopSettled = onSettled((): void => { + settled++ + }) + $s.n = 1 + await tick() + assert.equal(settled, 0) + stopSettled() + }) + + it('until disposes its effect after it resolves', async (): Promise => { + const $s = state({ + n: 0, + }) + const p = until((): boolean => $s.n > 0) + $s.n = 1 + assert.equal(await p, true) + let settled = 0 + const stopSettled = onSettled((): void => { + settled++ + }) + $s.n = 2 + await tick() + assert.equal(settled, 0) + stopSettled() + }) + it('onSettled keeps notifying other callbacks when one throws', async (): Promise => { const s = state({ n: 0, diff --git a/tests/state-hooks.test.ts b/tests/state-hooks.test.ts index 2da6e0a..490c14f 100644 --- a/tests/state-hooks.test.ts +++ b/tests/state-hooks.test.ts @@ -1,6 +1,6 @@ import assert from 'node:assert/strict' import { describe, it } from 'node:test' -import { state } from '../src/index.ts' +import { effect, state } from '../src/index.ts' describe('State Hooks', { concurrency: true, @@ -169,6 +169,39 @@ describe('State Hooks', { assert.deepStrictEqual(writes, ['name', 'theme']) }) + it('returns reactive nested proxies and passes primitives through when hooked', () => { + const reads: string[] = [] + const raw = { + count: 1, + empty: null, + label: 'a', + user: { + name: 'Alice', + }, + } + const $s = state(raw, { + onRead: (prop: PropertyKey) => { + reads.push(String(prop)) + }, + }) + + assert.strictEqual($s.count, 1) + assert.strictEqual($s.label, 'a') + assert.strictEqual($s.empty, null) + assert.notStrictEqual($s.user, raw.user) + assert.strictEqual($s.user, $s.user) + + const names: string[] = [] + const dispose = effect(() => { + names.push($s.user.name) + }) + $s.user.name = 'Bob' + dispose() + + assert.deepStrictEqual(names, ['Alice', 'Bob']) + assert.ok(reads.includes('name')) + }) + it('fires onRead when array mutating methods are accessed', () => { const reads: string[] = [] const $s = state(