From 84589d32e90d915c809b1ec53d2d3a560316f47b Mon Sep 17 00:00:00 2001 From: Ryan Carniato Date: Sat, 17 Oct 2026 02:36:53 -0700 Subject: [PATCH 1/3] fix(signals): a store key's delete decrements the key count MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `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 --- .changeset/store-delete-key-count.md | 5 + packages/signals/src/store/store.ts | 14 ++- .../tests/store/delete-key-count.test.ts | 95 +++++++++++++++++++ 3 files changed, 113 insertions(+), 1 deletion(-) create mode 100644 .changeset/store-delete-key-count.md create mode 100644 packages/signals/tests/store/delete-key-count.test.ts diff --git a/.changeset/store-delete-key-count.md b/.changeset/store-delete-key-count.md new file mode 100644 index 0000000000..5356a13ad2 --- /dev/null +++ b/.changeset/store-delete-key-count.md @@ -0,0 +1,5 @@ +--- +"@solidjs/signals": patch +--- + +Deleting a store key now decrements the store's key count. The count went up on every added key but came down only in the overlay rebuild paths, so the natural keyed-selection shape — delete the previous key, set the new one — drifted it upward by one per write until a small object crossed the overlay threshold and paid a full rebuild of its backing on every commit after. A delete of a draft's own key (a plain clone's, or an overlay's own add) is counted immediately; an overlay's delete of a committed key is still recorded and counted at the rebuild. diff --git a/packages/signals/src/store/store.ts b/packages/signals/src/store/store.ts index 408925a528..bd1b34a208 100644 --- a/packages/signals/src/store/store.ts +++ b/packages/signals/src/store/store.ts @@ -2232,7 +2232,19 @@ const traps: ProxyHandler = { if (!draft && !override) return true; const pb = ensurePB(target); touch(target, key); - delete pb[key as any]; + // The key count (`kc`, the overlay gate and the rebuild cap) follows a + // delete the way it follows an add: an own key of the draft leaving — a + // clone's key, or an overlay's own add — is one fewer now. An overlay's + // override of a committed key is the exception: the committed key's + // deletion is recorded in `del` and counted at the rebuild, as before. + // Without the decrement the natural "delete the old key, set the new + // one" shape drifted the count up by one per write until a three-key + // object crossed the overlay threshold and paid a rebuild on every + // commit after. + if (hasOwn.call(pb, key)) { + delete pb[key as any]; + if (!target.ovl || !hasOwn.call(target.v, key)) target.kc--; + } if (target.ovl && hasOwn.call(target.v, key)) (target.del ??= new Set()).add(key); if (override) notifyWrites(target); return true; diff --git a/packages/signals/tests/store/delete-key-count.test.ts b/packages/signals/tests/store/delete-key-count.test.ts new file mode 100644 index 0000000000..5a45b1f3c6 --- /dev/null +++ b/packages/signals/tests/store/delete-key-count.test.ts @@ -0,0 +1,95 @@ +/** + * The store's key count follows deletes. + * + * `kc` gates the overlay draft (an owned plain object above + * OVERLAY_MIN_KEYS drafts as an overlay) and caps the rebuild. It went up on + * every new key but came down only inside the overlay rebuild paths — never + * when a key was deleted from a plain-clone draft, and never when an + * overlay's own add was deleted again. 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, from then on, every delete forced the full rebuild clone + * at commit (1.4 µs of a 5.5 µs select on the js-framework fixture). + */ +import { describe, expect, it } from "vitest"; +import { createRoot, createStore, flush } from "../../src/index.js"; +import { $TARGET } from "../../src/store/store.js"; + +const target = (proxy: object): any => (proxy as any)[$TARGET]; +const ownKeys = (proxy: object) => Object.keys(target(proxy).v).length; + +describe("the store's key count follows deletes", () => { + it("delete-then-set selection keeps the count at the object's own keys and never drafts as an overlay", () => { + const [sel, setSel] = createRoot(() => + createStore>({ selected: null as number | null }) + ); + for (let id = 1; id <= 200; id++) { + setSel(s => { + const prev = s.selected; + if (prev != null && prev !== id) delete s[prev]; + s[id] = true; + s.selected = id; + }); + flush(); + const t = target(sel); + expect(t.kc).toBe(ownKeys(sel)); + expect(t.ovl).toBe(false); + } + expect(ownKeys(sel)).toBe(2); + }); + + it("delete and re-add in one draft leaves the count unchanged", () => { + const [store, set] = createRoot(() => createStore>({ a: 1, b: 2 })); + set(d => { + delete d.a; + d.a = 3; + }); + flush(); + expect(target(store).kc).toBe(2); + expect(ownKeys(store)).toBe(2); + }); + + it("deleting a never-committed key of a draft does not go below the real count", () => { + const [store, set] = createRoot(() => createStore>({ a: 1 })); + set(d => { + d.b = 2; + delete d.b; + }); + flush(); + expect(target(store).kc).toBe(1); + expect(ownKeys(store)).toBe(1); + }); + + it("under an overlay, an own add deleted again is uncounted and a committed key's delete counts at the rebuild", () => { + const seed: Record = {}; + for (let i = 0; i < 64; i++) seed["k" + i] = i; + const [store, set] = createRoot(() => createStore(seed)); + // first fold privatizes the seed; later drafts open as overlays + set(d => { + d.k0 = -1; + }); + flush(); + const t = target(store); + expect(t.kc).toBe(64); + set(d => { + (d as any).added = 1; + delete (d as any).added; + }); + flush(); + expect(t.kc).toBe(64); + expect(ownKeys(store)).toBe(64); + set(d => { + delete d.k63; + }); + flush(); + expect(t.kc).toBe(63); + expect(ownKeys(store)).toBe(63); + set(d => { + (d as any).fresh = 1; + delete d.k62; + }); + flush(); + expect(t.kc).toBe(63); + expect(ownKeys(store)).toBe(63); + }); +}); From c7b260afe43b6c286cb4c8f347033505c0ef868f Mon Sep 17 00:00:00 2001 From: Ryan Carniato Date: Sat, 17 Oct 2026 02:55:20 -0700 Subject: [PATCH 2/3] size: raise the three store-bearing caps for the delete key-count fix; 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 --- packages/signals/src/store/store.ts | 5 +++-- scripts/size/scenarios.js | 15 +++++++++------ 2 files changed, 12 insertions(+), 8 deletions(-) diff --git a/packages/signals/src/store/store.ts b/packages/signals/src/store/store.ts index bd1b34a208..64edbae5de 100644 --- a/packages/signals/src/store/store.ts +++ b/packages/signals/src/store/store.ts @@ -2241,11 +2241,12 @@ const traps: ProxyHandler = { // one" shape drifted the count up by one per write until a three-key // object crossed the overlay threshold and paid a rebuild on every // commit after. + const committed = target.ovl && hasOwn.call(target.v, key); if (hasOwn.call(pb, key)) { delete pb[key as any]; - if (!target.ovl || !hasOwn.call(target.v, key)) target.kc--; + committed || target.kc--; } - if (target.ovl && hasOwn.call(target.v, key)) (target.del ??= new Set()).add(key); + if (committed) (target.del ??= new Set()).add(key); if (override) notifyWrites(target); return true; } diff --git a/scripts/size/scenarios.js b/scripts/size/scenarios.js index ba04cf2e81..0c97a61efc 100644 --- a/scripts/size/scenarios.js +++ b/scripts/size/scenarios.js @@ -1080,8 +1080,9 @@ module.exports = [ // 44,832 B; measured locally (linux) at 14,774 B (44,832 B minified). Lower // only: cap at measured + 10 B rounded up to 0.01 KB; recorded minified // never raised. - limit: "14.79 KB", - capMinified: 44832, + // store delete key-count fix (#4132, 2026-10-17): 14.79 -> 14.82 KB, measured at 14,802 B brotli / 44,865 B minified (+12 B brotli over the cap, +33 B minified over the recorded size): deleteProperty decrements `kc` for a draft's own key, so a keyed store that deletes keys no longer drifts into overlay rebuilds. Correctness; local measurement, CI re-bases. + limit: "14.82 KB", + capMinified: 44865, alias }, { @@ -2583,8 +2584,9 @@ module.exports = [ // Lower only: cap at measured + 10 B rounded up to 0.01 KB; recorded // minified never raised. // mapArray window-staged commit (perf, #4114, 2026-10-16, merged over next @ dbc5d0005 after #4104's ratchet and #4108's observe raises): 29.91 -> 30.09 KB, measured at 30,075 B brotli / 94,579 B minified (+165 B brotli over the cap, +646 B minified over the recorded size): updateKeyedMap stages only the changed window, commits its bookkeeping arrays in place and takes a Map-free pure-removal path. Local measurement; CI's bytes re-base this cap. - limit: "30.09 KB", - capMinified: 94579, + // store delete key-count fix (#4132, 2026-10-17): 30.09 -> 30.14 KB, measured at 30,129 B brotli / 94,616 B minified (+39 B brotli over the cap, +37 B minified over the recorded size): deleteProperty decrements `kc` for a draft's own key, so a keyed store that deletes keys no longer drifts into overlay rebuilds. Correctness; local measurement, CI re-bases. + limit: "30.14 KB", + capMinified: 94616, alias }, { @@ -3896,8 +3898,9 @@ module.exports = [ // only: cap at measured + 10 B rounded up to 0.01 KB; recorded minified // never raised. // mapArray window-staged commit (perf, #4114, 2026-10-16, merged over next @ dbc5d0005 after #4104's ratchet and #4108's observe raises): 25.66 -> 25.95 KB, measured at 25,940 B brotli / 80,846 B minified (+280 B brotli over the cap, +640 B minified over the recorded size): updateKeyedMap stages only the changed window, commits its bookkeeping arrays in place and takes a Map-free pure-removal path. Local measurement; CI's bytes re-base this cap. - limit: "25.95 KB", - capMinified: 80846, + // store delete key-count fix (#4132, 2026-10-17): 25.95 -> 26.03 KB, measured at 26,011 B brotli / 80,879 B minified (+61 B brotli over the cap, +33 B minified over the recorded size): deleteProperty decrements `kc` for a draft's own key, so a keyed store that deletes keys no longer drifts into overlay rebuilds. Correctness; local measurement, CI re-bases. + limit: "26.03 KB", + capMinified: 80879, alias }, { From 830156deff526e8c06da3c68325705587b0a84b6 Mon Sep 17 00:00:00 2001 From: Ryan Carniato Date: Sat, 17 Oct 2026 03:01:52 -0700 Subject: [PATCH 3/3] size: raise the compiled hydrating cap for the delete key-count fix (CI: +2 B brotli, +33 B minified) Co-Authored-By: Claude Fable 5.1 --- scripts/size/scenarios.js | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/scripts/size/scenarios.js b/scripts/size/scenarios.js index 0c97a61efc..53889c709e 100644 --- a/scripts/size/scenarios.js +++ b/scripts/size/scenarios.js @@ -4047,8 +4047,9 @@ module.exports = [ // Lower only: cap at measured + 10 B rounded up to 0.01 KB; recorded // minified never raised. // mapArray window-staged commit (perf, #4114, 2026-10-16, merged over next @ dbc5d0005 after #4104's ratchet and #4108's observe raises): 31.72 -> 31.96 KB, measured at 31,950 B brotli / 101,408 B minified (+230 B brotli over the cap, +648 B minified over the recorded size): updateKeyedMap stages only the changed window, commits its bookkeeping arrays in place and takes a Map-free pure-removal path. Local measurement; CI's bytes re-base this cap. - limit: "31.96 KB", - capMinified: 101408, + // store delete key-count fix (#4132, 2026-10-17): 31.96 -> 31.98 KB, measured at 31,962 B brotli / 101,441 B minified (+2 B brotli over the cap, +33 B minified over the recorded size; CI and local agree): deleteProperty decrements `kc` for a draft's own key, so a keyed store that deletes keys no longer drifts into overlay rebuilds. Correctness. + limit: "31.98 KB", + capMinified: 101441, alias }, {