From 06eabe9de31de8e9b02fd6441e959422ae6cb880 Mon Sep 17 00:00:00 2001 From: nerdalytics <97166791+nerdalytics@users.noreply.github.com> Date: Sun, 18 Oct 2026 20:54:33 +0200 Subject: [PATCH 01/21] test(derive): pin effect lifecycle across reactive toggles --- tests/derive-hooks.test.ts | 41 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 41 insertions(+) diff --git a/tests/derive-hooks.test.ts b/tests/derive-hooks.test.ts index 6a27d45..4e9437e 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( From 4689e41b8d285d7eb206f625e73baab704697b54 Mon Sep 17 00:00:00 2001 From: nerdalytics <97166791+nerdalytics@users.noreply.github.com> Date: Sun, 18 Oct 2026 20:54:37 +0200 Subject: [PATCH 02/21] test(derive): pin onDependencyChange forwarding to internal effect --- tests/AGENTS.md | 2 +- tests/derive-hooks.test.ts | 41 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 42 insertions(+), 1 deletion(-) diff --git a/tests/AGENTS.md b/tests/AGENTS.md index c00197b..f076d6a 100644 --- a/tests/AGENTS.md +++ b/tests/AGENTS.md @@ -94,7 +94,7 @@ Test hooks instrumentation for each primitive. Each file verifies backward compa |------|-------|--------| | `state-hooks.test.ts` | 12 | `onRead`, `onWrite`, `onDelete`, `onHas`, `onOwnKeys`, nested propagation, 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` | 10 | `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/derive-hooks.test.ts b/tests/derive-hooks.test.ts index 6a27d45..d19fe71 100644 --- a/tests/derive-hooks.test.ts +++ b/tests/derive-hooks.test.ts @@ -101,6 +101,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, From 6b38f1cd5025b707ddfaf79f65055f6b925848e5 Mon Sep 17 00:00:00 2001 From: nerdalytics <97166791+nerdalytics@users.noreply.github.com> Date: Sun, 18 Oct 2026 20:54:41 +0200 Subject: [PATCH 03/21] test(state): pin nested proxy wrapping in hooked get handler --- tests/AGENTS.md | 2 +- tests/state-hooks.test.ts | 35 ++++++++++++++++++++++++++++++++++- 2 files changed, 35 insertions(+), 2 deletions(-) diff --git a/tests/AGENTS.md b/tests/AGENTS.md index c00197b..bc7910e 100644 --- a/tests/AGENTS.md +++ b/tests/AGENTS.md @@ -92,7 +92,7 @@ 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` | | `batch-hooks.test.ts` | 6 | `onBatchStart`, `onBatchEnd`, `onBatchError`, nested depth | 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( From 97ec3c2893c8a6cac5f41561da8237007738b4af Mon Sep 17 00:00:00 2001 From: nerdalytics <97166791+nerdalytics@users.noreply.github.com> Date: Sun, 18 Oct 2026 20:54:55 +0200 Subject: [PATCH 04/21] test(batch): pin deferred effect creation across batches --- tests/batch-integration.test.ts | 30 ++++++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) 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, From 3d6891c76075793ca85a18dfad18b2bc5f02f75c Mon Sep 17 00:00:00 2001 From: nerdalytics <97166791+nerdalytics@users.noreply.github.com> Date: Sun, 18 Oct 2026 20:55:01 +0200 Subject: [PATCH 05/21] refactor(state): inline resolveValue into hooked get handler --- src/AGENTS.md | 4 ++-- src/index.ts | 8 ++------ tests/property-deep-reactivity.test.ts | 2 +- 3 files changed, 5 insertions(+), 9 deletions(-) diff --git a/src/AGENTS.md b/src/AGENTS.md index 21ace14..5a128a6 100644 --- a/src/AGENTS.md +++ b/src/AGENTS.md @@ -77,7 +77,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,7 +92,7 @@ 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` | diff --git a/src/index.ts b/src/index.ts index 71eeb30..e5748bf 100644 --- a/src/index.ts +++ b/src/index.ts @@ -560,11 +560,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 +617,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) } } 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. From a90810cc61064a8c446ad75ee0cb15d9623faefd Mon Sep 17 00:00:00 2001 From: nerdalytics <97166791+nerdalytics@users.noreply.github.com> Date: Sun, 18 Oct 2026 20:55:02 +0200 Subject: [PATCH 06/21] refactor(derive): drop always-true guards around effect lifecycle --- src/index.ts | 22 ++++++++-------------- 1 file changed, 8 insertions(+), 14 deletions(-) diff --git a/src/index.ts b/src/index.ts index 71eeb30..5f4e505 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1304,8 +1304,6 @@ export function derive(computeFn: () => T | Promise, hooks?: DeriveHooks { - if (dispose) return - reactiveInternal = state(internalState) const internalEffectHooks: EffectHooks | undefined = onDependencyChange @@ -1358,19 +1356,15 @@ export function derive(computeFn: () => T | Promise, hooks?: DeriveHooks { - if (dispose) { - callHookSafe(onDeriveDispose) - dispose() - dispose = null - setDerivePending(internalState, reactiveInternal, false) - reactiveInternal = null - } + const disposeEffect = (d: Unsubscribe): void => { + callHookSafe(onDeriveDispose) + d() + dispose = null + setDerivePending(internalState, reactiveInternal, false) + reactiveInternal = null } - if (internalState.reactive) { - createEffect() - } + createEffect() const readValue = (): T | undefined | null => { if (untilEvaluating) { @@ -1400,7 +1394,7 @@ export function derive(computeFn: () => T | Promise, hooks?: DeriveHooks Date: Sun, 18 Oct 2026 20:55:08 +0200 Subject: [PATCH 07/21] refactor(batch): drop empty-length guard in runDeferredEffects --- src/index.ts | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/src/index.ts b/src/index.ts index 71eeb30..c2255dc 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1124,13 +1124,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 { From 5749275e52c50e839d08da89f4e48f441fa49eb9 Mon Sep 17 00:00:00 2001 From: nerdalytics <97166791+nerdalytics@users.noreply.github.com> Date: Sun, 18 Oct 2026 20:54:58 +0200 Subject: [PATCH 08/21] refactor(derive): inline internal effect hooks at effect() call --- src/index.ts | 10 +++------- 1 file changed, 3 insertions(+), 7 deletions(-) diff --git a/src/index.ts b/src/index.ts index 71eeb30..6ddae04 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1308,12 +1308,6 @@ export function derive(computeFn: () => T | Promise, hooks?: DeriveHooks { if (!internalState.reactive || isComputing) return @@ -1354,7 +1348,9 @@ export function derive(computeFn: () => T | Promise, hooks?: DeriveHooks Date: Sun, 18 Oct 2026 20:55:33 +0200 Subject: [PATCH 09/21] test(core): pin until effect disposal on resolve, abort and timeout --- tests/settle-observation.test.ts | 57 ++++++++++++++++++++++++++++++++ 1 file changed, 57 insertions(+) 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, From 3a64e4a472154db0fc47f204bf51fd6afc8cdcbd Mon Sep 17 00:00:00 2001 From: nerdalytics <97166791+nerdalytics@users.noreply.github.com> Date: Sun, 18 Oct 2026 20:55:45 +0200 Subject: [PATCH 10/21] refactor(core): drop the stop swap in until cleanup --- src/index.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/index.ts b/src/index.ts index 71eeb30..121779e 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1456,9 +1456,7 @@ function runUntil( 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 From 11ba73d5542803a9b8b20dc2c346b7784d81c89f Mon Sep 17 00:00:00 2001 From: nerdalytics <97166791+nerdalytics@users.noreply.github.com> Date: Sun, 18 Oct 2026 20:54:51 +0200 Subject: [PATCH 11/21] test(effect): pin nested effect cleanup on disposal --- tests/cleanup.test.ts | 55 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 55 insertions(+) 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, From bc6fb8148b139854b3f3851eae3039f3fee01ac8 Mon Sep 17 00:00:00 2001 From: nerdalytics <97166791+nerdalytics@users.noreply.github.com> Date: Sun, 18 Oct 2026 20:57:45 +0200 Subject: [PATCH 12/21] refactor(effect): fold child cleanup into one iterative loop --- src/AGENTS.md | 3 +-- src/index.ts | 49 ++++++++++++------------------------------------- 2 files changed, 13 insertions(+), 39 deletions(-) diff --git a/src/AGENTS.md b/src/AGENTS.md index 21ace14..7ebd48d 100644 --- a/src/AGENTS.md +++ b/src/AGENTS.md @@ -143,8 +143,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 | diff --git a/src/index.ts b/src/index.ts index 71eeb30..b711834 100644 --- a/src/index.ts +++ b/src/index.ts @@ -462,45 +462,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 { From 6815f4490f394fd8054efa4285f0e226b86043f1 Mon Sep 17 00:00:00 2001 From: nerdalytics <97166791+nerdalytics@users.noreply.github.com> Date: Sun, 18 Oct 2026 21:48:53 +0200 Subject: [PATCH 13/21] refactor(derive): share DeriveInternal type across derive helpers --- src/index.ts | 32 ++++++++++++-------------------- 1 file changed, 12 insertions(+), 20 deletions(-) diff --git a/src/index.ts b/src/index.ts index 71eeb30..c986623 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1214,13 +1214,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: Pick, 'lastValue'>, + reactiveInternal: Pick, 'value'> | null, newValue: T ): void { if (!Object.is(newValue, internalState.lastValue)) { @@ -1232,12 +1234,8 @@ function writeDeriveValue( } function setDerivePending( - internalState: { - pending: boolean - }, - reactiveInternal: { - pending: boolean - } | null, + internalState: Pick, 'pending'>, + reactiveInternal: Pick, 'pending'> | null, pending: boolean ): void { if (internalState.pending === pending) return @@ -1251,14 +1249,8 @@ function setDerivePending( function computeDerive( computeFn: () => T | Promise, - internalState: { - lastValue: T | undefined | null - pending: boolean - }, - reactiveInternal: { - pending: boolean - value: T | undefined | null - } | null, + internalState: Pick, 'lastValue' | 'pending'>, + reactiveInternal: Pick, 'pending' | 'value'> | null, onCompute: HookFunction<[T | undefined]> | undefined, onError: HookFunction<[Error]> | undefined, onAsync: (result: Promise) => void, From f4cc3459947a95093d49737cb34dc4aefa7a6d31 Mon Sep 17 00:00:00 2001 From: nerdalytics <97166791+nerdalytics@users.noreply.github.com> Date: Sun, 18 Oct 2026 21:49:20 +0200 Subject: [PATCH 14/21] test(effect): pin onSchedule dedup for sync and microtask effects --- tests/effect-scheduling.test.ts | 58 +++++++++++++++++++++++++++++++++ 1 file changed, 58 insertions(+) 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, From 8dea2e870ca086445c41f38ac0dd717a92ec5a64 Mon Sep 17 00:00:00 2001 From: nerdalytics <97166791+nerdalytics@users.noreply.github.com> Date: Sun, 18 Oct 2026 21:49:51 +0200 Subject: [PATCH 15/21] refactor(effect): merge addPendingEffect queue branches --- src/index.ts | 17 ++++++----------- 1 file changed, 6 insertions(+), 11 deletions(-) diff --git a/src/index.ts b/src/index.ts index 71eeb30..0ae6a09 100644 --- a/src/index.ts +++ b/src/index.ts @@ -330,18 +330,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 { From 61e62a0237d9ceaf7a3b149755b8293656f70725 Mon Sep 17 00:00:00 2001 From: nerdalytics <97166791+nerdalytics@users.noreply.github.com> Date: Sun, 18 Oct 2026 22:13:31 +0200 Subject: [PATCH 16/21] docs(derive): update derive-hooks test count after merge --- tests/AGENTS.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/AGENTS.md b/tests/AGENTS.md index 81e0bd9..7f2724e 100644 --- a/tests/AGENTS.md +++ b/tests/AGENTS.md @@ -94,7 +94,7 @@ Test hooks instrumentation for each primitive. Each file verifies backward compa |------|-------|--------| | `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` | 10 | `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 | From 63eb432727cc0264d14f0403889d595efb52c36a Mon Sep 17 00:00:00 2001 From: nerdalytics <97166791+nerdalytics@users.noreply.github.com> Date: Mon, 19 Oct 2026 01:28:44 +0200 Subject: [PATCH 17/21] fix(state): notify sealed-object subscribers from hookless fast paths The hookless set handler's out-of-effect branch and handleBatchFastPath read only the SUBSCRIBERS symbol, so writes to non-extensible targets, whose subscriber set lives in proxyCacheSubs, re-ran no effects. Both paths now fall back to proxyCacheSubs. A hasFallbackSubs flag, set when the first set is stored there, keeps the WeakMap lookup off the no-subscriber path until a non-extensible target has subscribers. --- src/AGENTS.md | 7 +- src/index.ts | 14 +++- tests/property-frozen-sealed.test.ts | 119 +++++++++++++++++++++++++-- 3 files changed, 126 insertions(+), 14 deletions(-) diff --git a/src/AGENTS.md b/src/AGENTS.md index 1c8d792..c8e1e43 100644 --- a/src/AGENTS.md +++ b/src/AGENTS.md @@ -27,6 +27,7 @@ Global mutable state driving the reactive system: | `effectQueue` | `EffectFunction[]` | Reused array `runPendingEffectBatch` drains when more than one effect is pending | | `deferredEffectCreations` | `EffectFunction[]` | Effects created inside batch (deferred until batch end) | | `dirtyTargets` | `Map>` | Batch fast path: tracks dirty target-property pairs for deferred scheduling | +| `hasFallbackSubs` | `boolean` | Set once any subscriber set is stored in `proxyCacheSubs`, never reset; until then subscriber lookups skip the WeakMap | | `rerunReadList`, `rerunReadIndex`, `rerunStable` | | Re-run fast path: replays the effect's `__readList` and advances while reads match | | `rerunTempDeps`, `rerunTempReads`, `buildingReadList` | | Re-run slow path: collect fresh deps/reads and build a new read list | | `settledCallbacks` | `Set` | `onSettled` subscribers | @@ -98,7 +99,7 @@ Handler helpers: | `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 | @@ -162,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 (frozen/sealed), setting `hasFallbackSubs` | +| `findSubscribers` | Read-only lookup: `[SUBSCRIBERS]` symbol, then `proxyCacheSubs` once `hasFallbackSubs` is set | ### Batch Internals diff --git a/src/index.ts b/src/index.ts index e07ee8b..12eca70 100644 --- a/src/index.ts +++ b/src/index.ts @@ -78,6 +78,9 @@ let untilSawPending = false // Proxy caching const proxyCache: WeakMap = new WeakMap() const proxyCacheSubs: WeakMap> = new WeakMap>() +// ponytail: process-wide and never reset; once any non-extensible target has subscribers, every hookless +// write to a target without subscribers pays the proxyCacheSubs lookup again. Per-target marking if that shows up +let hasFallbackSubs = false // Effects by the property they read, per subscriber Set. It may hold stale entries: scheduling re-checks __reads, // so it only has to be a superset of the true readers. Kept off the Set: an own property there cost ~8% on // single-subscriber writes in the benchmark suite @@ -159,9 +162,11 @@ function storeSubscriberSet(target: object, subscriberSet: Set): }) } else { proxyCacheSubs.set(target, subscriberSet) + hasFallbackSubs = true } } catch { proxyCacheSubs.set(target, subscriberSet) + hasFallbackSubs = true } } @@ -299,7 +304,7 @@ function callHookSafe(hook: HookFunction | undefin function findSubscribers(target: object): Set | undefined { const t = target as SubscribersObject - return t[SUBSCRIBERS] ?? proxyCacheSubs.get(target) + return t[SUBSCRIBERS] ?? (hasFallbackSubs ? proxyCacheSubs.get(target) : undefined) } function armMicrotaskFlush(): void { @@ -633,7 +638,10 @@ 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) subs = proxyCacheSubs.get(rawTarget) + if (!subs?.size) return true let props = dirtyTargets.get(rawTarget) if (!props) { @@ -669,7 +677,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 diff --git a/tests/property-frozen-sealed.test.ts b/tests/property-frozen-sealed.test.ts index 1e0e30e..3334f74 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,112 @@ 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('sealed arrays: mutating methods work via WeakMap method cache', (): void => { fc.assert( fc.property( From e8c76307bcbdbee369433213ec0e7ad315bfb1f2 Mon Sep 17 00:00:00 2001 From: nerdalytics <97166791+nerdalytics@users.noreply.github.com> Date: Mon, 19 Oct 2026 13:18:11 +0200 Subject: [PATCH 18/21] perf(state): replace fallback-subs flag with SUBSCRIBERS placeholder The process-wide hasFallbackSubs flag never reset, so once any sealed or frozen target had a subscriber, every hookless write to a target without subscribers paid a proxyCacheSubs miss (+10% on "state no subs"). definePropertyOnTarget now defines SUBSCRIBERS as a writable null placeholder at state() time, so an absent symbol means exactly "set lives in proxyCacheSubs"; null means "no subscribers yet". A target frozen after state() makes the placeholder non-writable, and storeSubscriberSet catches that assignment and stores in the WeakMap. Measured with two interleaved trunk-vs-change suite pairs at 10 cycles: aggregate 866.94 vs 864.48 ms and 925.17 vs 885.37 ms; "state no subs" 42.2/43.9 to 36.8/36.6 ms; "state creation" 27.1/27.6 to 36.9/36.2 ms, the cost of the extra define under GC pressure. Adds two benchmark rows with a sealed target and a test for a target frozen after state(). --- scripts/benchmark.ts | 29 +++++++++++++++++++ src/AGENTS.md | 9 +++--- src/index.ts | 42 +++++++++++++++------------- tests/property-frozen-sealed.test.ts | 16 +++++++++++ 4 files changed, 71 insertions(+), 25 deletions(-) diff --git a/scripts/benchmark.ts b/scripts/benchmark.ts index 6ea5d06..ae91a3e 100644 --- a/scripts/benchmark.ts +++ b/scripts/benchmark.ts @@ -241,6 +241,24 @@ function stateWrite1Sub(): number { return end - start } +function sealedWrite1Sub(): number { + const s = state( + Object.seal({ + v: 0, + }) + ) + const cleanup: Unsubscribe = effect((): void => { + void s.v + }) + const start = performance.now() + for (let i = 0; i < SINGLE_SUB_WRITES; i++) { + s.v = i + } + const end = performance.now() + cleanup() + return end - start +} + function stateWrite100Subs(): number { const s = state({ v: 0, @@ -501,6 +519,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 c8e1e43..cc50650 100644 --- a/src/AGENTS.md +++ b/src/AGENTS.md @@ -27,7 +27,6 @@ Global mutable state driving the reactive system: | `effectQueue` | `EffectFunction[]` | Reused array `runPendingEffectBatch` drains when more than one effect is pending | | `deferredEffectCreations` | `EffectFunction[]` | Effects created inside batch (deferred until batch end) | | `dirtyTargets` | `Map>` | Batch fast path: tracks dirty target-property pairs for deferred scheduling | -| `hasFallbackSubs` | `boolean` | Set once any subscriber set is stored in `proxyCacheSubs`, never reset; until then subscriber lookups skip the WeakMap | | `rerunReadList`, `rerunReadIndex`, `rerunStable` | | Re-run fast path: replays the effect's `__readList` and advances while reads match | | `rerunTempDeps`, `rerunTempReads`, `buildingReadList` | | Re-run slow path: collect fresh deps/reads and build a new read list | | `settledCallbacks` | `Set` | `onSettled` subscribers | @@ -66,7 +65,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. Defined as a writable `null` placeholder at `state()` time on extensible targets, so the hookless write paths can tell "no subscribers yet" (null) from "set lives in `proxyCacheSubs`" (absent) without a flag or an `Object.isExtensible` call | | `PROXY` | Stores Proxy reference on target objects | | `HOOKS` | Stores StateHooks on extensible target objects; defined but never read (nested propagation is by closure) | @@ -163,8 +162,8 @@ addPendingEffect(subscriber) | Function | Purpose | |----------|---------| | `getSubscribers` | Resolves subscriber set: `[SUBSCRIBERS]` symbol → `proxyCacheSubs` → create new | -| `storeSubscriberSet` | Stores via `Object.defineProperty` (extensible) or `proxyCacheSubs` WeakMap (frozen/sealed), setting `hasFallbackSubs` | -| `findSubscribers` | Read-only lookup: `[SUBSCRIBERS]` symbol, then `proxyCacheSubs` once `hasFallbackSubs` is set | +| `storeSubscriberSet` | Assigns into the `[SUBSCRIBERS]` placeholder when it is present (also on a target sealed after `state()`); otherwise, or when the assignment throws (frozen after `state()`), stores in `proxyCacheSubs` | +| `findSubscribers` | Read-only lookup: `[SUBSCRIBERS]` absent → `proxyCacheSubs`; `null` placeholder → `undefined`; else the set | ### Batch Internals @@ -202,7 +201,7 @@ addPendingEffect(subscriber) | Function | Purpose | |----------|---------| | `defineProxyProperties` | Stores `[PROXY]` and `[HOOKS]` symbols on extensible targets; falls back to `storeHooksForFrozenTarget` otherwise | -| `definePropertyOnTarget` | `Object.defineProperty` for `PROXY` and `HOOKS` symbols | +| `definePropertyOnTarget` | `Object.defineProperty` for `PROXY`, the `SUBSCRIBERS` null placeholder, and `HOOKS` symbols | | `storeHooksForFrozenTarget` | Writes hooks to `frozenHooksCache` for non-extensible targets | ### Utilities diff --git a/src/index.ts b/src/index.ts index 12eca70..561bff2 100644 --- a/src/index.ts +++ b/src/index.ts @@ -78,9 +78,6 @@ let untilSawPending = false // Proxy caching const proxyCache: WeakMap = new WeakMap() const proxyCacheSubs: WeakMap> = new WeakMap>() -// ponytail: process-wide and never reset; once any non-extensible target has subscribers, every hookless -// write to a target without subscribers pays the proxyCacheSubs lookup again. Per-target marking if that shows up -let hasFallbackSubs = false // Effects by the property they read, per subscriber Set. It may hold stale entries: scheduling re-checks __reads, // so it only has to be a superset of the true readers. Kept off the Set: an own property there cost ~8% on // single-subscriber writes in the benchmark suite @@ -123,9 +120,12 @@ type EffectFunction = { effectName?: string } -// Helper types for objects with internal symbols +// Helper types for objects with internal symbols. +// [SUBSCRIBERS] is null on an extensible target nobody has subscribed to yet (placeholder defined by +// definePropertyOnTarget), and absent on a target that could not take the slot: non-extensible at state() time, +// or a Proxy that rejected the define. Absent means the set lives in proxyCacheSubs. type SubscribersObject = ProxyTarget & { - [SUBSCRIBERS]?: Set + [SUBSCRIBERS]?: Set | null } type ProxyObject = ProxyTarget & { @@ -152,22 +152,17 @@ function wrapArrayMethod(target: object, prop: PropertyKey, originalMethod: Cach } function storeSubscriberSet(target: object, subscriberSet: Set): void { + const t = target as SubscribersObject try { - if (Object.isExtensible(target)) { - Object.defineProperty(target, SUBSCRIBERS, { - configurable: true, - enumerable: false, - value: subscriberSet, - writable: false, - }) - } else { - proxyCacheSubs.set(target, subscriberSet) - hasFallbackSubs = true + if (t[SUBSCRIBERS] === null) { + // the placeholder slot is writable, so this also works on a target sealed after state() + t[SUBSCRIBERS] = subscriberSet + return } } catch { - proxyCacheSubs.set(target, subscriberSet) - hasFallbackSubs = true + // frozen after state(): the slot is non-writable now } + proxyCacheSubs.set(target, subscriberSet) } function getSubscribers(target: object): Set { @@ -303,8 +298,9 @@ function callHookSafe(hook: HookFunction | undefin } function findSubscribers(target: object): Set | undefined { - const t = target as SubscribersObject - return t[SUBSCRIBERS] ?? (hasFallbackSubs ? proxyCacheSubs.get(target) : undefined) + const subs = (target as SubscribersObject)[SUBSCRIBERS] + if (subs === undefined) return proxyCacheSubs.get(target) + return subs ?? undefined } function armMicrotaskFlush(): void { @@ -640,7 +636,7 @@ function handleBatchFastPath(rawTarget: ProxyTarget, prop: PropertyKey, value: u // inlined, not findSubscribers: the call cost ~5% on the batch + derive benchmarks let subs = (rawTarget as SubscribersObject)[SUBSCRIBERS] - if (subs === undefined && hasFallbackSubs) subs = proxyCacheSubs.get(rawTarget) + if (subs === undefined) subs = proxyCacheSubs.get(rawTarget) if (!subs?.size) return true let props = dirtyTargets.get(rawTarget) @@ -732,6 +728,12 @@ function definePropertyOnTarget(target: ProxyTarget, proxy: unknown, hooks: Stat value: proxy, writable: false, }) + Object.defineProperty(target, SUBSCRIBERS, { + configurable: true, + enumerable: false, + value: null, + writable: true, + }) if (hooks) { Object.defineProperty(target, HOOKS, { configurable: true, diff --git a/tests/property-frozen-sealed.test.ts b/tests/property-frozen-sealed.test.ts index 3334f74..8775032 100644 --- a/tests/property-frozen-sealed.test.ts +++ b/tests/property-frozen-sealed.test.ts @@ -387,6 +387,22 @@ describe('Property-Based: Frozen/Sealed Object Reactivity', { 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('sealed arrays: mutating methods work via WeakMap method cache', (): void => { fc.assert( fc.property( From 1939a057d0267e7c66f6abf323dd2096175f86cb Mon Sep 17 00:00:00 2001 From: nerdalytics <97166791+nerdalytics@users.noreply.github.com> Date: Mon, 19 Oct 2026 13:41:16 +0200 Subject: [PATCH 19/21] chore(core): bump version to 2001.0.1 --- AGENTS.md | 2 +- README.md | 2 +- jsr.json | 2 +- package-lock.json | 4 ++-- package.json | 2 +- 5 files changed, 6 insertions(+), 6 deletions(-) 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" } From 3969ef0ef8e83bc05878c578971bc769e4e0c07b Mon Sep 17 00:00:00 2001 From: nerdalytics <97166791+nerdalytics@users.noreply.github.com> Date: Mon, 19 Oct 2026 13:53:45 +0200 Subject: [PATCH 20/21] refactor(core): inline one-caller helpers flagged by review --- scripts/benchmark.ts | 31 +++++++++++++------------------ src/index.ts | 31 ++++++++++++++----------------- 2 files changed, 27 insertions(+), 35 deletions(-) diff --git a/scripts/benchmark.ts b/scripts/benchmark.ts index ae91a3e..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,23 +238,21 @@ function stateWrite1Sub(): number { return end - start } -function sealedWrite1Sub(): number { - const s = state( - Object.seal({ +const stateWrite1Sub = (): number => + write1Sub( + state({ v: 0, }) ) - const cleanup: Unsubscribe = effect((): void => { - void s.v - }) - const start = performance.now() - for (let i = 0; i < SINGLE_SUB_WRITES; i++) { - s.v = i - } - const end = performance.now() - cleanup() - return end - start -} + +const sealedWrite1Sub = (): number => + write1Sub( + state( + Object.seal({ + v: 0, + }) + ) + ) function stateWrite100Subs(): number { const s = state({ diff --git a/src/index.ts b/src/index.ts index 561bff2..c94f52e 100644 --- a/src/index.ts +++ b/src/index.ts @@ -299,8 +299,7 @@ function callHookSafe(hook: HookFunction | undefin function findSubscribers(target: object): Set | undefined { const subs = (target as SubscribersObject)[SUBSCRIBERS] - if (subs === undefined) return proxyCacheSubs.get(target) - return subs ?? undefined + return subs === undefined ? proxyCacheSubs.get(target) : (subs ?? undefined) } function armMicrotaskFlush(): void { @@ -1195,8 +1194,8 @@ type DeriveInternal = { } function writeDeriveValue( - internalState: Pick, 'lastValue'>, - reactiveInternal: Pick, 'value'> | null, + internalState: DeriveInternal, + reactiveInternal: DeriveInternal | null, newValue: T ): void { if (!Object.is(newValue, internalState.lastValue)) { @@ -1208,8 +1207,8 @@ function writeDeriveValue( } function setDerivePending( - internalState: Pick, 'pending'>, - reactiveInternal: Pick, 'pending'> | null, + internalState: DeriveInternal, + reactiveInternal: DeriveInternal | null, pending: boolean ): void { if (internalState.pending === pending) return @@ -1223,8 +1222,8 @@ function setDerivePending( function computeDerive( computeFn: () => T | Promise, - internalState: Pick, 'lastValue' | 'pending'>, - reactiveInternal: Pick, 'pending' | 'value'> | null, + internalState: DeriveInternal, + reactiveInternal: DeriveInternal | null, onCompute: HookFunction<[T | undefined]> | undefined, onError: HookFunction<[Error]> | undefined, onAsync: (result: Promise) => void, @@ -1318,14 +1317,6 @@ export function derive(computeFn: () => T | Promise, hooks?: DeriveHooks { - callHookSafe(onDeriveDispose) - d() - dispose = null - setDerivePending(internalState, reactiveInternal, false) - reactiveInternal = null - } - createEffect() const readValue = (): T | undefined | null => { @@ -1356,7 +1347,13 @@ export function derive(computeFn: () => T | Promise, hooks?: DeriveHooks Date: Mon, 19 Oct 2026 14:14:09 +0200 Subject: [PATCH 21/21] perf(state): gate the fallback subscriber lookup on the PROXY symbol The eager SUBSCRIBERS placeholder from e8c7630 cost +10 ms per 100,000 state() calls against origin/trunk, and no defineProperty spelling removed it (hoisted descriptor, Reflect.defineProperty, define order); only an enumerable plain assignment matched base, which spread and Object.assign would copy. Drop the placeholder. The hookless set handler and the batch fast path now consult proxyCacheSubs when the target has no PROXY symbol, which state() defines on every extensible target, so non-extensible targets and symbol-rejecting Proxy targets need no flag. hasFallbackSubs returns, armed only when a target that carries PROXY lands in the WeakMap: sealed or frozen after state() and before its first subscription. Two interleaved base-vs-branch pairs at 10 cycles: 840.66 vs 842.10 ms and 839.85 vs 833.73 ms; "state creation" 24.0/24.3 vs 23.3/23.5 ms; "state no subs" 40.8/41.2 vs 36.7/37.1 ms. Adds a test for a target sealed after state() (direct and batched writes notify). --- src/AGENTS.md | 9 ++++--- src/index.ts | 39 +++++++++++++++------------- tests/property-frozen-sealed.test.ts | 24 +++++++++++++++++ 3 files changed, 50 insertions(+), 22 deletions(-) diff --git a/src/AGENTS.md b/src/AGENTS.md index cc50650..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 the subscriber Set on target objects. Defined as a writable `null` placeholder at `state()` time on extensible targets, so the hookless write paths can tell "no subscribers yet" (null) from "set lives in `proxyCacheSubs`" (absent) without a flag or an `Object.isExtensible` call | +| `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) | @@ -162,8 +163,8 @@ addPendingEffect(subscriber) | Function | Purpose | |----------|---------| | `getSubscribers` | Resolves subscriber set: `[SUBSCRIBERS]` symbol → `proxyCacheSubs` → create new | -| `storeSubscriberSet` | Assigns into the `[SUBSCRIBERS]` placeholder when it is present (also on a target sealed after `state()`); otherwise, or when the assignment throws (frozen after `state()`), stores in `proxyCacheSubs` | -| `findSubscribers` | Read-only lookup: `[SUBSCRIBERS]` absent → `proxyCacheSubs`; `null` placeholder → `undefined`; else the set | +| `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 @@ -201,7 +202,7 @@ addPendingEffect(subscriber) | Function | Purpose | |----------|---------| | `defineProxyProperties` | Stores `[PROXY]` and `[HOOKS]` symbols on extensible targets; falls back to `storeHooksForFrozenTarget` otherwise | -| `definePropertyOnTarget` | `Object.defineProperty` for `PROXY`, the `SUBSCRIBERS` null placeholder, and `HOOKS` symbols | +| `definePropertyOnTarget` | `Object.defineProperty` for `PROXY` and `HOOKS` symbols | | `storeHooksForFrozenTarget` | Writes hooks to `frozenHooksCache` for non-extensible targets | ### Utilities diff --git a/src/index.ts b/src/index.ts index c94f52e..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() @@ -120,12 +123,9 @@ type EffectFunction = { effectName?: string } -// Helper types for objects with internal symbols. -// [SUBSCRIBERS] is null on an extensible target nobody has subscribed to yet (placeholder defined by -// definePropertyOnTarget), and absent on a target that could not take the slot: non-extensible at state() time, -// or a Proxy that rejected the define. Absent means the set lives in proxyCacheSubs. +// Helper types for objects with internal symbols type SubscribersObject = ProxyTarget & { - [SUBSCRIBERS]?: Set | null + [SUBSCRIBERS]?: Set } type ProxyObject = ProxyTarget & { @@ -152,17 +152,23 @@ function wrapArrayMethod(target: object, prop: PropertyKey, originalMethod: Cach } function storeSubscriberSet(target: object, subscriberSet: Set): void { - const t = target as SubscribersObject try { - if (t[SUBSCRIBERS] === null) { - // the placeholder slot is writable, so this also works on a target sealed after state() - t[SUBSCRIBERS] = subscriberSet + if (Object.isExtensible(target)) { + Object.defineProperty(target, SUBSCRIBERS, { + configurable: true, + enumerable: false, + value: subscriberSet, + writable: false, + }) return } } catch { - // frozen after state(): the slot is non-writable now + // 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 { @@ -299,7 +305,8 @@ function callHookSafe(hook: HookFunction | undefin function findSubscribers(target: object): Set | undefined { const subs = (target as SubscribersObject)[SUBSCRIBERS] - return subs === undefined ? proxyCacheSubs.get(target) : (subs ?? undefined) + if (subs !== undefined) return subs + return hasFallbackSubs || (target as ProxyObject)[PROXY] === undefined ? proxyCacheSubs.get(target) : undefined } function armMicrotaskFlush(): void { @@ -635,7 +642,9 @@ function handleBatchFastPath(rawTarget: ProxyTarget, prop: PropertyKey, value: u // inlined, not findSubscribers: the call cost ~5% on the batch + derive benchmarks let subs = (rawTarget as SubscribersObject)[SUBSCRIBERS] - if (subs === undefined) subs = proxyCacheSubs.get(rawTarget) + if (subs === undefined && (hasFallbackSubs || (rawTarget as ProxyObject)[PROXY] === undefined)) { + subs = proxyCacheSubs.get(rawTarget) + } if (!subs?.size) return true let props = dirtyTargets.get(rawTarget) @@ -727,12 +736,6 @@ function definePropertyOnTarget(target: ProxyTarget, proxy: unknown, hooks: Stat value: proxy, writable: false, }) - Object.defineProperty(target, SUBSCRIBERS, { - configurable: true, - enumerable: false, - value: null, - writable: true, - }) if (hooks) { Object.defineProperty(target, HOOKS, { configurable: true, diff --git a/tests/property-frozen-sealed.test.ts b/tests/property-frozen-sealed.test.ts index 8775032..5f201da 100644 --- a/tests/property-frozen-sealed.test.ts +++ b/tests/property-frozen-sealed.test.ts @@ -403,6 +403,30 @@ describe('Property-Based: Frozen/Sealed Object Reactivity', { 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(