Skip to content

fix(state): notify sealed-object subscribers from hookless write paths - #178

Merged
nerdalytics merged 29 commits into
trunkfrom
fix/sealed-writes-notify
Oct 19, 2026
Merged

nerdalytics merged 29 commits into
trunkfrom
fix/sealed-writes-notify

Conversation

@nerdalytics

@nerdalytics nerdalytics commented Oct 19, 2026 •

Copy link
Copy Markdown
Owner

Description

A write to a sealed reactive object made outside an effect, with no hooks installed, did not re-run the effects that read the written property. The handbook's "Frozen and sealed objects" section promises that it does. The hookless set handler and the batch fast path read only the SUBSCRIBERS symbol, and a non-extensible target keeps its subscriber set in the proxyCacheSubs WeakMap instead. This PR fixes both paths and lands the internal refactors from the over-engineering audit that only existed on the local trunk. On the benchmark, the branch does the same work faster than trunk by 1.3% and 2.2% in two interleaved rounds. The one row slower by more than 0.1 ms in both rounds is the sealed write, where trunk skipped the subscriber work because of the bug. With that row included, the totals differ by 0.2% and 0.7%, under the 1% noise limit.

Changes Made

  • Both hookless write paths now fall back to proxyCacheSubs, so direct and batched writes to a sealed target notify its subscribers. This is commit 63eb432.
  • The fallback lookup runs only for a target without the PROXY symbol. state() defines that symbol on every extensible target, so a target without it never took symbols, and the common path needs no flag and no Object.isExtensible call. Three commits reach this form; the last perf(state) commit is the final one.
  • storeSubscriberSet arms hasFallbackSubs only when a target that carries PROXY lands in the WeakMap. That happens when code seals or freezes the target after state() and before its first subscription. Only then do writes with no subscribers pay a WeakMap miss.
  • Commit e8c7630 tried an eager SUBSCRIBERS placeholder and a later commit in this branch reverts it. The placeholder cost 10 ms per 100,000 state() calls. The revert's commit message lists the four defineProperty spellings measured.
  • Eight refactor commits from the audit remove dead guards and one-caller helpers in derive, until, effect cleanup and the batch flush. Six test commits in the same series pin the behaviour those refactors touch.
  • The version moves to 2001.0.1, so the push to trunk publishes to npm and JSR.

Performance Impact

  • Two interleaved rounds of node --expose-gc scripts/benchmark.ts -R 10 against origin/trunk, sum of the 18 medians: 840.66 vs 842.10 ms in round 1, which is 0.2% slower, and 839.85 vs 833.73 ms in round 2, which is 0.7% faster. The 1% limit is the noise floor of this benchmark, not a target; two runs of the same trunk code differed by up to 0.6% in this session.
  • The "sealed write 1 sub" row rises from 2.7 ms to 14.7 ms. Trunk skips the subscriber work on that row, so the trunk number measures the bug, not a baseline. On the 17 rows where both sides do the same work, the branch sums 827.41 vs 838.01 ms, 1.3% faster, and 819.08 vs 837.16 ms, 2.2% faster. Both are beyond the noise floor.
  • Eight of those 17 rows are slower on the branch in at least one round, by 0.01 to 1.62 ms. Five of them flip sign between rounds, one is equal in round 1 and 0.02 ms slower in round 2. Two are slower in both rounds. "classic loop" runs no Beacon code and measures 1.66/1.67 vs 1.69/1.69 ms. "derive chain depth 10" measures 2.53/2.50 vs 2.56/2.57 ms.
  • The "state no subs" row drops from 40.8/41.2 ms to 36.7/37.1 ms. The "state creation" row is 24.0/24.3 ms on trunk and 23.3/23.5 ms here.
  • To check this yourself, run the benchmark twice on trunk and twice on this branch, interleaved, and compare the sums of the medians. One pair cannot resolve 1% on this machine; trunk-to-trunk drift in this session reached 0.6%.

Test Coverage

  • tests/property-frozen-sealed.test.ts gains six cases: direct write, batched write, unchanged value, symbol-rejecting target, frozen after state(), and sealed after state(). Against origin/trunk's core, four of its 21 tests fail. On this branch all 260 tests pass.
  • To see the four failures, run node --test tests/property-frozen-sealed.test.ts with trunk's src/index.ts in place.
  • npm run check, npm run build and npm test exit 0. Branch coverage on src/index.ts reports the same 23 pre-existing uncovered entries as trunk and no new ones.

Related Issues

The local files .do/inbox/2026-10-18-sealed-writes-skip-subscribers.md and .do/inbox/2026-10-19-fallback-subs-flag-ceiling.md track this work. They are not committed because .do/ is gitignored.

Checklist

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

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.
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().
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).
@nerdalytics
nerdalytics marked this pull request as ready for review October 19, 2026 14:25
@nerdalytics
nerdalytics merged commit 4d60150 into trunk Oct 19, 2026
12 checks passed
@nerdalytics
nerdalytics deleted the fix/sealed-writes-notify branch October 19, 2026 14:29
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