Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/store-delete-key-count.md
Original file line number Diff line number Diff line change
@@ -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.
17 changes: 15 additions & 2 deletions packages/signals/src/store/store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2232,8 +2232,21 @@ const traps: ProxyHandler<StoreTarget> = {
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;
}
Expand Down
95 changes: 95 additions & 0 deletions packages/signals/tests/store/delete-key-count.test.ts
Original file line number Diff line number Diff line change
@@ -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<Record<string, any>>({ 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<Record<string, number>>({ 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<Record<string, number>>({ 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<string, number> = {};
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);
});
});
19 changes: 12 additions & 7 deletions scripts/size/scenarios.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
},
{
Expand Down Expand Up @@ -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
},
{
Expand Down Expand Up @@ -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
},
{
Expand Down Expand Up @@ -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
},
{
Expand Down
Loading