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..64edbae5de 100644 --- a/packages/signals/src/store/store.ts +++ b/packages/signals/src/store/store.ts @@ -2232,8 +2232,21 @@ const traps: ProxyHandler = { if (!draft && !override) return true; const pb = ensurePB(target); touch(target, key); - delete pb[key as any]; - if (target.ovl && hasOwn.call(target.v, key)) (target.del ??= new Set()).add(key); + // 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. + const committed = target.ovl && hasOwn.call(target.v, key); + if (hasOwn.call(pb, key)) { + delete pb[key as any]; + committed || target.kc--; + } + if (committed) (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); + }); +}); diff --git a/scripts/size/scenarios.js b/scripts/size/scenarios.js index 10a7aae75f..a9e1c02787 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 }, { @@ -2584,8 +2585,10 @@ module.exports = [ // 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. // A held error reaches a derivation of `latest` (#4113, #4106, 2026-10-17, merged over next @ 01270641): 30.09 -> 30.14 KB, measured locally at 30,125 B brotli / 94,611 B minified (+32 B minified over the recorded size): `laneRead` and `verdictValue` hand a held error to an override/derived read the way they hand a value, and `stageError` schedules the verdict wake. Cap at measured + 10 B rounded up to 0.01 KB. Accepted by the maintainer (2026-10-17). + // 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. + // Merged over next @ 7093b074b after #4129 raised this same cap 30.09 -> 30.14 KB for its own bytes (2026-10-17): the two together measured locally at 30,116 B brotli / 94,644 B minified, under the cap, so the cap stays at 30.14 KB; recorded minified 94,611 -> 94,644. limit: "30.14 KB", - capMinified: 94611, + capMinified: 94644, alias }, { @@ -3897,8 +3900,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 }, { @@ -4045,8 +4049,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 }, {