From 0887edc7fa4819bbe5a7f8f4396f65004023adf0 Mon Sep 17 00:00:00 2001 From: Brenley Dueck Date: Mon, 2 Nov 2026 16:14:57 -0600 Subject: [PATCH] perf(store): skip per-call allocations on the store setter path --- .changeset/store-write-skip-allocations.md | 5 ++ packages/signals/src/store/store.ts | 48 ++++++++++---- packages/signals/src/store/target.ts | 10 +-- .../store/written-key-chain-privatize.test.ts | 64 +++++++++++++++++++ scripts/size/scenarios.js | 20 +++--- 5 files changed, 122 insertions(+), 25 deletions(-) create mode 100644 .changeset/store-write-skip-allocations.md create mode 100644 packages/signals/tests/store/written-key-chain-privatize.test.ts diff --git a/.changeset/store-write-skip-allocations.md b/.changeset/store-write-skip-allocations.md new file mode 100644 index 000000000..27f811533 --- /dev/null +++ b/.changeset/store-write-skip-allocations.md @@ -0,0 +1,5 @@ +--- +"@solidjs/signals": patch +--- + +A store setter no longer allocates a Set for its write scope, and a setter that writes one key of a record keeps that key bare instead of in a Set. A single-key write through a store setter is about 22% faster. diff --git a/packages/signals/src/store/store.ts b/packages/signals/src/store/store.ts index 1db6936b7..cefeda9c5 100644 --- a/packages/signals/src/store/store.ts +++ b/packages/signals/src/store/store.ts @@ -657,6 +657,9 @@ const OVERLAY_MIN_KEYS = 32; const OVERLAY_REBUILD_MAX_KEYS = 1024; const OVERLAY_REBUILD_MIN_ADDS = 16; const WK_ALL = new Set() as WrittenKeys; +/** A bare written key iterates as a set of one. */ +const writtenList = (wk: WrittenKeys | PropertyKey | null): Iterable | null => + typeof wk === "object" ? wk : [wk]; const plainProto = (o: object): boolean => { const p = Object.getPrototypeOf(o); @@ -669,7 +672,11 @@ function overlayRebuilds(t: StoreTarget, pb: Record): boolean if (t.kc > OVERLAY_REBUILD_MAX_KEYS) return false; if (t.del?.size) return true; const wk = t.wk; - if (wk !== null && wk !== WK_ALL && wk.u === undefined && wk.size <= OVERLAY_REBUILD_MIN_ADDS) + if ( + wk !== null && + wk !== WK_ALL && + (typeof wk !== "object" || (wk.u === undefined && wk.size <= OVERLAY_REBUILD_MIN_ADDS)) + ) return false; const v = t.v; let adds = 0; @@ -1001,8 +1008,8 @@ function foldTarget(t: StoreTarget, old: Record): void { const v = t.v; const wk = t.wk; if (wk !== null && wk !== WK_ALL) { - for (let w: WrittenKeys | undefined = wk; w !== undefined; w = w.u) - for (const key of w) { + for (let w: any = wk; w !== undefined; w = w.u) + for (const key of writtenList(w)!) { if (hasOwn.call(pb, key)) copyOwn(v, pb, key); else delete (v as any)[key]; } @@ -1201,7 +1208,9 @@ function notifyWrites(t: StoreTarget): void { // a held node its unchanged value again would join this tick to the hold. const wk0 = t.wk; const writtenKeys = - wk0 === WK_ALL || t.a === true || !plainProto(t.ovl ? (t.v as object) : pb) ? null : wk0; + wk0 === WK_ALL || t.a === true || !plainProto(t.ovl ? (t.v as object) : pb) + ? null + : writtenList(wk0); if (__OBSERVE__ && attrHooks !== null) reportReplacedContainers(t, old, pb, writtenKeys); if (t._fam !== null && !heldDerivationHit && writtenKeys !== null) for (const key of writtenKeys) @@ -1449,9 +1458,11 @@ export function notifyFold( // visibility let writing = 0; -/** Roots of the stores with an open setter: reads of THEIR drafts are the +/** Root of the store with an open setter: reads of ITS drafts are the * writer's (no tracking, pending backing served); every other store reads * normally — a derive's external reads are its dependencies (#3037). */ +let writeScope: any = null; +/** Further scopes joined by a served child proxy of another store. */ let writeScopes: Set | null = null; const pendingNotify = new Set(); @@ -1462,7 +1473,11 @@ function scopeKey(target: StoreTarget): any { return t; } function inDraft(target: StoreTarget): boolean { - return writeScopes !== null && writeScopes.has(scopeKey(target)); + let k; + return ( + writeScope !== null && + ((k = scopeKey(target)) === writeScope || (writeScopes !== null && writeScopes.has(k))) + ); } /** Shallow store: values served verbatim (a nested store proxy still wraps @@ -1474,9 +1489,12 @@ function serveShallow(target: StoreTarget, key: PropertyKey, v: any): any { } /** A child proxy served to a draft joins the draft's scope. */ function draftServe(target: StoreTarget, proxy: any): any { - if (writeScopes !== null && inDraft(target)) { + if (inDraft(target)) { const ct: StoreTarget | undefined = proxy?.[$TARGET]; - if (ct?.v !== undefined) writeScopes.add(scopeKey(ct)); + if (ct?.v !== undefined) { + const k = scopeKey(ct); + if (k !== writeScope) (writeScopes ??= new Set()).add(k); + } } return proxy; } @@ -2010,7 +2028,9 @@ function touch(target: StoreTarget, key: PropertyKey): void { target.wk = wk; } } - if (wk !== WK_ALL) (target.wk ??= new Set() as WrittenKeys).add(key); + if (wk === null || wk === key) target.wk = key; + else if (typeof wk !== "object") target.wk = (new Set() as WrittenKeys).add(wk).add(key); + else if (wk !== WK_ALL) wk.add(key); } /** Dev: targets (and their keys) already warned about for a write outside @@ -2104,7 +2124,7 @@ const traps: ProxyHandler & ThisType = { // beneath it, and any hold there, is not this reader's concern; a link // without one reads through below). const node0 = target.n?.[key as any]; - if (writeScopes === null && !writeOverride) { + if (writeScope === null && !writeOverride) { const nodeH = node0; if ( nodeH !== undefined && @@ -2480,9 +2500,10 @@ export function storeSetter(proxy: T, fn: (draft: T) => T | void, guard = tru if (writeHook !== null) writeHook(proxy as object); if (__DEV__ && guard) devGuardStoreSetterWrite(); const target: StoreTarget = (proxy as any)[$TARGET]; + const prevScope = writeScope; const prevScopes = writeScopes; - writeScopes = new Set(); - writeScopes.add(scopeKey(target)); + writeScope = scopeKey(target); + writeScopes = null; const prevUser = userWrite; userWrite = guard; // An outermost setter's exit notifies the targets it wrote. A @@ -2504,6 +2525,7 @@ export function storeSetter(proxy: T, fn: (draft: T) => T | void, guard = tru result = fn(proxy); } finally { writing--; + writeScope = prevScope; writeScopes = prevScopes; // Outermost setter exit: emit write-time notifications (one node write // per changed observed key) so holds and lanes engage now. @@ -2592,7 +2614,7 @@ export function createStore>( * keys, or a read inside a draft or write scope. Those keep the trap. */ installShallowView(t => { // Any open write scope keeps the trap (it covers `inDraft`). - if (t?.s && !(t.ch || t.a || writeScopes || writeOverride || t._fam?.opt)) + if (t?.s && !(t.ch || t.a || writeScope !== null || writeOverride || t._fam?.opt)) return (t._fam && pullFamily(t), readSource(t) as any[]); }); diff --git a/packages/signals/src/store/target.ts b/packages/signals/src/store/target.ts index 838d4b40c..50461f5ec 100644 --- a/packages/signals/src/store/target.ts +++ b/packages/signals/src/store/target.ts @@ -56,9 +56,10 @@ export interface StoreFamily { } /** One setter's written keys; `u` is the batch's earlier setters' (the - * fold's union), present only once a second setter writes the target. */ + * fold's union, a set or a bare key), present only once a second setter + * writes the target. */ export interface WrittenKeys extends Set { - u?: WrittenKeys; + u?: WrittenKeys | PropertyKey; } export interface StoreTarget { @@ -85,8 +86,9 @@ export interface StoreTarget { * maps) would otherwise pay a full node scan on every write — and to THIS * setter's keys (D-8: a key an earlier setter of the batch wrote is told * once, #3743). null = no trap writes this batch (bulk paths fall back to - * the full scan); WK_ALL = bound unusable (array length write). */ - wk: WrittenKeys | null; + * the full scan); WK_ALL = bound unusable (array length write); a bare + * key = this setter's only write, nothing chained. */ + wk: WrittenKeys | PropertyKey | null; /** Lazy deep-witness node: `deep()` subscribes ONE node per record instead * of one per path; write paths bump it only when it exists. Separate from * `k` so $TRACK/mapArray never rerun on leaf value changes (R9). */ diff --git a/packages/signals/tests/store/written-key-chain-privatize.test.ts b/packages/signals/tests/store/written-key-chain-privatize.test.ts new file mode 100644 index 000000000..266a1979f --- /dev/null +++ b/packages/signals/tests/store/written-key-chain-privatize.test.ts @@ -0,0 +1,64 @@ +/** + * A target's written keys chain across the setters of one batch; a setter + * that wrote a single key leaves it bare, not in a set. When a descendant's + * path copy privatizes the target mid-batch, the fold walks that chain to + * merge every setter's writes onto the private backing. + */ +import { describe, expect, it } from "vitest"; +import { createRoot, createStore, deep, flush, snapshot, untrack } from "../../src/index.js"; + +type Child = { id: number; selected: boolean }; +type Row = { id: number; label: string; selected: boolean; children: Child[] }; + +const seed = (): Row[] => [ + { id: 1, label: "a", selected: true, children: [{ id: 11, selected: true }] } +]; + +function make() { + return createRoot(() => { + const handles = createStore(() => seed(), [] as Row[]); + untrack(() => deep(handles[0])); + return handles; + }); +} + +const read = (store: any) => JSON.parse(JSON.stringify(snapshot(untrack(() => deep(store))))); + +describe("written-key chain on a target privatized mid-batch", () => { + it("a single-key setter behind a later setter commits", () => { + const [store, setStore] = make(); + setStore(prev => { + prev[0].children[0].selected = false; + }); + setStore(prev => { + prev[0].selected = false; + }); + setStore(prev => { + prev[0].label = "b"; + }); + flush(); + expect(read(store)).toEqual([ + { id: 1, label: "b", selected: false, children: [{ id: 11, selected: false }] } + ]); + }); + + it("a chain of two sets ending in a bare key commits", () => { + const [store, setStore] = make(); + setStore(prev => { + prev[0].children[0].selected = false; + }); + setStore(prev => { + prev[0].selected = false; + }); + setStore(prev => { + delete (prev[0] as any).label; + }); + setStore(prev => { + prev[0].id = 2; + }); + flush(); + expect(read(store)).toEqual([ + { id: 2, selected: false, children: [{ id: 11, selected: false }] } + ]); + }); +}); diff --git a/scripts/size/scenarios.js b/scripts/size/scenarios.js index 1f7739b27..314281a3c 100644 --- a/scripts/size/scenarios.js +++ b/scripts/size/scenarios.js @@ -1126,8 +1126,9 @@ module.exports = [ // Batch re-record (2026-11-01, over next @ 689347f, size job 124769765997 on #4563's head, whose non-test code is next's; batch #4546, #4563, test-only #4571): 14.99 KB -> 15.01 KB, recorded minified 43,999 -> 43,858 B (-141 B), measured at 14,997 B brotli. Minified shrank; the brotli over the old cap is mangle layout. Re-recorded once after the batch, per the batch process. // Batch re-record (2026-11-01, over next @ 77be406, size job 124886513372: #4582, #4583, #4586, #4572, #4567, #4585, #4581): cap unchanged at 15.01 KB, recorded minified 43,858 -> 43,860 B (+2 B), measured at 14,982 B brotli. Re-recorded once after the batch, per the batch process. // Batch re-record (2026-11-02, over next @ a56b10b, size job 125156290178: #4596, #4601, #4602, #4603, #4608): cap unchanged at 15.01 KB, recorded minified 43,860 -> 43,875 B (+15 B), measured at 14,996 B brotli. Re-recorded once after the batch, per the batch process. - limit: "15.01 KB", - capMinified: 43875, + // Store writes skip per-call allocations (2026-11-02, over next @ 456e6d5): 15.01 KB -> 15.07 KB, recorded minified 43,875 -> 43,925 B, measured locally at 15,055 B brotli (+44 B brotli, +183 B minified over next, which has not re-recorded #4620's -133 B). One setter scope is held in a variable and a setter's first written key is kept bare, so a single-key write allocates no Set: write-floor -22%, input burst -9 to -14%, array writes -7%. Cap at measured + 10 B rounded up to 0.01 KB; recorded minified = measured. + limit: "15.07 KB", + capMinified: 43925, alias }, { @@ -2780,11 +2781,12 @@ module.exports = [ // Batch re-record (2026-11-02, over next @ a56b10b, size job 125156290178: #4596, #4601, #4602, #4603, #4608): 32.77 KB -> 32.81 KB, recorded minified 98,962 -> 99,010 B (+48 B), measured at 32,795 B brotli. Re-recorded once after the batch, per the batch process. // Batch re-record (2026-11-02, over next @ 97e1826, size job 125171473550: #4611): 32.81 KB -> 32.90 KB, recorded minified 99,010 -> 99,155 B (+145 B), measured at 32,883 B brotli. #4611's growth was accepted by the maintainer. Re-recorded once after the batch, per the batch process. // Batch re-record (2026-11-02, over next @ 7ea8706, size job 125205107698: #4614, #4615): cap unchanged at 32.90 KB, recorded minified 99,155 -> 99,163 B (+8 B), measured at 32,837 B brotli. The growth of #4614 and #4615 was accepted by the maintainer. Re-recorded once after the batch, per the batch process. - limit: "32.90 KB", + // Store writes skip per-call allocations (2026-11-02, over next @ 456e6d5): 32.90 KB -> 32.96 KB, recorded minified 99,163 -> 99,218 B, measured locally at 32,947 B brotli (+89 B brotli, +188 B minified over next, which has not re-recorded #4620's -133 B). One setter scope is held in a variable and a setter's first written key is kept bare, so a single-key write allocates no Set: write-floor -22%, input burst -9 to -14%, array writes -7%. Cap at measured + 10 B rounded up to 0.01 KB; recorded minified = measured. + limit: "32.96 KB", // An Errored heals when the last reader that caught its error is disposed (#4373, #4384, 2026-10-28, re-recorded over next @ cbc0eebd): recorded minified 96,989 -> 97,047 B (+58 B minified over next's record), measured locally (linux) at 31,811 B brotli. Recorded minified = measured; CI re-bases. // Boundary readers keyed by the source they wait on (#4373), with enterLane's tail folded into one else-chain as an offset (-29 B on lane pages) (#4384, 2026-10-28, re-recorded over next @ 1b32ff2e): recorded minified 97,024 -> 97,045 B (+21 B minified over next's record), measured locally (linux) at 31,798 B brotli. Recorded minified = measured; CI re-bases. // latest() read inline and through a memo show the same screen (#4284 through a memo; a plain write beside an async memo), 2026-10-29, over next @ 7ebd4a32: recorded minified 97,045 -> 97,426 B (+240 B minified over next), measured locally (linux) at 31,976 B brotli (+109 B over next). A render effect a verdict memo's change re-runs reads as its verdict reader for that pass (`laneDirty`), a pass that read a staging before the frame had a transaction re-derives once as a verdict reader only if the frame parks, `latest()` of a first load in flight in a verdict lane is loading (also through an identity memo, a derived store and `isPending`), a lane's dissolution leaves such a load and the nodes pending on it uninitialized instead of committing `undefined`, a verdict lane born at a seam is judged at it, a pass born in a staged reader stays the frame's on a verdict-memo read (C4, #4010), a plain write beside `latest()` computes once (the seam re-derives a pre-transaction staged reader only if the frame parks), a verdict reader's plain read of a staging in a transaction only a `latest()` opened is the frame's read, so a memo of `latest()` computes once too (D-22; not a pass with no value yet, D-6), and an error stays the display while the memo it came through re-asks, a Loading fallback behind it included (D-39, async-gen 91501 #1831). recompute's CONFIG_VERDICT clear now sits behind the lane engine: -21 B minified on every floor. Correctness; the maintainer: "only flag me if brotli goes more than 20kb" (2026-10-29). Cap unchanged; recorded minified = measured. - capMinified: 99163, + capMinified: 99218, alias }, { @@ -4206,8 +4208,9 @@ module.exports = [ // Batch re-record (2026-10-31, over next @ 6bde511: #4511 after #4514): cap unchanged at 26.36 KB, recorded minified 80,074 -> 80,106 B (+32 B), measured at 26,303 B brotli. Re-recorded once after the batch, per the batch process. // Batch re-record (2026-10-31, over next @ ab3e844: #4519, #4523, #4450, #4522 after #4521): cap unchanged at 26.36 KB, recorded minified 80,106 -> 80,127 B (+21 B), measured at 26,311 B brotli. Re-recorded once after the batch, per the batch process. // Batch re-record (2026-11-02, over next @ 97e1826, size job 125171473550: #4611): 26.36 KB -> 26.41 KB, recorded minified 80,127 -> 80,107 B (-20 B), measured at 26,396 B brotli. #4611's growth was accepted by the maintainer. Re-recorded once after the batch, per the batch process. - limit: "26.41 KB", - capMinified: 80107, + // Store writes skip per-call allocations (2026-11-02, over next @ 456e6d5): 26.41 KB -> 26.50 KB, recorded minified 80,107 -> 80,161 B, measured locally at 26,482 B brotli (+80 B brotli, +188 B minified over next, which has not re-recorded #4620's -133 B). One setter scope is held in a variable and a setter's first written key is kept bare, so a single-key write allocates no Set: write-floor -22%, input burst -9 to -14%, array writes -7%. Cap at measured + 10 B rounded up to 0.01 KB; recorded minified = measured. + limit: "26.50 KB", + capMinified: 80161, alias }, { @@ -4387,8 +4390,9 @@ module.exports = [ // Batch re-record (2026-10-31, over next @ ab3e844: #4519, #4523, #4450, #4522 after #4521): 32.54 KB -> 32.56 KB, recorded minified 100,868 -> 100,886 B (+18 B), measured at 32,542 B brotli. Re-recorded once after the batch, per the batch process. // Batch re-record (2026-10-31, over next @ 3a73a36: #4529, #4533, #4494, #4534, #4532): 32.56 KB -> 32.61 KB, recorded minified 100,886 -> 100,851 B (-35 B), measured locally (linux) at 32,591 B brotli. Minified fell; the brotli rise is mangle-props layout from #4494's renamed store fields, not new code. Re-recorded once after the batch, per the batch process. // Batch re-record (2026-11-02, over next @ 97e1826, size job 125171473550: #4611): 32.61 KB -> 32.66 KB, recorded minified 100,851 -> 100,825 B (-26 B), measured at 32,647 B brotli. #4611's growth was accepted by the maintainer. Re-recorded once after the batch, per the batch process. - limit: "32.66 KB", - capMinified: 100825, + // Store writes skip per-call allocations (2026-11-02, over next @ 456e6d5): 32.66 KB -> 32.79 KB, recorded minified 100,825 -> 100,867 B, measured locally at 32,773 B brotli (+143 B brotli, +188 B minified over next, which has not re-recorded #4620's -133 B). One setter scope is held in a variable and a setter's first written key is kept bare, so a single-key write allocates no Set: write-floor -22%, input burst -9 to -14%, array writes -7%. Cap at measured + 10 B rounded up to 0.01 KB; recorded minified = measured. + limit: "32.79 KB", + capMinified: 100867, alias }, {