Skip to content

fix(signals): a store key's delete decrements the key count - #4132

Merged
ryansolid merged 4 commits into
nextfrom
fix/store-delete-key-count
Oct 18, 2026
Merged

ryansolid merged 4 commits into
nextfrom
fix/store-delete-key-count

Conversation

@ryansolid

Copy link
Copy Markdown
Member

fix(signals): a store key's delete decrements the key count

What

deleteProperty in packages/signals/src/store/store.ts now decrements the store's key count (kc) when an own key of the draft leaves: a plain clone's key, or an overlay's own add. An overlay's delete of a committed key is unchanged: recorded in del and counted at the rebuild, as before.

Why

kc gates the overlay draft (an owned plain object above 32 keys drafts as an overlay over its committed backing) and caps the rebuild. The set trap incremented it for every new key; nothing decremented it on a delete outside the overlay rebuild paths. So the natural keyed-selection shape — a store holding selected[id], written as "delete the previous key, set the new one" — drifted the count up by one per write. After 32 selections a three-key object crossed the overlay threshold and, since any delete makes an overlay rebuild its backing (#3689), every commit after paid a full clone of the object. The user's code was right; the store miscounted it. Any keyed store that adds and deletes keys over time (a selection map, a cache, a set of open rows) hit the same drift.

Found on the Octane js-framework fixture while asking why Solid's selection cost three times Vue Vapor's compiled selector: the profile showed materializePB (the overlay rebuild) on a store that should never have been an overlay.

Measured

Call profiler on the unminified fixture, Solid on next, the fixture's original delete form, µs per select click:

wall store write path
before 5.47 materializePB 1.37, ensurePB 0.32, storeSetter 1.69 incl
after 3.38 storeSetter 0.89 incl, ensurePB 0.22, settle 0.18
Vue Vapor (compiled selector) 1.65 —

Octane harness, all targets rerun: select 7.2 → 6.2 µs, select_lots 4.4 → 3.7 µs (Octane 5.6 / 3.7, Vapor 2.8 / 1.8); the suite's Solid ÷ Octane geomean 1.19 → 1.09. The other ops are within run-to-run noise. The remaining gap to Vapor is the transaction itself against a map lookup, which is the 2.0 selection-through-store design, not this bug.

Tests

tests/store/delete-key-count.test.ts: 200 delete-then-set selections stay at two keys and never draft as an overlay; delete and re-add in one draft; delete of a never-committed key; an overlay's own add deleted again, and an overlay's committed key deleted (counted at the rebuild). The #3689 overlay rebuild tests pass unchanged. Signals (5343), solid (869), web default (1379), server (1719) and hydrate (509) suites pass.

🤖 Generated with Claude Code

`kc` gates the overlay draft (an owned plain object above 32 keys drafts
as an overlay over its committed backing) and caps the rebuild. The set
trap incremented it for every new key; nothing decremented it when a key
was deleted from a plain-clone draft, or when an overlay's own add was
deleted again — only the overlay rebuild paths subtracted their recorded
committed deletions. So the natural keyed-selection shape, "delete the
old key, set the new one", drifted the count up by one per write: after
32 selections a three-key object crossed the overlay threshold and, since
any delete makes an overlay rebuild its backing (#3689), every commit
after paid a full clone of the object — 1.4 µs of a 5.5 µs select on the
js-framework fixture, found while asking why Solid's keyed-store
selection cost three times Vue Vapor's compiled selector.

`deleteProperty` now decrements for an own key of the draft that leaves
(a clone's key, an overlay's own add) and leaves an overlay's committed
key to `del`, counted at the rebuild as before. Tests pin the selection
shape staying at two keys and out of overlay mode over 200 writes,
delete-and-re-add, delete of a never-committed key, and the overlay's two
delete cases; the overlay rebuild tests (#3689) pass unchanged.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@changeset-bot

changeset-bot Bot commented Oct 17, 2026 •

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: bcd6e02

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 14 packages
Name Type
@solidjs/signals Patch
test-integration Patch
@solidjs/web Patch
@solidjs/babel-plugin Patch
@solidjs/compiler Patch
@solidjs/diagnostics Patch
@solidjs/element Patch
@solidjs/h Patch
@solidjs/html Patch
solid-js Patch
@solidjs/universal Patch
board-example Patch
server-dbmon-example Patch
todos-server-example Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@github-actions

github-actions Bot commented Oct 17, 2026 •

Copy link
Copy Markdown

Size (brotli, eager entry chunk)

scenario head vs base minified vs base minified vs recorded cap lazy chunks (not counted)
signals: core floor (createSignal/Memo/Effect/Root/flush) 7.25 KB 0 B 0 B +4 B 7.25 KB ⚠️ over by 3 B, 16 B minified headroom
signals: + createStore 14.79 KB +27 B (+0.2%) +33 B +4 B 14.82 KB ✅
signals: + isPending/latest 9.96 KB 0 B 0 B +55 B 9.96 KB ✅
signals: + until/refresh (truth readers, no optimism) 7.85 KB 0 B 0 B +4 B 7.86 KB ✅
app: render + one signal (the simple-app floor) 9.55 KB 0 B 0 B +4 B 9.56 KB ✅
app: hydrating (no stores) with Show/For/Loading/Errored/lazy 18.20 KB 0 B 0 B +4 B 18.19 KB ⚠️ over by 10 B, 16 B minified headroom lazy-page.js 0.04 KB
app: hydrating + every store primitive family 30.12 KB −9 B (−0.0%) +33 B 0 B 30.14 KB ✅ lazy-page.js 0.04 KB
app: CSR with Show/For/Loading/Errored/lazy 13.17 KB 0 B 0 B +4 B 13.14 KB ⚠️ over by 27 B, 16 B minified headroom lazy-page.js 0.04 KB
app: CSR, observe tier (same app on the observe artifacts) 14.88 KB 0 B 0 B +4 B 14.91 KB ✅ lazy-page.js 0.04 KB
app: CSR, observe tier + attribution engine enabled 29.48 KB 0 B 0 B +4 B 29.48 KB ✅ lazy-page.js 0.04 KB
app: compiled floor (one template, one text hole, one delegated click) 9.76 KB 0 B 0 B +6 B 9.77 KB ✅
app: compiled CSR (JSX todo app: spread/merge/omit, events, class/style, keyed For, Show, Loading + lazy, store) 25.95 KB +1 B (+0.0%) +33 B +4 B 26.03 KB ✅ stats.js 0.18 KB
app: compiled hydrating (the same JSX todo app through hydrate(), compiled hydratable) 31.96 KB −13 B (−0.0%) +33 B +4 B 31.98 KB ✅ stats.js 0.20 KB
frames: eager client consumer (frames client + transport, lazy codec) 12.23 KB 0 B 0 B 0 B 12.25 KB ✅
page: base server components (hydrating + dynamic + frames + sf reference) 33.40 KB 0 B 0 B +4 B 33.46 KB ✅ assets.js 0.78 KB, decode.js 6.25 KB, lazy-page.js 0.04 KB, regions.js 1.07 KB, trace.js 8.99 KB, wire.js 1.33 KB
page: live server components (base + live/GET + action + isPending/latest) 37.63 KB 0 B 0 B 0 B 37.65 KB ✅ assets.js 0.78 KB, decode.js 6.25 KB, lazy-page.js 0.04 KB, regions.js 1.07 KB, trace.js 8.99 KB, wire.js 1.33 KB
page: compiled base server components (the base page as JSX: templates with class/style/attributes/events, For/Show; no spread) 35.35 KB 0 B 0 B +4 B 35.42 KB ✅ assets.js 0.78 KB, decode.js 6.25 KB, regions.js 1.06 KB, sc-comments.js 0.20 KB, trace.js 9.01 KB, wire.js 1.33 KB
page: compiled live server components (the compiled base page + live/GET + action + isPending/latest) 41.43 KB 0 B 0 B +54 B 41.48 KB ✅ eager (counted): web.js 21.55 KB; assets.js 0.78 KB, decode.js 6.25 KB, regions.js 1.07 KB, sc-comments.js 0.19 KB, trace.js 9.03 KB, wire.js 1.33 KB
page: base + router (base page + @solidjs/router: createRouter, two routes, preload, useNavigate) 40.87 KB 0 B 0 B +7 B 40.90 KB ✅ assets.js 0.78 KB, decode.js 6.25 KB, lazy-page.js 0.04 KB, regions.js 1.07 KB, server.js 1.02 KB, serverForms.js 3.60 KB, trace.js 9.05 KB, wire.js 1.33 KB
page: live + router (live page + @solidjs/router: createRouter, two routes, preload, useNavigate) 47.09 KB 0 B 0 B +1 B 47.05 KB ⚠️ over by 37 B, 19 B minified headroom eager (counted): client.js 26.75 KB; assets.js 0.78 KB, decode.js 6.25 KB, lazy-page.js 0.04 KB, regions.js 1.07 KB, server.js 1.02 KB, serverForms.js 3.30 KB, trace.js 9.05 KB, wire.js 1.34 KB
server: floor (getRequestEvent + isServer) 1.33 KB 0 B 0 B 0 B 1.34 KB ✅
server: renderToString (the server-render floor) 20.62 KB 0 B 0 B −23 B 20.59 KB ⚠️ over by 34 B, 43 B minified headroom

⚠️ Over the brotli cap within the minified allowance (passes)

  • signals: core floor (createSignal/Memo/Effect/Root/flush): over brotli cap by 3 B; minified 19,777 B vs 19,773 B recorded with the cap (+4 B) — 16 B of the 20 B minified allowance left; +0 B minified over this PR's base
  • app: hydrating (no stores) with Show/For/Loading/Errored/lazy: over brotli cap by 10 B; minified 53,240 B vs 53,236 B recorded with the cap (+4 B) — 16 B of the 20 B minified allowance left; +0 B minified over this PR's base
  • app: CSR with Show/For/Loading/Errored/lazy: over brotli cap by 27 B; minified 36,904 B vs 36,900 B recorded with the cap (+4 B) — 16 B of the 20 B minified allowance left; +0 B minified over this PR's base
  • page: live + router (live page + @solidjs/router: createRouter, two routes, preload, useNavigate): over brotli cap by 37 B; minified 141,256 B vs 141,255 B recorded with the cap (+1 B) — 19 B of the 20 B minified allowance left; +0 B minified over this PR's base
  • server: renderToString (the server-render floor): over brotli cap by 34 B; minified 72,131 B vs 72,154 B recorded with the cap (−23 B) — 43 B of the 20 B minified allowance left; +0 B minified over this PR's base

Bundled with Rolldown (what Vite ships), brotli q11, decimal KB. A scenario fails only when it is over its brotli cap and its minified size is more than 20 B over the minified recorded with the cap; over the cap within that allowance is brotli layout noise and passes with a warning. Caps and their recorded minified in scripts/size/scenarios.js; the floor and page caps in floor-caps.json are frozen (lower only, or Size-Exception: in the PR body). npm run ratchet lowers caps per RC; it never raises one (scripts/size/README.md).

…; tighten the trap

+33 B minified on the createStore graph (one hasOwn probe hoisted, the
delete and the decrement share its verdict); caps at measured + 10 B
with a ledger line each (signals: + createStore 14.79 -> 14.82 KB,
app: hydrating + every store primitive family 30.09 -> 30.14 KB,
app: compiled CSR 25.95 -> 26.03 KB). Inline caps, not frozen floors.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@coveralls

coveralls commented Oct 17, 2026 •

Copy link
Copy Markdown

Coverage Report for CI Build 39104313478

Coverage remained the same at 77.564%

Details

  • Coverage remained the same as the base build.
  • Patch coverage: No coverable lines changed in this PR.
  • No coverage regressions found.

Uncovered Changes

No uncovered changes found.

Coverage Regressions

No coverage regressions found.


Coverage Stats

Coverage Status
Relevant Lines: 1339
Covered Lines: 1097
Line Coverage: 81.93%
Relevant Branches: 1050
Covered Branches: 756
Branch Coverage: 72.0%
Branches in Coverage %: Yes
Coverage Strength: 27.69 hits per line

💛 - Coveralls

…CI: +2 B brotli, +33 B minified)

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@codspeed

codspeed Bot commented Oct 17, 2026 •

Copy link
Copy Markdown

Merging this PR will not alter performance

⚠️ 2 benchmarks spent significant time in system calls

System calls cannot be consistently instrumented, so they are not included in the measure, which understates the real cost. Please switch to the Walltime instrument to accurately measure system calls.

Measurement and system calls

✅ 196 untouched benchmarks
⏩ 5 skipped benchmarks1


Comparing fix/store-delete-key-count (bcd6e02) with next (7093b07)

Open in CodSpeed

Footnotes

  1. 5 benchmarks were skipped, so the baseline results were used instead. If they were deleted from the codebase, click here and archive them to remove them from the performance reports. ↩

ryansolid added a commit that referenced this pull request Oct 18, 2026
Journal §5–§12 of the Vapor lane: the signal-favoring fixture form, the
selection cost traced to the store key-count drift (#4132) and the
rc.0/rc.1 comparison, swap's history back to rc.0 and the reconciler walk
change (#4133), and the move-hint probe — built, measured against a
back-to-back control, and parked (swap 25 -> 15.8 µs where it applies, a
3% tax where it does not, ~300 B brotli per app bundle, never for a
keyed-by-function For). A pointer under the May metadata-channel deferral
and the lane ledger updated.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
ryansolid added a commit that referenced this pull request Oct 18, 2026
… ÷ Octane 0.93 -> 0.85, ÷ Vapor 1.05 -> 0.97)

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…e-delete-key-count

# Conflicts:
#	scripts/size/scenarios.js
@ryansolid
ryansolid merged commit f420740 into next Oct 18, 2026
8 checks passed
ryansolid added a commit that referenced this pull request Oct 18, 2026
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.

2 participants