Skip to content

perf(state): drop the hooks slot on objects and the frozen hooks cache - #187

Merged
nerdalytics merged 2 commits into
trunkfrom
perf/drop-unread-hooks-slot-on-objects
Oct 25, 2026
Merged

nerdalytics merged 2 commits into
trunkfrom
perf/drop-unread-hooks-slot-on-objects

Conversation

@nerdalytics

Copy link
Copy Markdown
Owner

Description

Every state(obj, hooks) call on a non-array target ran one Object.defineProperty for the module-private HOOKS symbol, and nothing read it. A hooked non-extensible target was also written into a WeakMap that had no reader. Hooks reach every trap through the handler closures, so both writes were dead. This removes them and closes HOOKS_TODO.md item C1.

  • The only runtime change is in hooksSlotFor, which now returns undefined for a non-array target. The array branch, definePropertyOnTarget and the internal-symbol guard are unchanged.
  • The catch arm of defineProxyProperties stays. An extensible target whose defineProperty trap rejects symbols still reaches it, and tests/property-frozen-sealed.test.ts covers that path.
  • No consumer-visible behavior changes. The symbol is module-private, hooks fire as before on extensible and non-extensible targets, and a non-extensible target is still registered in proxyCache.
  • No version bump. The publish workflow is gated on a version change, so merging publishes nothing.

Changes Made

  • src/index.ts: hooksSlotFor returns undefined for non-array targets, HooksSlot narrows to DirectArrayContext | undefined, and frozenHooksCache with storeHooksForFrozenTarget is deleted.
  • scripts/benchmark.ts: a new state creation (hooks) case in the blind-spots group. It is its own commit, so the baseline below comes from the unchanged library.
  • tests/state-hooks.test.ts: one test asserts that a hooked plain object owns one own symbol, a hooked array owns two, and a hookless object owns one. The object case fails on trunk.
  • Prose that described the removed storage now matches the code, in src/AGENTS.md, src/hooks/AGENTS.md, tests/AGENTS.md, two test comments, and HOOKS_TODO.md, where C1 moves to shipped.

Performance Impact

  • state creation (hooks) median went from 21.49ms to between 13.24ms and 13.39ms across five runs on Node v24.21.0, Apple M4 Pro.
  • The gate is a 1% per-case ceiling on slowdowns. In two 10-cycle runs per commit, ordered baseline, final, final, baseline, 9 cases read over 1% slower in one pairing and under 1% or faster in the other (classic loop, state no subs, state + derive + 2 effects, effect triggers, derive chain depth 10, sealed write 1 sub, state no subs after seal, state write equal value, array push/pop (undefined)). The two baseline runs of identical code differed by more than 1% on 8 cases, so the suite cannot resolve 1% on them.
  • Two cases read over 1% slower in both pairings, state creation (+1.4%, +1.1%) and array push/pop (hooks) (+2.0%, +1.1%). Neither path is in the diff. A targeted probe with 8 alternating processes per commit, 15 samples each, measured them at -0.59% and -0.37%, while the baseline's own processes spread 2.6%. The full tables are in the ticket reports under openspec/changes/drop-unread-hooks-slot-on-objects/tickets/.

Test Coverage

  • npm test passes 308 tests. npm run test:coverage reports 100% branches, 100% functions, 100% lines.
  • npm run check and npm run build pass.
  • To verify by hand, call state(raw, { onRead() {} }) and check Object.getOwnPropertySymbols(raw).length. It is 1 for a plain object and 2 for an array.

Related Issues

None. Tracked as HOOKS_TODO.md item C1 and decided in .do/inbox/2026-10-25-drop-unread-hooks-slot-on-objects.md (Q80 a).

Checklist

  • Tests added/updated for changes
  • Documentation updated
  • All tests passing
  • Code formatting checked
  • Performance implications considered
  • Breaking changes documented (if applicable)

The blind-spots group names creation of a target that takes a second
symbol as a gap, but no case timed it. Add one shaped like the hookless
creation case with a single no-op onRead hook built once at module
level, so the next change can compare hooked creation against this
baseline.
A hooked non-array target kept its StateHooks under the HOOKS symbol,
and a hooked non-extensible target was written into frozenHooksCache,
but nothing read either: every trap reaches the hooks through its
handler closure. Only an array target needs the slot, for the
DirectArrayContext its direct push and pop read through the Proxy
receiver.

hooksSlotFor now returns undefined for a non-array target, so the
symbol is defined exactly on extensible arrays, and the cache with its
writer is gone. The HooksSlot type says so. No consumer-visible change:
hooks fire as before on every kind of target, direct push and pop on
hooked arrays behave as before, and a non-extensible target still gets
its Proxy registered in proxyCache.

Closes HOOKS_TODO.md item C1.
@nerdalytics
nerdalytics marked this pull request as ready for review October 25, 2026 10:23
@nerdalytics
nerdalytics merged commit 18f5a71 into trunk Oct 25, 2026
12 checks passed
@nerdalytics
nerdalytics deleted the perf/drop-unread-hooks-slot-on-objects branch October 25, 2026 10:25
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant