diff --git a/.changeset/maparray-small-move-fast-path.md b/.changeset/maparray-small-move-fast-path.md new file mode 100644 index 000000000..4bfe44514 --- /dev/null +++ b/.changeset/maparray-small-move-fast-path.md @@ -0,0 +1,7 @@ +--- +"@solidjs/signals": patch +--- + +mapArray SMALL-MOVE fast path: rotates, swaps, small displacements, and removals leave a keyed window that is the old window shifted with a bounded number of genuinely displaced identities — but the general diff paid O(newLen) regardless (window key-map, four full-length staged arrays, element-copied prefix/suffix), measured at ~50-140µs/op on 1000 rows against ~20µs of actual DOM work (the jfb-reorder suite's stable 1.8-4x deficits). The fast path scans first (two-pointer aligned-run detection with bounded realignment lookahead and a compare budget, so hopeless shapes like reverse bail almost immediately with nothing allocated), then commits by slicing the live arrays (native memcpy preserves the fresh-identity contract downstream change propagation relies on), copying only shifted runs, patching the displaced few, and disposing leftover sources. Gated to large trimmed windows (the trims already make small windows cheap), scoped to the plain identity-keyed mode (row-signal/custom-key/index modes keep the general path — halves the code for the same benchmark wins), and kept out of updateKeyedMap's function body (inlining deoptimized the general path). Rotate 140→6µs, swap 94→4µs, displace3 54→5µs; removefirst and reverse at parity; ~0.5 kB brotli in mapArray-bearing bundles. + +Cold path (2026-09-09): the scan and the commit are two functions, so a pass that scans and bails — a full REPLACE, typically a page's first structural pass — compiles only the scan; and a 65-compare pre-probe in `updateKeyedMap` (is a mid-window item still within ±32 of its old position?) turns a replace away before the scan is even called. Interleaved A/B against `next`, cold (fresh page per sample) and warm: run/replace/runlots/clear at parity, swap and rotate ~0.5x, reverse/shuffle unchanged. On Octane's js-framework board: reorder suite 1.71x → 1.15x vs octane, js-framework 1.27x → 1.10x. diff --git a/packages/signals/src/map.ts b/packages/signals/src/map.ts index f9fd04f2c..8b02db449 100644 --- a/packages/signals/src/map.ts +++ b/packages/signals/src/map.ts @@ -118,6 +118,189 @@ const pureOptions = { ownedWrite: true }; // were, so the retry diffs against uncorrupted state. Consequence of the // strong-abort ordering: removed rows now dispose AFTER the pass's new rows // are created (you cannot destroy state before knowing the pass will land). + +/** SMALL-MOVE fast path (jfb-reorder profile, 2026-09-02): rotates, swaps, + * small displacements, and removals leave a keyed window that is the old + * window SHIFTED, with 32-or-fewer genuinely displaced identities — but the + * general diff pays O(newLen) regardless (window key-map, four full-length + * staged arrays, element-copied prefix/suffix): ~50-140µs/op on 1000 rows + * against ~20µs of actual DOM work. + * + * Scoped to the PLAIN identity-keyed mode (no row signals, no index + * accessors — the hot For shape); other modes keep the general path, which + * halves this function's size for the same benchmark wins. + * + * PHASE 1 (scan, zero allocation beyond two small ledgers): a two-pointer + * walk records ALIGNED RUNS — at most ledger+1 — realigning at boundaries + * with bounded lookahead (interleaved splices stack shift offsets past + * single-step). A compare BUDGET bails hopeless shapes (reverse, shuffle) + * almost immediately. PHASE 2 (commit, success only): slice() the live + * arrays (native memcpy keeps the fresh-identity contract downstream change + * propagation relies on), copy only shifted runs, patch displaced pairs, + * dispose leftover sources (dif < 0). Unmatched destinations (replacements, + * insertions) bail with nothing staged. Kept OUT of updateKeyedMap: + * inlining deoptimized the general path (JIT function-size budget). */ +/** Dev-only engagement counter (tests prove the fast path actually ran). */ +let smallMoveHits = 0; +/** @internal */ +export function __smallMoveHits(): number { + return smallMoveHits; +} + +function trySmallMove( + data: MapData, + newItems: Item[], + newLen: number, + start: number +): boolean { + const oldItems = data._items; + const oldEnd = data._len - 1; + const srcPos: number[] = []; + const dstPos: number[] = []; + const runs: number[] = []; // flat triples: oldStart, newStart, length + let budget = 256; + let i = start; + let j = start; + let inRun = false; + while (i <= oldEnd && j <= newLen - 1) { + const oldItem = oldItems[i]; + const newItem = newItems[j]; + if (oldItem === newItem) { + if (!inRun) { + runs.push(i, j, 0); + inRun = true; + } + runs[runs.length - 1]++; + i++; + j++; + continue; + } + inRun = false; + // Bounded realignment lookahead, shorter distance wins. + let del = -1; + let lim = Math.min(32 - srcPos.length, oldEnd - i, budget); + for (let a = 1; a <= lim; a++) { + if (oldItems[i + a] === newItem) { + del = a; + break; + } + } + budget -= del === -1 ? lim : del; + let ins = -1; + lim = Math.min(32 - dstPos.length, newLen - 1 - j, budget); + for (let a = 1; a <= lim; a++) { + if (newItems[j + a] === oldItem) { + ins = a; + break; + } + } + budget -= ins === -1 ? lim : ins; + if (del !== -1 && (ins === -1 || del <= ins)) { + while (del-- > 0) srcPos.push(i++); + continue; + } + if (ins !== -1) { + while (ins-- > 0) dstPos.push(j++); + continue; + } + if (budget <= 0 || srcPos.length === 32 || dstPos.length === 32) return false; + srcPos.push(i++); + dstPos.push(j++); + } + for (; i <= oldEnd; i++) { + if (srcPos.length === 32) return false; + srcPos.push(i); + } + for (; j <= newLen - 1; j++) { + if (dstPos.length === 32) return false; + dstPos.push(j); + } + return commitSmallMove(data, newItems, newLen, srcPos, dstPos, runs); +} + +/** PHASE 2 of the small-move path, in its OWN function so that a pass which + * only SCANS and bails (a full replace: the first structural pass of a page, + * typically) compiles nothing but the scan — V8 parses and compiles lazily + * per function, and cold `replace` measured +0.5 ms with both phases in one + * body. Pairs displaced sources with destinations (an unmatched destination + * is a replacement/insertion → general path), then commits: slice() the live + * arrays, copy shifted runs, patch displaced pairs, dispose leftovers. */ +function commitSmallMove( + data: MapData, + newItems: Item[], + newLen: number, + srcPos: number[], + dstPos: number[], + runs: number[] +): boolean { + const oldItems = data._items; + let i: number; + let j: number; + let consumed: boolean[] | undefined; + if (dstPos.length !== 0) { + consumed = new Array(srcPos.length); + for (j = 0; j < dstPos.length; j++) { + let found = -1; + for (i = 0; i < srcPos.length; i++) { + if (!consumed[i] && oldItems[srcPos[i]] === newItems[dstPos[j]]) { + found = i; + break; + } + } + if (found === -1) return false; + consumed[found] = true; + dstPos[j] = (dstPos[j] << 6) | found; // pack pairing (found < 32) + } + } + // DUPLICATES: the general path pairs equal identities by OCCURRENCE ORDER + // (the chained index map). Displaced↔displaced pairing above is ascending + // on both sides, so it agrees; but an aligned run was matched by POSITION, + // and if a displaced identity also occurs inside a run the two algorithms + // can hand different occurrences different owners (row-local state moves; + // a shrink could dispose the wrong one). Decline that case — general path. + if (srcPos.length !== 0 || dstPos.length !== 0) { + const displaced = new Set(); + for (i = 0; i < srcPos.length; i++) displaced.add(oldItems[srcPos[i]]); + for (j = 0; j < dstPos.length; j++) displaced.add(newItems[dstPos[j] >> 6]); + for (let r = 0; r < runs.length; r += 3) { + const ro = runs[r]; + for (let a = 0, n = runs[r + 2]; a < n; a++) + if (displaced.has(oldItems[ro + a])) return false; + } + } + // PHASE 2: commit. + if (__DEV__) smallMoveHits++; + const oldMappings = data._mappings; + const oldNodes = data._nodes; + const mappings = oldMappings.slice(0, newLen); + const nodes = oldNodes.slice(0, newLen); + for (let r = 0; r < runs.length; r += 3) { + const ro = runs[r]; + const rn = runs[r + 1]; + if (ro !== rn) { + for (let a = 0; a < runs[r + 2]; a++) { + mappings[rn + a] = oldMappings[ro + a]; + nodes[rn + a] = oldNodes[ro + a]; + } + } + } + for (j = 0; j < dstPos.length; j++) { + const p = dstPos[j] >> 6; + const q = srcPos[dstPos[j] & 63]; + mappings[p] = oldMappings[q]; + nodes[p] = oldNodes[q]; + } + data._mappings = mappings; + data._nodes = nodes; + data._len = newLen; + data._items = newItems.slice(0); + // Dispose unmatched sources LAST (general-path ordering). + for (i = 0; i < srcPos.length; i++) { + if (consumed === undefined || !consumed[i]) oldNodes[srcPos[i]].dispose(); + } + return true; +} + function updateKeyedMap(this: MapData): any[] { const newItems = this._list() || [], newLen = newItems.length; @@ -244,6 +427,32 @@ function updateKeyedMap(this: MapData): any[ return; } + // SMALL-MOVE FAST PATH: extracted to its own function — inlining it + // here bloats updateKeyedMap past the JIT's optimization budget and + // deoptimizes the GENERAL path (measured 2x on reverse). Gated to + // LARGE trimmed windows: when the trims already shrank the window + // (plain removals, tail edits), the general path is window- + // proportional and cheap — the fast path would only re-walk what the + // trims proved. + if ( + newLen <= this._len && + end - start > 64 && + this._rows === undefined && + this._indexes === undefined + ) { + // PROBE before the scan: a small move keeps a mid-window item within + // ±32 of its old position; a REPLACE (all fresh items — the shape + // every page's first structural pass usually is) has it nowhere. + // ~65 compares, no allocation, and the scan function is never + // compiled for a replace (its cold first-call compile was the cost). + const m = start + ((newEnd - start) >> 1); + const probe = newItems[m]; + const hi = Math.min(end, m + 32); + let k = Math.max(start, m - 32); + while (k <= hi && this._items[k] !== probe) k++; + if (k <= hi && trySmallMove(this, newItems as Item[], newLen, start)) return; + } + const dif = newLen - this._len; const temp: MappedItem[] = new Array(newLen); const tempNodes: Root[] = new Array(newLen); diff --git a/packages/signals/tests/mapArray-smallmove.test.ts b/packages/signals/tests/mapArray-smallmove.test.ts new file mode 100644 index 000000000..7d11f5a08 --- /dev/null +++ b/packages/signals/tests/mapArray-smallmove.test.ts @@ -0,0 +1,302 @@ +import { describe, expect, it } from "vitest"; +import { createRoot, createSignal, flush, mapArray, onCleanup } from "../src/index.js"; +import { __smallMoveHits } from "../src/map.js"; + +/** SMALL-MOVE fast path: after prefix/suffix trimming, a same-or-shorter + * window whose mismatches are ≤32 displaced identities commits as in-place + * patches over sliced arrays — no window Map, no staging arrays. + * + * Every test here PROVES which path ran (`__smallMoveHits`, dev-only) and + * pins semantics against an ORACLE: the same source sequence driven through + * the general path (an arity-2 mapper creates index signals, which the fast + * path declines). Mapped values carry a creation sequence number, so "same + * mapped array" means the same OWNERS ended up at the same positions — the + * duplicate-pairing contract — and disposal order is compared too. */ + +type Item = { id: number }; +type Mapped = { item: Item; seq: number }; + +function rotateF(a: readonly T[]): T[] { + return [...a.slice(1), a[0]]; +} +function rotateB(a: readonly T[]): T[] { + return [a[a.length - 1], ...a.slice(0, -1)]; +} +/** Move `k` evenly spaced rows to new positions (the jfb displace shape). */ +function displace(a: readonly T[], k: number): T[] { + const next = [...a]; + for (let i = 0; i < k; i++) { + const from = Math.floor(((i + 1) * next.length) / (k + 2)); + const [row] = next.splice(from, 1); + next.splice((from + 7) % next.length, 0, row); + } + return next; +} +const ids = (m: Mapped[]) => m.map(x => x.item.id); +const seqs = (m: Mapped[]) => m.map(x => x.seq); + +/** Drive one source through BOTH paths: `fast` (arity-1 mapper, eligible) + * and `oracle` (arity-2 mapper → index signals → general path always). */ +function pair(initial: Item[]) { + let seq = 0; + const disposed: { fast: number[]; oracle: number[] } = { fast: [], oracle: [] }; + const mk = + (side: "fast" | "oracle") => + (item: Item): Mapped => { + const m = { item, seq: seq++ }; + onCleanup(() => disposed[side].push(m.seq)); + return m; + }; + const [$fast, setFast] = createSignal(initial); + const [$oracle, setOracle] = createSignal(initial); + let fast!: () => Mapped[]; + let oracle!: () => Mapped[]; + const dispose = createRoot(d => { + fast = mapArray($fast, mk("fast")); + const o = mk("oracle"); + oracle = mapArray($oracle, (item: Item, _index: () => number) => o(item)); + fast(); + oracle(); + return d; + }); + const set = (next: Item[]) => { + setFast(next); + setOracle(next); + flush(); + }; + /** After a set: mapped ids equal on both sides (correctness), and the + * ORDER of owners (seq) equals the oracle's (duplicate pairing). */ + const agree = () => { + expect(ids(fast())).toEqual(ids(oracle())); + // Owners created on the two sides interleave in seq; compare RELATIVE order. + const rank = (m: Mapped[]) => { + const sorted = [...m].map(x => x.seq).sort((a, b) => a - b); + return m.map(x => sorted.indexOf(x.seq)); + }; + expect(rank(fast())).toEqual(rank(oracle())); + expect(disposed.fast.length).toBe(disposed.oracle.length); + }; + return { set, fast, oracle, agree, disposed, dispose }; +} + +const items = (n: number): Item[] => Array.from({ length: n }, (_, i) => ({ id: i })); +const hits = () => __smallMoveHits(); + +describe("mapArray small-move fast path — engagement", () => { + it("engages for an arity-1 mapper on a >64 window and agrees with the general path", () => { + const p = pair(items(200)); + const before = hits(); + const rotated = rotateF(p.oracle().map(m => m.item)); + p.set(rotated); + expect(hits()).toBe(before + 1); + p.agree(); + expect(ids(p.fast())).toEqual(rotated.map(i => i.id)); + p.dispose(); + }); + + it("does NOT engage for an arity-2 mapper (index signals) — the oracle path", () => { + let seq = 0; + const [$s, set] = createSignal(items(200)); + let m!: () => Mapped[]; + const dispose = createRoot(d => { + m = mapArray($s, (item: Item, _i: () => number) => ({ item, seq: seq++ })); + m(); + return d; + }); + const before = hits(); + set(rotateF(items(200).map((_, i) => m()[i].item))); + flush(); + expect(hits()).toBe(before); + dispose(); + }); + + it("window gate (`end - start > 64`): a 66-row changed window engages, 65 does not", () => { + for (const [window, engages] of [ + [66, true], + [65, false] + ] as const) { + // Prefix of 100 unchanged rows, then a rotation of exactly `window` + // rows: the trims leave start=100, end=100+window-1. + const src = items(100 + window); + const p = pair(src); + const head = src.slice(0, 100); + const tail = src.slice(100); + const before = hits(); + p.set([...head, ...rotateF(tail)]); + expect(hits() - before, `window ${window}`).toBe(engages ? 1 : 0); + p.agree(); + p.dispose(); + } + }); + + it("displacement bound: 32 displaced rows engage, 33 fall to the general path", () => { + for (const [k, engages] of [ + [32, true], + [33, false] + ] as const) { + const src = items(400); + const p = pair(src); + // Move the first k rows to the END as a block: k displaced identities. + const next = [...src.slice(k), ...src.slice(0, k)]; + const before = hits(); + p.set(next); + expect(hits() - before, `k=${k}`).toBe(engages ? 1 : 0); + p.agree(); + expect(ids(p.fast())).toEqual(next.map(i => i.id)); + p.dispose(); + } + }); +}); + +describe("mapArray small-move fast path — semantics vs the general path", () => { + it("rotate forward / backward: mapped owners move with their items, nothing re-created", () => { + const p = pair(items(300)); + const created = () => p.fast().length + p.disposed.fast.length; + const c0 = created(); + for (const op of [rotateF, rotateB, rotateF, rotateF]) { + p.set(op(p.oracle().map(m => m.item))); + p.agree(); + } + expect(created()).toBe(c0); + expect(p.disposed.fast).toEqual([]); + p.dispose(); + }); + + it("scattered displacements k = 3..8 agree with the general path whether or not they engage", () => { + // Engagement is an optimization, not a contract: the scan may decline a + // scatter it can't realign within its lookahead. Correctness never varies. + const p = pair(items(500)); + const before = hits(); + for (let k = 3; k <= 8; k++) { + p.set( + displace( + p.oracle().map(m => m.item), + k + ) + ); + p.agree(); + } + expect(hits()).toBeGreaterThan(before); // and it does engage for most of them + p.dispose(); + }); + + it("adjacent swap (jfb swap rows) engages", () => { + const p = pair(items(1000)); + const src = p.oracle().map(m => m.item); + const next = [...src]; + [next[1], next[998]] = [next[998], next[1]]; + const before = hits(); + p.set(next); + expect(hits()).toBe(before + 1); + p.agree(); + p.dispose(); + }); + + it("shrink: displaced rows that leave are disposed, the same ones the general path disposes", () => { + const p = pair(items(300)); + const src = p.oracle().map(m => m.item); + // Drop 5 rows from the middle and rotate the rest by one: a non-growing move with leavers. + const kept = src.filter((_, i) => i < 100 || i >= 105); + const before = hits(); + p.set(rotateF(kept)); + expect(hits()).toBe(before + 1); + p.agree(); + expect(p.disposed.fast.length).toBe(5); + p.dispose(); + }); + + it("growth (newLen > oldLen) is excluded — general path", () => { + const p = pair(items(200)); + const src = p.oracle().map(m => m.item); + const before = hits(); + p.set([...rotateF(src), { id: 9999 }]); + expect(hits()).toBe(before); + p.agree(); + p.dispose(); + }); + + it("replacement inside the window bails: fresh row created, old disposed", () => { + const p = pair(items(200)); + const src = p.oracle().map(m => m.item); + const next = [...src]; + next[100] = { id: 424242 }; + const before = hits(); + p.set(next); + expect(hits()).toBe(before); + p.agree(); + expect(p.disposed.fast.length).toBe(1); + p.dispose(); + }); + + it("full replace (every item fresh) bails before the scan (pre-probe)", () => { + const p = pair(items(1000)); + const before = hits(); + p.set(items(1000)); // all new objects + expect(hits()).toBe(before); + p.agree(); + expect(p.disposed.fast.length).toBe(1000); + p.dispose(); + }); +}); + +describe("mapArray small-move fast path — duplicate identities", () => { + it("a displaced identity that also occurs in an aligned run DECLINES (occurrence-order pairing preserved)", () => { + // The audit's shape, embedded in a >64 window: old [A,B,A,C] → new [B,A,C,A]. + const A = { id: 1 }, + B = { id: 2 }, + C = { id: 3 }; + const filler = items(100).map(i => ({ id: 1000 + i.id })); + const src = [A, B, A, C, ...filler]; + const p = pair(src); + const before = hits(); + p.set([B, A, C, A, ...rotateF(filler)]); + // Declined: the general path pairs the two A occurrences in order. + expect(hits()).toBe(before); + p.agree(); + p.dispose(); + }); + + it("duplicates only among DISPLACED rows pair ascending on both sides — may engage, must agree", () => { + const A = { id: 1 }; + const filler = items(200).map(i => ({ id: 1000 + i.id })); + // Two A's at the front move together to the back. + const src = [A, A, ...filler]; + const p = pair(src); + p.set([...filler, A, A]); + p.agree(); + p.dispose(); + }); + + it("duplicate removal disposes the same occurrence the general path does", () => { + const A = { id: 1 }; + const filler = items(200).map(i => ({ id: 1000 + i.id })); + const src = [A, ...filler.slice(0, 100), A, ...filler.slice(100)]; + const p = pair(src); + // Drop the second A and rotate the tail: a shrink involving a duplicate. + p.set([A, ...filler.slice(0, 100), ...rotateF(filler.slice(100))]); + p.agree(); + expect(p.disposed.fast.length).toBe(1); + p.dispose(); + }); + + it("random duplicate-heavy reorders: 200 rounds, fast path always agrees with the general path", () => { + let s = 12345; + const rnd = (n: number) => (s = (s * 1103515245 + 12345) >>> 0) % n; + const base = items(120); // 120 identities, some used twice → duplicates everywhere + const p = pair([...base, ...base.slice(0, 30)]); + for (let round = 0; round < 200; round++) { + const cur = p.oracle().map(m => m.item); + const next = [...cur]; + const moves = 1 + rnd(6); + for (let m = 0; m < moves; m++) { + const from = rnd(next.length); + const [row] = next.splice(from, 1); + next.splice(rnd(next.length), 0, row); + } + if (rnd(4) === 0) next.splice(rnd(next.length), 1); // occasional shrink + p.set(next); + p.agree(); + } + p.dispose(); + }); +}); diff --git a/scripts/size/.size-limit.js b/scripts/size/.size-limit.js index 934789f05..3270757d2 100644 --- a/scripts/size/.size-limit.js +++ b/scripts/size/.size-limit.js @@ -505,6 +505,13 @@ module.exports = [ // 17.58. The channel is deleted from next — regions own value delivery, // the unified-For design owns structure — reclaiming the insert $ll seam and core emission bytes. limit: "17.61 KB", + // + // mapArray SMALL-MOVE fast path (#3227, rebased 2026-09-09): 17.61 KB -> + // 18.15 KB, measured at 18.11. Scan + commit as two functions (a + // replace compiles only the scan) plus a 65-compare pre-probe in + // updateKeyedMap; identity-keyed mode only. Interleaved A/B vs next, cold + // and warm: creation/clear at parity, swap and rotate ~0.5x. + limit: "18.15 KB", modifyEsbuildConfig }, { @@ -608,6 +615,13 @@ module.exports = [ // came in UNDER its pre-fix size — see its note). The usual +4-7 B // Linux delta leaves ~20 B headroom. limit: "26.45 KB", + // + // mapArray SMALL-MOVE fast path (#3227, rebased 2026-09-09): 26.45 KB -> + // 27.05 KB, measured at 27.01. Scan + commit as two functions (a + // replace compiles only the scan) plus a 65-compare pre-probe in + // updateKeyedMap; identity-keyed mode only. Interleaved A/B vs next, cold + // and warm: creation/clear at parity, swap and rotate ~0.5x. + limit: "27.05 KB", modifyEsbuildConfig }, { @@ -654,6 +668,19 @@ module.exports = [ // those bytes land here, and this scenario was not ratcheted with the // hydrating ones. Ratchet on next so the branch is green again. limit: "13.01 KB", + // + // mapArray SMALL-MOVE fast path (#3227, rebased 2026-09-09): 13.01 KB -> + // 13.53 KB, measured at 13.49. Scan + commit as two functions (a + // replace compiles only the scan) plus a 65-compare pre-probe in + // updateKeyedMap; identity-keyed mode only. Interleaved A/B vs next, cold + // and warm: creation/clear at parity, swap and rotate ~0.5x. + limit: "13.53 KB", + // + // #3227 audit round (2026-09-09): 13.53 -> 13.55 KB, measured at 13.54 + // (+54 B: duplicate-identity decline — a displaced identity that also + // sits in an aligned run falls to the general path, preserving + // mapArray's occurrence-order pairing). + limit: "13.55 KB", modifyEsbuildConfig }, { @@ -672,6 +699,13 @@ module.exports = [ // `@solidjs/signals/attribution` and is charged by the scenario below. path: "csr-app.js", limit: "14.30 KB", + // + // mapArray SMALL-MOVE fast path (#3227, rebased 2026-09-09): 14.30 KB -> + // 14.80 KB, measured at 14.77. Scan + commit as two functions (a + // replace compiles only the scan) plus a 65-compare pre-probe in + // updateKeyedMap; identity-keyed mode only. Interleaved A/B vs next, cold + // and warm: creation/clear at parity, swap and rotate ~0.5x. + limit: "14.80 KB", modifyEsbuildConfig: observeEsbuildConfig }, { @@ -686,6 +720,17 @@ module.exports = [ // in documentation/plans/observe-tier-plan.md. path: "csr-app-attribution.js", limit: "24.00 KB", + // + // mapArray SMALL-MOVE fast path (#3227, rebased 2026-09-09): 24.00 KB -> + // 24.50 KB, measured at 24.47. Scan + commit as two functions (a + // replace compiles only the scan) plus a 65-compare pre-probe in + // updateKeyedMap; identity-keyed mode only. Interleaved A/B vs next, cold + // and warm: creation/clear at parity, swap and rotate ~0.5x. + limit: "24.50 KB", + // + // #3227 audit round (2026-09-09): 24.50 -> 24.60 KB, measured at 24.58 + // (duplicate decline + the dev-only engagement counter this tier keeps). + limit: "24.60 KB", modifyEsbuildConfig: observeEsbuildConfig }, {