diff --git a/.changeset/map-array-subsequence-fast-paths.md b/.changeset/map-array-subsequence-fast-paths.md new file mode 100644 index 0000000000..23f1d4fd9b --- /dev/null +++ b/.changeset/map-array-subsequence-fast-paths.md @@ -0,0 +1,5 @@ +--- +"@solidjs/signals": patch +--- + +`mapArray` commits a structural change without rebuilding full-length copies of its row bookkeeping: a pass stages only the changed window, lands it into the live arrays in place with the unchanged suffix shifted by a loop, and rebuilds only the mapped array that signals the change downstream. A pass that only removes rows is matched with two pointers and no key map, and the key-chain array is sized to the window rather than to the edit's position. One-item inserts and removals on long keyed lists, every-nth removals and sparse filters no longer allocate several list-sized arrays per edit; the strong-abort contract for a mapper that suspends is unchanged, kept by ordering (verify, create, then mutate) instead of by copying. diff --git a/documentation/benchmarking-strategy.md b/documentation/benchmarking-strategy.md index 5ce31c89f6..bcee5e38ff 100644 --- a/documentation/benchmarking-strategy.md +++ b/documentation/benchmarking-strategy.md @@ -392,26 +392,51 @@ campaign that built this and what it found is the [Store / List Delivery Lane](./performance-experiments.md#store--list-delivery-lane-2026-08-16--2026-09-18-the-octane-board) in the journal. The mechanics: -**Where.** Fork `ryansolid/octane`, branch `solid-rc8-fixtures-rebase` -(upstream `main` + our Solid fixture commits; rebase it when upstream -moves — the Solid fixtures are ours alone, upstream still pins -2.0-beta.20). Local checkout `~/Development/octane-fork`. A second -checkout, `~/Development/octane`, holds the pre-September experiment -scaffolding and the older results directories. - -**Linking `next`.** Keep a worktree of this repo on `origin/next` -(`~/Development/solid-next-bench`), `pnpm install`, `pnpm build` in -`packages/compiler` (native binary, needs cargo), then +**Where.** Fork `ryansolid/octane`, branch `bench/next-linked-oct15` +(upstream `octanejs/octane` `main` at 0.13.8, `91dae8d73`, plus our seven +Solid fixture commits — the shape passes, `board-compare.mjs`, the +`uibench/solid-reconcile` target and its manifest line). Local checkout +`~/Development/octane-fork`. Upstream pins the published rc (rc.14 on +2026-10-15) and never carries our shapes, so **rebase before every board**: +`git checkout -b bench/next-linked- && git fetch origin main && git +rebase origin/main`. Upstream edits the same fixtures' `package.json` / +`vite.config.js` identically to our pin commit, so the only conflicts are +`pnpm-workspace.yaml` and `pnpm-lock.yaml` — take upstream's side for both +(`git checkout --ours`), the pin commit drops out as empty, and the lockfile +is regenerated by the install below. Older branches are kept as they were: +`bench/next-linked` and `solid-rc8-fixtures-rebase` are the 0.2.13-era +boards (Sep 9 / Sep 17). A second checkout, `~/Development/octane`, holds +the pre-September experiment scaffolding and the older results directories. + +**Linking `next`.** Keep a worktree of this repo at the `next` commit under +test (`~/Development/solid-next-bench`; `git worktree add -b +bench/next- ~/Development/solid-next-bench ` — a worktree cannot +check out `next` itself while the main checkout is on it, hence the branch), +`pnpm install --frozen-lockfile`, `pnpm --filter @solidjs/compiler build` +(native binary, needs cargo, ~30 s), then `pnpm turbo run build --filter=@solidjs/signals --filter=solid-js ---filter=@solidjs/web --filter=@solidjs/babel-plugin --filter=@solidjs/compiler`. +--filter=@solidjs/web --filter=@solidjs/babel-plugin` (~25 s). In the fork's `pnpm-workspace.yaml` `overrides:` block, point `solid-js`, `@solidjs/web`, `@solidjs/signals`, `@solidjs/compiler` and `@solidjs/babel-plugin` at `link:../solid-next-bench/packages/`, -then `pnpm install --no-frozen-lockfile`. `@solidjs/vite-plugin` stays -npm and picks the linked compiler up through the override — verify with +then `pnpm install --no-frozen-lockfile` (the fork is on pnpm 12; ~40 s +with a warm store). `@solidjs/vite-plugin` stays npm (3.0.0-next.49 drives +the compiler built from `next` at rc.15 without complaint) and picks the +linked compiler up through the override — verify with `readlink -f node_modules/.pnpm/@solidjs+vite-plugin*/node_modules/@solidjs/compiler`. **Keep the override and the lockfile it produces uncommitted**; the fixture branch must install from the published rc for anyone else. +The 2026-10-15 session found the worktree deleted and the overrides +dangling — the board was unrunnable until it was recreated; check +`ls ~/Development/solid-next-bench` before trusting the link. + +**Solid-vs-Solid A/B.** A second worktree at the reference commit +(`~/Development/solid-sep17-bench` at `dd908fc12`, the Sep 17 board's +`next`), built the same way. Flip the five overrides to it, reinstall, +run the suites into one results dir, flip back, run again into another; +the runner rebuilds the fixtures itself. Same machine, same Octane, same +day — this is the only read on "did `next` regress" that does not depend +on the harness staying still between boards (it did not: see gotchas). **Running.** From the fork root: @@ -441,6 +466,21 @@ for the two-sided geomean board (timed ops only). For a single suite, **Gotchas, each learned the hard way:** +- **The machine must be idle for the whole run, and nobody but the + runner can tell.** `ps -Ao pcpu,etime,comm -r | head` before launching + and again when it finishes. The 2026-10-15 board's last two suites ran + beside a game and a Chrome renderer each holding more than half a core: + every target's uibench numbers came out 4–5× slower than the quick pass + an hour earlier and the Solid/Octane ratio there read 1.90× instead of + 1.63× — load does not cancel in the ratio. The reference frameworks' + absolute drift against the previous board (React, Inferno, Svelte, + Preact; pins unchanged) is the tell: 0.97–1.05 on a clean suite, anything + else means rerun that suite. +- Upstream ≥ 0.13 batches the sub-millisecond js-framework ops (`select`, + `swap`, `select_lots`: `BATCHED_OPS` in `js-framework/run.mjs`) inside each + sample. Those ops now read 10–40× smaller than on the 0.2.13 boards for + every framework; compare them only within a run. uibench always + repeated its small cases, so its numbers are comparable across boards. - `PLAYWRIGHT_BROWSERS_PATH` is set in the agent environment to a sandbox cache; unset it or Chromium "doesn't exist". - Long runs and preview servers must be tool-managed background tasks @@ -467,13 +507,22 @@ for the two-sided geomean board (timed ops only). For a single suite, until the fixture has been read. **Profiling a single op.** Build the fixture unminified -(`npx vite build --minify false --outDir dist-prof`), serve it, and drive +(`npx vite build --minify false --outDir dist-prof`), serve it +(`npx vite preview --outDir dist-prof --port 5347 --strictPort`), and drive the op from a Playwright script with CDP `Profiler` (self-time by function) and `HeapProfiler.startSampling` (allocation by function); -interleave the A and B pages in one browser for paired comparison. The -2026-09-18 todomvc scripts (`todo-prof.mjs`, `todo-var.mjs`, -`todo-alloc.mjs`, `todo-ab.mjs`) are the template; they lived in `/tmp` -and should be re-created next to the harness if this becomes routine. +interleave the A and B pages in one browser for paired comparison. For +uibench this is `benchmarks/uibench/tools/prof.mjs` in the fork (untracked; +run from `benchmarks/uibench` so it resolves the suite's Playwright): +`node tools/prof.mjs --url=http://localhost:5347/ --case='tree/[500]/[removeLast(1)]' +--reps=200 --mode=both`. It follows `run.mjs`'s protocol (prepare → settle → +gc → run) and brackets each `__run` with two sentinel functions that spin +0.3 ms so they are always sampled; only the samples between them count, so +the `__prepare` of each rep — a full render for the removeAll cases — stays +out of the numbers. Allocation sampling is started and stopped around each +`__run` over CDP for the same reason. The 2026-09-18 todomvc scripts +(`todo-prof.mjs`, `todo-var.mjs`, `todo-alloc.mjs`, `todo-ab.mjs`) were the +template; they lived in `/tmp` and are gone. ## Cross-references diff --git a/documentation/performance-experiments.md b/documentation/performance-experiments.md index 19f3a774b8..9e502e31d7 100644 --- a/documentation/performance-experiments.md +++ b/documentation/performance-experiments.md @@ -7936,3 +7936,546 @@ Cost sheet, measured (this machine; jantimon's cores are ~2.8× slower): it). The thing the sidecar buys — 15 kB gz / 8 kB br off the critical path before hydration can start, ~45–85 ms at slow-4G — is exactly what neither board measures. Hence the gate on target 8. + +## Octane Board Refresh (2026-10-15): upstream 0.13.8, rebased fixtures, new baseline + +The first board since 2026-09-17. Solid `next` at `3e94215b4` (466 commits +and ~13k lines moved under `packages/signals/src` since the Sep 17 board's +`dd908fc12` — the core rewrite), Octane upstream at 0.13.8. Everything +below is from `~/Development/octane-fork/benchmarks/results-next-oct15` +(26 suites), the quick pass `results-smoke-oct15` (uibench, 2 iterations), +and `results-next-sep17`. Numbers from the two suites the machine load +invalidated (below) are marked as such. + +### Instruments, what changed + +- **The fork was rebased** onto `octanejs/octane` `main` at 0.13.8 + (`91dae8d73`, 781 commits past the 0.2.13 the Sep boards used): branch + `bench/next-linked-oct15`, our seven fixture commits on top. Upstream's + edits to the Solid fixtures' `package.json` / `vite.config.js` were + byte-identical to our rc.8 pin commit, so that commit dropped out and the + only conflicts were `pnpm-workspace.yaml` / `pnpm-lock.yaml` (upstream's + side taken). Pins upstream moved: `solid-js` / `@solidjs/web` rc.14, + `@solidjs/vite-plugin` 3.0.0-next.49, `vue` 3.6.0-rc.1 → **rc.10**, + `svelte` 5.56.7 → 5.57.2. Unchanged: React 19.2.7, Playwright 1.61.1. + Recipe in [`benchmarking-strategy.md` → Octane board + workflow](./benchmarking-strategy.md#octane-board-workflow). +- **The `next` link had to be rebuilt.** `~/Development/solid-next-bench` + no longer existed; recreated at `3e94215b4` on branch `bench/next-oct15`, + compiler rc.15 built from source. Vite plugin next.49 drives it. +- **Upstream's js-framework harness now batches sub-millisecond ops** + (`select`, `swap`, `select_lots`) inside each sample. Those ops read + 10–40× smaller than in September for every framework; the millisecond ops + (`run`, `runlots`, `add`, `replace`, `clear`) are unchanged. Cross-board + comparisons of sub-ms ops are only valid for uibench, which always + repeated its small cases. +- **Board exit 1, Octane's own gates again**: recursive-context's `work` + sub-run "produced no targets"; hydration-interactivity's `octane-tsrx` + "hydration replaced a server-rendered article". Every Solid gate in all + 26 suites passed. +- **Two suites are invalid.** The board's last two timed suites, + streaming-ssr and uibench, ran while a game and a Chrome renderer each + held more than half a core. The tell is the reference frameworks' drift + against Sep 17 (pins unchanged): React / Inferno / Svelte / Preact sit at + 0.97–1.05 on every suite through lifecycle-memory, then 1.3–1.5 on + streaming-ssr and **4.3–4.9 on uibench** (Solid 2.6 / 5.3 vs the quick + pass). Load does not cancel in the ratio: uibench read 1.90× Octane on the + board against 1.63× in the clean quick pass an hour earlier. Both suites + are rerun as part of the A/B below; uibench numbers in this section are + the quick pass unless stated. +- `benchmarks/uibench/tools/prof.mjs` (fork, untracked): CPU + allocation + profile of one uibench case under the harness's own protocol, windowed to + the timed commit. See the strategy doc. + +### The board + +`board-compare` (timed ops only), lower = Solid faster. Sep 17 = `next` at +`dd908fc12` vs Octane 0.2.13 / Vapor rc.1; Oct 15 = `3e94215b4` vs 0.13.8 / +rc.10. + +| suite | vs Octane Sep 17 | Oct 15 | vs Vapor Sep 17 | Oct 15 | +| ----------------------- | ---------------: | ---------: | --------------: | --------: | +| js-framework | 0.91× | 1.16× | 1.09× | 1.38× | +| js-framework-reorder | 0.81× | 1.01× | 0.49× | 1.10× | +| dbmon | 1.04× | 1.03× | 1.21× | 1.12× | +| todomvc | 0.78× | 0.88× | 1.30× | 1.04× | +| chat-stream | 1.07× | 0.79× | 0.97× | 1.21× | +| svg-dashboard | 0.85× | 1.25× | — | — | +| signal-favoring | 0.63× | 0.86× | 2.47× | 1.54× | +| effectful-list | 0.47× | 1.11× | 0.67× | 1.13× | +| memo-wall | 0.20× | 0.66× | 0.99× | 1.16× | +| store-selector-fanout | 0.59× | 0.61× | 0.95× | 0.87× | +| scaling-curves | 0.83× | 0.76× | 1.11× | 0.99× | +| spa-navigation | 0.31× | 0.87× | 0.51× | 0.37× | +| recursive-context | 0.82× | 0.98× | 0.92× | 0.87× | +| external-store-fanout | 0.75× | 0.72× | 1.00× | 1.00× | +| controlled-form | 0.93× | 0.90× | 0.94× | 0.92× | +| event-delegation | 0.87× | 0.84× | 0.91× | 1.33× | +| application-composition | 0.90× | 0.87× | 1.01× | 0.98× | +| suspense-recovery | 0.98× | 0.98× | 1.00× | 0.98× | +| scheduler-responsiveness| 1.00× | 0.99× | 1.02× | 0.98× | +| hydration-interactivity | — | — | 1.14× | 1.08× | +| lifecycle-memory | 0.61× | 0.61× | 1.00× | 0.99× | +| streaming-ssr | 1.29× | 1.35× | — | — | +| uibench | 1.20× | 1.65× | 1.16× | 1.60× | +| **overall geomean** | **0.739×** | **0.898×** | **0.981×** | **1.020×** | + +(Overall rows as `board-compare` prints them from the board run: 21 suites vs +Octane including the board's invalid streaming-ssr reading of 1.45× — 0.877× +without it — and 20 vs Vapor; uibench is outside its list. The streaming-ssr +and uibench rows above are from the clean same-day rerun in the A/B below, +not from the board run.) + +### Why the ratios moved: they got faster, we did not move + +Absolute drift Oct 15 / Sep 17 on ops ≥ 0.5 ms (timer-floor safe), geomean +over every valid suite: **Solid 0.99 (96 ops)**, React 0.98, Inferno 1.01, +Svelte 0.99, Preact 1.00 — the machine and harness are comparable at the +millisecond scale — **Octane 0.83 (74 ops)**, Vapor 1.00 overall but 0.43 on +js-framework-reorder, 0.72 on effectful-list, 0.63 on chat-stream. + +- Octane 0.13.8 fixed what the Sep 17 caveat flagged ("Octane regressed in + that run") and more: memo-wall 0.64, spa-navigation 0.32, svg-dashboard + 0.61, js-framework 0.83, todomvc 0.79 of its own Sep 17 times. The 0.739× + overall that flattered us is gone; 0.88–0.90× is the honest number. +- Vapor rc.10 ships a real list-reconciliation improvement: reverse and + shuffle 1.1 → 0.6 ms, the displace ops 0.21 → 0.065 ms, + `remove_100_scattered` 1.06 → 0.32 ms. Our small-move fast path's 4× + lead over Vapor on displace/rotate (Sep) is now ~1.2×. +- Solid's own moves: todomvc 0.81 (the per-row ternary fixture plus a month + of `next`; the Sep 4× `add100` outlier is gone — 1.25× Vapor now), + chat-stream 0.78 (every framework 0.8–0.9 there), dbmon 1.33 (Inferno + 1.36, Svelte 1.14 — suite-wide, not ours). +- **Cross-board, there is no sign of a Solid regression from the rewrite at + the ±5% level on millisecond ops, and the clean uibench quick pass says + the same for sub-ms ops (Solid 0.976 over 96 cases).** That is not an + A/B. The same-harness A/B — `dd908fc12` built in + `~/Development/solid-sep17-bench` and linked into the same fork, same + day, same Octane and Vapor — is set up and approved, pending an idle + machine. It is the only read that does not depend on the harness having + stood still, and it did not. + +### The shape of the gap, within the Oct 15 run + +- **Single-item structural ops on large lists are the gap, against both.** + js-framework `swap` 60 µs vs Octane 21 / Vapor 15 (2.9× / 3.9×); + js-framework-reorder `removefirst` 62 vs 25 / 15 µs (2.5× / 4.1×); + uibench one-item tree ops (quick pass) 20–30 µs vs 5–6 / 9–13 µs (4–6× / + 2–3×); memo-wall `one_change` 1.85× Vapor; effectful-list + `remove_100_scattered` 2.3× Vapor (1.35× Octane). September's uibench + reading (2× Octane, parity with Vapor) is now 5× / 2.5×: the gap did not + grow — Solid's absolutes are flat — the others closed theirs. +- **Bulk ops hold or lead.** js-framework `run` / `runlots` / `add` / + `replace` / `clear` 0.96× Vapor as a group and 1.0–1.14× Octane; + reorder `displace*` / `rotate*` 0.76–0.91× both; effectful-list mount + 0.85× Vapor; memo-wall mount 0.62× Vapor; spa-navigation 0.37× / 0.87×; + uibench renders fastest of every target (quick pass, as in Sep). +- **Disposal (Sep open target 1) still shows, behind the one-item ops:** + uibench `removeAll` 3–5× Octane / 1.1–3× Vapor (quick pass); jfb `clear` + 1.04× / 1.08×; effectful-list `clear` 1.23× / 1.04×. +- **streaming-ssr** `shell_staggered` 2.4× Octane with equal totals (Sep + 2.6×) — invalid run, but the same single-op shape as Sep open target 4. +- Working hypothesis for the one-item ops, **unprofiled**: not the + disposal or creation of the one row but the per-row constant across the + O(n) passes every list commit pays — `updateKeyedMap`'s copy of + mappings + nodes into fresh arrays and `newItems.slice(0)`, + `normalize`'s fresh array over the mapped nodes, and `reconcileArrays`'s + prefix / suffix scans with `isLive`'s `parentNode` + `$$SLOT` probe per + element. Three to four passes over 500–1000 rows at 10–20 ns per element + per pass is the whole 40–50 µs difference. The profile (`tools/prof.mjs`: + `tree/[500]/[removeLast(1)]`, `[insertLast(1)]`, `tree/[10,50]/removeAll`, + `table/[100,4]/removeAll`; jfb `swap` by click) decides. + +### Bundle size + +js-framework fixture, brotli: **Solid 21.9 KB** (Sep 17: 23.9 — down 8% +across the rewrite), Octane 26.1 (Sep: 43), Svelte 14.0, Ripple 13.1, +Inferno 9.4, Preact 8.9, React 53.8. todomvc: Solid 13.9 vs Vapor 20.0, +Octane 30.1, Svelte 14.2. The app/framework split is meaningless under +local links (framework shows 328 B); totals are fine. + +### Pending, in order + +1. Same-harness A/B, Sep 17 `dd908fc12` vs today, eight suites (uibench, + js-framework, js-framework-reorder, effectful-list, memo-wall, todomvc, + dbmon, streaming-ssr), each side ~15–18 min — approved, waits for an idle + machine. Side B also replaces the two invalid suites. +2. Profile the single-item ops (above) and jfb `swap`; then the first + optimization, one change at a time, Tier-2 validated. +3. Re-rank the open targets after the profile; the Sep list (disposal, + one-item keyed ops, create floor, `shell_staggered`, `Show` memos, + chat-stream fixture) stands until then. + +### Same-harness A/B: Sep 17 `next` vs today (2026-10-15, 15:50–16:11) + +The question the drift table could not answer: did the core rewrite move +Solid's own numbers? Side A is the Sep 17 board's `next` (`dd908fc12`, built +in `~/Development/solid-sep17-bench`) with `@solidjs/vite-plugin` +3.0.0-next.43 — the Sep 17 compiler rejects the `sourceNames` option next.49 +passes, so A is that era's toolchain end to end; side B is `3e94215b4` with +next.49. Same fork (0.13.8), same Octane / Vapor / React builds, same +Chromium, one idle machine, 21 minutes apart; the fork was relinked between +sides and the runner rebuilt the fixtures. Results in +`results-ab-sep17` / `results-ab-oct15`; reader `ab-compare.mjs` applies the +runner's own noise rule (regression iff median > 1.15× **and** min > 1.10×). +Octane's own B/A drift is the machine check — nothing but Solid changed. + +| suite | Solid B/A (ops) | Octane B/A | flagged | +| -------------------- | --------------: | ---------: | --------------------------------------------------------- | +| uibench | 0.960 (96) | 1.008 | none; one-item tree ops 0.76–0.89, worst cases 0.83–0.91 | +| js-framework | 0.999 (10) | 0.985 | none | +| js-framework-reorder | 1.051 (14) | 0.970 | none; append100 1.37 / insertmid100 1.22 / prepend100 1.11 by median with **mins unchanged** (0.30 → 0.30) — bimodal 8-sample medians, rerun interleaved | +| effectful-list | 1.009 (6) | 0.970 | none | +| memo-wall | 0.987 (7) | 0.986 | none | +| todomvc | 0.896 (8) | 0.855 | `complete25` 0.76 and `filterCycle` 0.70 improved (Octane moved 0.86 too — part machine) | +| dbmon | 1.044 (6) | 1.011 | none | +| streaming-ssr | 1.503 (4) | 1.235 | `shell_allfast` 0.143 → 0.360 ms and `shell_staggered` 0.535 → 1.287 **regressed** | +| **overall** | **0.985 (151)** | | 2 regressed (both streaming-ssr shells), 2 improved | + +**DOM lanes: the rewrite did not cost anything measurable.** Every browser +suite is within noise of Sep 17 on the same harness, uibench is 4% better +with the one-item ops 10–25% better, todomvc is better. The 1.20× → 1.65× +move on uibench is entirely Octane's and Vapor's: on today's harness the +Sep 17 build reads 1.72× Octane / 1.66× Vapor, today's 1.65× / 1.60×. + +**Server lane: a probable time-to-shell regression, unconfirmed.** Read the +mins, not the medians: Solid's `shell_allfast` min went 0.111 → 0.244 ms +while every other target's held (React 0.090 → 0.091, Preact 0.120 → 0.117, +Octane 0.142 → 0.146, Ripple 0.032 → 0.052, Inferno 0.070 → 0.086); the +totals did not move (`total_allfast` 0.83, `total_staggered` 1.01), so it is +the first flush, not the render. `shell_staggered` moved for **every** target +(Preact 2.16×, Octane 1.85×, Inferno 1.67×, React 1.57×) between the two +sides — Spotlight's `mds_stores` was indexing the new worktrees at 24% of a +core in one load snapshot — so that op is run noise and says nothing yet. +The suite is Node-only and ~1 min per side; confirm with an interleaved +B A B A before bisecting. If it holds, the field is the 196 commits since +`dd908fc12` under `packages/web/src/server.ts`, `response.ts` and +`packages/solid/src/server/` — the shell-end marker splice (#4030), the +suspended-shell wait (#3771), the `createSSRResponse` settle changes, the +frames tiers (#3860), Stage 8's live transport (#3653, #3660) — none +implicated yet. The fixture is `renderToStream(...).pipe({ write, end })` +from `@solidjs/web`, a `.tsrx` page with ten `@try/@pending` cards over +`createMemo(() => promise)`; shell = time to the first `write`. + +### Interleaved confirmation (21:01–21:12): the shell "regression" was run drift + +B A B A over streaming-ssr and js-framework-reorder, relinking between each +run (`results-il-{oct15-1,sep17-1,oct15-2,sep17-2}`; reader `il-compare.mjs`). +Solid, median | min per run, B = today, A = Sep 17: + +| op | B1 | A1 | B2 | A2 | B/A med | B/A min | +| ---------------------- | ------------: | ------------: | ------------: | ------------: | ------: | ------: | +| `shell_allfast` | 0.250 / 0.191 | 0.260 / 0.243 | 0.288 / 0.221 | 0.355 / 0.192 | 0.81 | 1.00 | +| `shell_staggered` | 0.994 / 0.636 | 1.163 / 0.703 | 1.107 / 0.821 | 1.087 / 0.701 | 0.95 | 0.91 | +| reorder `insertmid100` | 0.380 / 0.300 | 0.420 / 0.400 | 0.340 / 0.300 | 0.440 / 0.400 | 0.86 | 0.75 | +| reorder `append100` | 0.400 / 0.300 | 0.360 / 0.300 | 0.380 / 0.300 | 0.420 / 0.300 | 0.95 | 1.00 | +| reorder `removefirst` | 0.061 / 0.055 | 0.062 / 0.060 | 0.076 / 0.070 | 0.081 / 0.070 | 0.94 | 0.92 | + +**Not a regression.** Alternated, the Sep 17 build reads the same shell +times as today's; the A/B's 2.5× was the suite moving between its two +regimes — the same Sep 17 build read 0.143 / 0.111 at 15:50 and 0.260 / +0.243 at 21:03. Octane's own `shell_allfast` min wandered 0.10–0.17 across +the four runs. Rule for this suite: a shell-op delta is real only when it +reproduces alternated within one session; single A/B sides are not enough +even with the min. The reorder bulk inserts are flat on min in every +pairing, as the A/B's unflagged reading said. + +**Closing the question the rewrite raised: no measurable regression on any +lane measured** — browser DOM suites 0.985 B/A over 151 ops, SSR shell at +parity alternated — across 466 commits and the core rewrite. + +What the suite does say, for both builds today: Solid's `shell_staggered` +is 0.64–0.82 ms min against Octane's 0.15–0.22 and React's 0.25–0.29 — +3–5× on first-flush latency under staggered boundaries with equal totals. +That is Sep open target 4 (`shell_staggered` 2.6× then), unchanged by the +rewrite and still open. + +### Pending, revised + +1. Profile the single-item list ops with `tools/prof.mjs` (uibench + `tree/[500]/[removeLast(1)]`, `[insertLast(1)]`, `tree/[10,50]/removeAll`, + `table/[100,4]/removeAll`) and jfb `swap`; then the first optimization. +2. Re-rank the open targets after the profile; `shell_staggered` keeps its + Sep rank. + +### Candidate 1 — `mapArray` subsequence fast paths (2026-10-15 evening, kept pending review) + +**Profile first** (`tools/prof.mjs`, unminified `dist-prof`, CDP 50 µs +sampling, 200 reps, today's `next`; the first pass had the sentinel spin +loops counted inside the window — fixed, the numbers below are windowed on +the whole stack). `tree/[500]/[removeLast(1)]`, ~15 µs attributed per +commit: `updateKeyedMap` body 8.3 µs (55%), `insertExpression` → +`reconcileArrays` 2.2 (its `isLive` probe per prefix element 0.6), +`normalize` → `flattenArray` 1.4, `disposeChildren` 1.1, `setSignal` + +queue 1.3. Allocation, sampled at 256 B: **15–20 KB per one-item op**, all +of it list-sized arrays — the staged `temp` / `tempNodes` / `rows` (8.0 KB +sampled), `flattenArray`'s copy of the 500 mapped nodes (5.4 KB), the +`newItems.slice(0)` (2.0 KB), and a chain array `new Array(newEnd + 1)` +sized to the edit's POSITION rather than the window — a full-length +allocation for an edit at the tail. The edit itself — one owner created or +disposed, one DOM node moved — is a few hundred nanoseconds. +`tree/[10,50]/removeAll` is a different animal: `disposeChildren` 53 µs +self (44%, ~2,600 owners at ~20 ns each), `cleanChildren` 33 µs (27%, the +native DOM teardown), nothing else above 2%; the lever there is the owner +count per row, not the per-owner cost. + +Why the staging exists: the #2903 strong-abort work made every pass build +its result into fresh full-length arrays so a mapper that throws +`NotReadyError` leaves `_mappings` / `_nodes` / `_rows` untouched. The +small-move path (2026-09-02) then took the reorders out of that cost but +gated itself to windows over 64 rows in plain mode — one-item edits, every +key-fn / index-signal list, and every sparse removal fell through to the +staging path. Not the diff algorithm (O(window), trims a one-item edit to a +window of one) — the commit. + +**The change** (worktree `~/Development/solid-next-bench`, branch +`perf/map-array-subsequence`, `packages/signals/src/map.ts`): two fast +paths in the general branch, after the small-move probe so its pinned +shapes stay with it, and only when the length changed. + +- *Pure removal* (`isRemovalOnly`: the new window is an ordered + subsequence of the old — two pointers, one key call per item, pure reads): + `commitRemoval` compacts `_nodes` / `_rows` / `_indexes` IN PLACE from + `start` (a survivor's new index is its write index), rebuilds only + `_mappings` (its fresh identity is the change signal downstream), writes + survivors' row signals (same key, maybe a new object) and index signals, + shrinks the lengths, disposes the leavers last. +- *Pure insertion* (`isInsertionOnly`, the mirror): the new rows are + created FIRST into created-sized stage arrays (the mapper now writes + `rows![slot]` / `indexes![slot]`, `slot` = `j` on the full-length paths), + so an abort disposes only them with the live arrays untouched; then + `commitInsertion` spreads the live arrays in place from the tail (writing + index `j` never passes an unread old index, `i <= j` throughout). +- The strong-abort contract holds by ORDER (verify → create → mutate), not + by copying. Every mode: identity, key fn, row signals, index signals, + `keyed: false` (whose edits are always tail-only after the prefix loop). +- The general path's chain array is window-sized (`newEnd - start + 1`, + offset by `start`). +- `__subsequenceHits`, dev-only, like `__smallMoveHits`. + +Tests: `tests/mapArray-subsequence.test.ts`, 15 — engagement per shape and +mode, duplicates (`[A,A,B]` ↔ `[A,B]`), fall-through shapes (replace, swap, +reverse, remove-plus-insert), small-move precedence on its pinned shrink +shape, two 300-round randomized oracles (plain: one of the two fast paths +takes every round; index mode: the subsequence path takes every round, +indices re-read correct), and the NotReadyError abort on an insertion (the +aborted row's owner disposed with the pass, state untouched, retry lands, +owners balance). Full suites green: signals 5,327 / web 1,732 + 511. + +**Measured** (batch of three, machine idle, 22:00–22:05): + +| lane | op | before | after | note | +| --- | --- | --- | --- | --- | +| Tier-1 `single-item-ops.bench.tsx` (jsdom, µs per remove+insert pair, mean of two alternated runs) | tail | 97.9 | 88.8 | −9% | +| | head | 89.3 | 85.2 | −5% | +| | middle (500) | 101.3 | 95.4 | −6% | +| | scattered (every 10th ×100) | 2,233 | 2,155 | −4% | +| | tail, keyed by fn | 116.8 | 106.2 | −9% | +| uibench fixture, unminified, `prof.mjs --mode=time` (ms median, two rounds) | `tree/[500]/[removeLast(1)]` | 0.060 / 0.065 | 0.050 / 0.050 | −20%, min 0.040 → 0.025 | +| | `tree/[500]/[insertLast(1)]` | 0.060 / 0.060 | 0.045 / 0.045 | −25% | +| | `tree/[500]/[removeFirst(1)]` | 0.065 / 0.065 | 0.050 / 0.045 | −27% | +| | `table/[100,4]/filter/4` | 0.095 / 0.095 | 0.085 / 0.090 | −8% | +| | `tree/[10,50]/removeAll` (control) | 0.260 / 0.270 | 0.275 / 0.270 | flat | +| | `tree/[500]/[react_worst_case]` (control) | 0.150 / 0.140 | 0.155 / 0.150 | flat | +| Octane harness, same day (`results-ab-oct15` / `results-il-oct15-2` → `results-cand1-oct15`) | uibench, 96 ops | 1.654× Octane, 1.602× Vapor | **1.521× / 1.455×** | Solid 0.919 of itself, Octane's own drift 1.000 | +| | uibench one-item insert/remove (16) | 3.13× / 1.82× | 2.64× / 1.51× | Solid 0.84; `tree/[500]` removeFirst 0.57, insertFirst 0.60, insertLast 0.66, removeLast 0.72 | +| | uibench table filters (16) | 1.84× / 2.82× | 1.23× / 1.87× | Solid 0.68 | +| | uibench removeAll / renders / one-item moves / activate / anim / no_change (controls) | | | Solid 1.01 / 1.00 / 1.00 / 1.04 / 0.98 / 1.00 | +| | uibench worst-case reorders (4) | 2.37× | 2.66× | Solid 1.09 — see below | +| | js-framework-reorder, 14 ops | 1.012× / 1.122× | **0.983× / 1.040×** | `removefirst` 0.071–0.090, `removeevery10` 0.80–0.82, the 100-row inserts 0.82–0.91 | +| | effectful-list, 6 ops | 1.274× | 1.322× | Octane's own drift 0.897 that run; Solid 0.93 of itself, `remove_100_scattered` 0.97 — its cost is 100 disposals, not arrays | + +Nothing flagged by the runner's rule in either direction. The Tier-1 +deltas are small because jsdom's DOM dominates a pair there; the fixture +and the harness agree on 20–40% off the one-item ops on 500-row lists and +~30% off the sparse filters, scaling with list length (the nested 50-row +lists in `tree/[10,50]` move within noise — the saved allocation is 10× +smaller against the same fixed cost). + +**Open on this candidate.** + +1. The four worst-case reorders read 1.09 as a group (medians +9–15%, two + mins up) while every other control was flat and Octane did not move. Two + candidate causes: the gate ran `isInsertionOnly`'s scan on equal-length + passes (a reorder is never a subsequence, so a wasted O(window) with + key calls — fixed afterwards: `dif !== 0 &&`), and 8-sample bimodality + on 60 µs ops. Rerun in isolation, old vs candidate alternated, before + the PR. +2. The remaining one-item cost is ~17 µs against Octane's 6: a fixed + per-update floor (`setSignal` → queue → `flush` → `recompute` → the + insert's two effects → one owner disposal, ~10 µs) plus the two O(n) + passes still paid per commit — `normalize`'s `flattenArray` copy of the + mapped nodes and `reconcileArrays`' prefix / suffix scan with `isLive`. + `normalize` adopting an array `mapArray` already owns is the next + allocation to remove; the floor wants a finer profile (20 µs sampling, + 800 reps) on the candidate build. + +### Candidate 2 — window staging + in-place commit via `copyWithin` (2026-10-15, reverted) + +Candidate 1 measured well but cost +1,590 B minified / +480–760 B brotli on +every list-bearing scenario (`scripts/size`, candidate vs base): four new +functions beside the general path — the class the Props lane ruling named +as the one that balloons. Candidate 2 kept the intent and moved it INTO the +general path: stage only the changed window (`tempNodes`, `rows`, `indexes` +sized `newEnd - start + 1`), commit the bookkeeping arrays in place with +the suffix shifted by `Array.prototype.copyWithin`, rebuild only +`_mappings`. +166–192 B minified, +81–118 B brotli — a tenth of candidate 1. +Tests all green (the candidate-1 test file rewritten behavior-only as +`mapArray-window-commit.test.ts`; the engagement counter is gone with the +separate paths). + +Measured the same way (batch, idle machine, 22:35–22:48): **a step back.** + +| | base → cand 2 | +| --- | --- | +| fixture `tree/[500]/[removeLast(1)]` / `[insertLast(1)]` (median, 3 rounds) | 0.92 / 0.83 — the tail still wins | +| fixture `tree/[500]/[removeFirst(1)]` | **1.23** (min 1.22) | +| fixture worst-case reorders (4) | 1.04–1.12 | +| Tier-1 head / middle pair | 1.30 / 1.15; tail 0.94, keyed tail 0.90 | +| uibench suite | 1.654× → **1.691×** Octane; one-item group 1.05 (heads 1.2, tails 0.85) | +| js-framework-reorder | 1.012× → 1.060× | + +Two causes, both then measured in Node (`micro-shift.mjs`, 1k object refs): + +- **`copyWithin` is not a memmove in V8.** Two shifts of 999 refs: 31.4 µs + via `copyWithin`, **0.97 µs** via a plain JS loop (32×), 2.6 µs via + `slice` + `unshift`. It is a generic per-element builtin. That is the + whole head-op regression; candidate 1's in-place loops were the right + mechanism. +- **In-place stores into a long-lived array are NOT slower** than stores + into a fresh one (0.35 vs 0.43 µs per 500) — the write-barrier + explanation for the worst-case drift is wrong. What candidates 1 and 2 + share on full-window reorders (no shifts, no creations) is a larger + `updateKeyedMap` body, and the small-move note already recorded that + inlining into that body deoptimized the general path. Working hypothesis: + keep the body no larger than base. + +### Candidate 3 — window staging, JS shift loops, commit in its own function (2026-10-15, measuring) + +Candidate 2 with the two findings applied: `commitWindow(data, newItems, +newLen, start, end, newEnd, tempNodes, rows, indexes)` shifts the suffix +with a loop walked from the side that never overwrites an unread slot (tail +first for growth, window end first for shrink), lands the staged window, +writes survivors' row and index signals; `updateKeyedMap`'s own body loses +the per-array prefix / suffix copies and gains one call. +218–232 B +minified, +99–389 B brotli (brotli's layout noise, ±50–90 B, is a third of +that spread). All suites green. The measurement batch is the same as the +others', into `results-cand3-oct15`. + +#### Candidates 3 and 4, measured (23:20–23:55) — the four-way table + +Same batch each (idle machine; Octane's own drift per run within 0.97–1.03 +on every group, so the Solid columns are read raw). Solid candidate / base +per uibench op group; suite rows are `solid/octane` and `solid/vapor`. + +| | size, minified / brotli over cap (per list scenario) | one-item insert/remove (16) | `tree/[500]` one-item (4) | table filters (16) | worst-case reorders (4) | removeAll / renders / moves / activate / anim / no_change | uibench suite | jfb-reorder suite | jfb `removefirst` | +| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | +| base | — | — | 5.04× Octane | 1.84× | 2.37× | — | 1.654× / 1.602× | 1.012× / 1.122× | 3.30× | +| c1 subsequence paths | +1,590 B / ~+500 B | 0.84 | 0.64 | 0.68 | 1.09 | 1.01 / 1.00 / 1.00 / 1.04 / 0.98 / 1.00 | 1.521× / 1.455× | 0.983× / 1.040× | 0.71–0.90 | +| c2 `copyWithin` | +170 B / ~+100 B | 1.05 | — | 1.02 | 1.11 | flat | 1.691× / 1.653× | 1.060× / 1.118× | 0.96 | +| c3 loop shifts + commit helper | +220 B / ~+180 B | 0.93 | 0.78 | 0.98 | **1.03** | 1.00 / 1.02 / 1.00 / 1.04 / 0.98 / 0.99 | 1.654× / 1.586× | 1.026× / 1.148× | 0.72 | +| c4 = c3 + Map-free pure removal + length-first growth | +645 B / ~+310 B | 0.91 | 0.75 | **0.70** | 1.11 | 1.00 / 1.00 / 0.98 / 1.03 / 0.99 / 0.99 | **1.530× / 1.482×** | 1.001× / 1.090× | 0.70 | + +Fixture timing (unminified, 3 rounds, medians) agrees on the wins — +c4: `removeLast` 0.85, `insertLast` 0.83, `removeFirst` 0.77, +`table/[100,4]/filter/4` 0.83 — and reads the worst-case reorders **flat** +for every candidate (c4: 1.00–1.04; c3: 0.96–1.00; c1 earlier: flat), while +the harness, on the minified build, reads them +9–12% for c1, c2 and c4 and +flat for c3. The one structural difference c3 has from the other three is +the smallest `updateKeyedMap` body (the commit left it; nothing came back in), +which is the small-move note's finding again: the general path is sensitive +to the size of that function — most likely through what V8 still inlines +into it — and the full-window reorders are where the general path is all of +the op. It needs an isolated old-vs-new read on the MINIFIED build before +either c4 or that hypothesis is believed. `remove_100_scattered` 0.87 and +`removeevery10` 0.86 under c4 are the Map-free stager at work; jfb +`prepend100` / `insertmid100` read 1.18–1.21 under c3 and c4 but their +minimum flips between 0.30 and 0.40 ms across identical builds in the +afternoon's interleave, so they are "rerun alternated", not a verdict. + +Reading: c3 is the conservative PR — smallest, nothing slower anywhere on +either instrument, half the one-item win, filters untouched. c4 is c1's +result at 40% of its bytes, with the filter class (16 ops, 1.84× → 1.29× +Octane) and the sparse removals on top, pending the worst-case isolation. +Both patches are saved (`cand1-subsequence.patch`, `cand4-map.patch`; +c3 = c4 without `stageRemoval` and the length-first lines). + +#### Prior art checked against candidates 3 and 4 + +- `a7d42f655` (2026-07-22, "mapArray: swap-commit update pass + no-change + fast path") is the direct predecessor: before it the general path staged + full-length arrays, copied the whole range from `start` back into the live + arrays and re-sliced `_mappings` (~4 passes per edit); it removed the + copy-back and the slice by swapping the staged arrays in — the swap + supplying the fresh identity — and measured todomvc `toggleAll` ~2× and + keyed-reorder rotate ops ~35% faster. It kept the full-length staging. + Candidates 3/4 keep both of its wins (fresh `_mappings` by swap; the + no-change return untouched) and remove the staging it kept; a reorder is + the same write count with three fewer allocations (rotate ops flat under + c4, 1.01–1.03), and `toggleAll` goes into the final validation since no + batch tonight ran todomvc. +- The May `mapArray` probes do not intersect: the `runWithOwner` lift was + reverted as neutral (creation side), Investigations 5–8 are the SSR + `mapArray`. The 2026-08-18 positional-prefix keyed walk is the store + reconciler. The keyed-list-driver proposal is the abandoned patch-channel + direction. No earlier attempt at an in-place client commit is recorded. + +#### Candidate 4 — final validation and the board (2026-10-16, 00:20–00:35) — KEPT, to PR + +Isolated on the minified build (`isolate-worst.sh`, base vs candidate, 3 +rounds alternated): the four worst-case reorders 1.08 / 1.04 / 0.98 / 0.96 +by median, mins 1.00 / 1.13 / 0.94 / 1.07 — the harness's +11% did not +reproduce; the group averages +1.5%, inside what the same ops show between +identical builds. `removeFirst(1)` 0.79 and `filter/4` 0.90 reproduce. + +Validation on the suites not yet run under the candidate (Octane's own +drift per suite in brackets): **js-framework 1.002 [1.02], nothing flagged** +(`swap` unchanged — a permutation, the small-move path's); **dbmon 1.03 +[0.99], nothing flagged**; **todomvc 0.84 [1.10]** — `toggleAllOn` 0.55, +`toggleAllOff` 0.78, `clearCompleted` 0.76, `destroy25` 0.82, `filterCycle` +0.83, `edit10` 0.86 (five improved by the runner's rule; the July `toggleAll` +win is intact), `complete25` 1.23 on a 0.3 → 0.5 ms min — no structural edit +in that op, the sub-ms bimodal class (see `add100`, 2026-09-18): rerun in +isolation, not a verdict. jfb-reorder `insertmid100` / `prepend100` are the +same class (their min flipped 0.30 ↔ 0.40 between identical builds in the +afternoon's interleave). + +**The board**, Solid / competitor geomean over timed ops, each suite from +its clean same-day run (22 suites vs Octane, 21 vs Vapor, uibench and the +clean streaming-ssr rerun included): + +| | vs Octane | vs Vapor | +| --- | ---: | ---: | +| Sep 17 (Octane's bad run) | 0.754× | 0.988× | +| today, before | 0.945× | 1.044× | +| today, after candidate 4 | **0.926×** | **1.039×** | + +Where it moved: uibench 1.65× → 1.53× / 1.60× → 1.48×, js-framework-reorder +1.07× → 1.00× / 1.13× → 1.09×, todomvc 0.95× → 0.72× / 1.01× → 0.88×. The +op class moved 20–40%; the suite geomeans dilute it over 96 ops of which 32 +are the target; the board dilutes it again over 16 suites that never make a +structural list edit. + +Decision: candidate 4 goes to the PR (#4114, `perf/map-array-inplace-commit` +in `~/Development/solid-next-bench`): +635–654 B minified per list-bearing +scenario; brotli +165–544 B against the caps `next` carries after #4104's +ratchet (brotli's layout noise is most of that spread). Ten inline caps +raised and three frozen floors re-based with dated ledger lines +(`Size-Exception` in the PR body): eleven from local bytes on the merged +tree, the two router pages from CI's bytes — a local bundle of those two +resolves `@solidjs/router` differently and reads ~7 KB under CI, so CI's +first size run caught them (+295 / +294 B brotli over base). Candidate +1's patch stays on file for the record; candidate 3 is candidate 4 minus +`stageRemoval` and the length-first lines. + +Remaining one-item cost after this (uibench `tree/[500]` ops 3.4–4.2× +Octane, 1.9–2.4× Vapor): a fixed per-update floor of ~10 µs (`setSignal` → +queue → `flush` → `recompute` → the insert's two effects → one owner +disposal) and two O(n) passes still paid per commit on the DOM side — +`normalize`'s `flattenArray` copy of the mapped nodes and `reconcileArrays`' +prefix / suffix scan with the `isLive` probe. Next in order: `normalize` +adopting an array `mapArray` already owns; a 20 µs-sampled profile of the +floor on this build; then the September list (disposal / owner count per +row for removeAll, `Show`'s second memo, the compiler's static-child insert, +`shell_staggered`). Fixture pass queued: effectful-list to the shallow-store +shape, todomvc's `editing` to an id-keyed selector. diff --git a/packages/signals/src/map.ts b/packages/signals/src/map.ts index af4ec74f39..7a11fafc2b 100644 --- a/packages/signals/src/map.ts +++ b/packages/signals/src/map.ts @@ -300,6 +300,101 @@ function commitSmallMove( return true; } +/** Stage a PURE REMOVAL without the key map: when the new window is the old + * window with items dropped, in order (one-item removals, every-nth, the + * table filters), a two-pointer walk lands every survivor in the window + * stage with one key call per item and returns the leavers; a shape that is + * not an ordered subsequence returns null with the node stage cleared, and + * the map path below starts from scratch (it rewrites every window slot of + * `temp` / `rows` / `indexes`, so only `tempNodes`' `!== undefined` probe + * needs the reset). The stage arrays are scratch — nothing live is touched. */ +function stageRemoval( + data: MapData, + newItems: readonly Item[], + start: number, + end: number, + newEnd: number, + temp: MappedItem[], + tempNodes: Root[], + rows: Signal[] | undefined, + indexes: Signal[] | undefined +): Root[] | null { + const key = data._key; + const oldItems = data._items; + const nodes = data._nodes; + const removed: Root[] = []; + let i = start; + for (let j = start; j <= newEnd; i++, j++) { + const nk = key ? key(newItems[j]) : newItems[j]; + while (i <= end && (key ? key(oldItems[i]) : oldItems[i]) !== nk) removed.push(nodes[i++]); + if (i > end) { + tempNodes.fill(undefined as any); + return null; + } + temp[j] = data._mappings[i]; + tempNodes[j - start] = nodes[i]; + if (rows) rows[j - start] = data._rows![i]; + if (indexes) indexes[j - start] = data._indexes![i]; + } + for (; i <= end; i++) removed.push(nodes[i]); + return removed; +} + +/** Land a staged window into the live bookkeeping arrays in place: shift the + * suffix by `dif` (a loop walked from the side that never overwrites an + * unread slot), take the window from the stage, write survivors' row and + * index signals. Its own function so the general path's body stays under + * the JIT's optimization budget — the inlined commit measured ~10% slower on + * full-window reorders (the small-move note has the same finding). */ +function commitWindow( + data: MapData, + newItems: readonly Item[], + newLen: number, + start: number, + end: number, + newEnd: number, + tempNodes: Root[], + rows: Signal[] | undefined, + indexes: Signal[] | undefined +): void { + const nodes = data._nodes; + const liveRows = data._rows; + const liveIndexes = data._indexes; + const oldLen = data._len; + const dif = newLen - oldLen; + let i: number, j: number; + if (dif > 0) { + // Grow first: a store past the end from the top is a third slower than + // filling a pre-sized tail (measured on 1k refs, 2026-10-15). + nodes.length = newLen; + if (liveRows) liveRows.length = newLen; + if (liveIndexes) liveIndexes.length = newLen; + for (i = oldLen - 1, j = newLen - 1; i > end; i--, j--) { + nodes[j] = nodes[i]; + if (liveRows) liveRows[j] = liveRows[i]; + if (liveIndexes) liveIndexes[j] = liveIndexes[i]; + } + } else if (dif < 0) { + for (i = end + 1, j = newEnd + 1; i < oldLen; i++, j++) { + nodes[j] = nodes[i]; + if (liveRows) liveRows[j] = liveRows[i]; + if (liveIndexes) liveIndexes[j] = liveIndexes[i]; + } + nodes.length = newLen; + if (liveRows) liveRows.length = newLen; + if (liveIndexes) liveIndexes.length = newLen; + } + for (j = start; j <= newEnd; j++) { + nodes[j] = tempNodes[j - start]; + if (liveRows) setSignal((liveRows[j] = rows![j - start]), newItems[j]); + if (liveIndexes) setSignal((liveIndexes[j] = indexes![j - start]), j); + } + for (j = newEnd + 1; j < newLen; j++) { + if (liveRows) setSignal(liveRows[j], newItems[j]); + if (liveIndexes && dif !== 0) setSignal(liveIndexes[j], j); + } +} + function updateKeyedMap(this: MapData): any[] { // Untracked reads inside the internal owner resolve via _parentComputed: // routed through the node, a store-proxy lookup sees pending writes (not a @@ -315,6 +410,10 @@ function updateKeyedMap(this: MapData): any[ runWithOwner(this._owner, () => { let i: number, j: number, + // The stage slot a mapper writes its row/index signal into: `j` on the + // create-all path, `j - start` on the general path, whose stage arrays + // are sized to the changed WINDOW, not the list. + slot: number, rows: Signal[] | undefined, indexes: Signal[] | undefined, // Mappers write freshly-created row/index signals into the STAGE @@ -322,22 +421,22 @@ function updateKeyedMap(this: MapData): any[ mapper = this._rows ? this._byIndex ? () => { - rows![j] = signal(newItems[j], pureOptions); - return this._map(accessor(rows![j]), j); + rows![slot] = signal(newItems[j], pureOptions); + return this._map(accessor(rows![slot]), j); } : () => { - rows![j] = signal(newItems[j], pureOptions); - indexes && (indexes[j] = signal(j, pureOptions)); + rows![slot] = signal(newItems[j], pureOptions); + indexes && (indexes[slot] = signal(j, pureOptions)); return this._map( - accessor(rows![j]), - indexes ? accessor(indexes[j]) : (undefined as any) + accessor(rows![slot]), + indexes ? accessor(indexes[slot]) : (undefined as any) ); } : this._indexes ? () => { const item = newItems[j]; - indexes![j] = signal(j, pureOptions); - return this._map(item, accessor(indexes![j])); + indexes![slot] = signal(j, pureOptions); + return this._map(item, accessor(indexes![slot])); } : () => { const item = newItems[j]; @@ -373,8 +472,10 @@ function updateKeyedMap(this: MapData): any[ indexes = this._indexes && new Array(newLen); try { - for (j = 0; j < newLen; j++) + for (j = 0; j < newLen; j++) { + slot = j; mappings[j] = runWithOwner((nodes[j] = createOwner()), mapper)!; + } } catch (err) { for (i = 0; i <= j!; i++) nodes[i]?.dispose(); throw err; @@ -483,85 +584,93 @@ function updateKeyedMap(this: MapData): any[ } const dif = newLen - this._len; + // WINDOW-SIZED STAGING, IN-PLACE COMMIT (2026-10-15). The strong-abort + // contract (#2903) only needs the pass to touch nothing live before + // every mapper has succeeded; it never needed four fresh newLen-sized + // arrays. Only the changed window [start, newEnd] is staged — the + // prefix and suffix are, by construction, the rows that did not move + // relative to each other — and at commit the bookkeeping arrays + // (`_nodes`, `_rows`, `_indexes`) take the window in place with the + // suffix shifted by a plain loop (`copyWithin` is a generic + // per-element builtin in V8 — 30× slower than the loop on 1k refs, + // measured 2026-10-15). `_mappings` alone is + // rebuilt: its fresh identity is the change signal downstream relies + // on. A one-row edit on a 500-row list used to allocate ~20 KB of + // staging plus a position-sized chain array per commit (uibench's + // one-item tree ops at 4–6× Octane, jfb-reorder `removefirst` at + // 2.5×); now it stages one slot. + const wLen = newEnd - start + 1; const temp: MappedItem[] = new Array(newLen); - const tempNodes: Root[] = new Array(newLen); - rows = this._rows ? new Array(newLen) : undefined; - indexes = this._indexes ? new Array(newLen) : undefined; - - // 0) prepare a map of all indices in the changed window of newItems, - // scanning backwards so we encounter them in natural order - newIndices = new Map(); - newIndicesNext = new Array(newEnd + 1); - for (j = newEnd; j >= start; j--) { - item = newItems[j]; - key = this._key ? this._key(item) : item; - i = newIndices.get(key)!; - newIndicesNext[j] = i === undefined ? -1 : i; - newIndices.set(key, j); - } + const tempNodes: Root[] = new Array(wLen); + rows = this._rows ? new Array(wLen) : undefined; + indexes = this._indexes ? new Array(wLen) : undefined; - // 1) step through the old changed window and see if items can be found - // in the new set; if so, stage them at their new positions; if not, - // queue them for disposal at commit - for (i = start; i <= end; i++) { - item = this._items[i]; - key = this._key ? this._key(item) : item; - j = newIndices.get(key)!; - if (j !== undefined && j !== -1) { - temp[j] = this._mappings[i]; - tempNodes[j] = this._nodes[i]; - rows && (rows[j] = this._rows![i]); - indexes && (indexes[j] = this._indexes![i]); - j = newIndicesNext[j]; + // A shrinking pass is tried as a pure removal first (stageRemoval: no + // key map, one key call per item); anything else takes the map path. + const pure = + dif < 0 + ? stageRemoval(this, newItems, start, end, newEnd, temp, tempNodes, rows, indexes) + : null; + if (pure !== null) { + // (The list-churn census below wants removals AND creations in one + // pass; a pure removal has none, so no item ledger is kept here.) + if (pure.length !== 0) removed = pure; + } else { + // 0) prepare a map of all indices in the changed window of newItems, + // scanning backwards so we encounter them in natural order + newIndices = new Map(); + newIndicesNext = new Array(wLen); + for (j = newEnd; j >= start; j--) { + item = newItems[j]; + key = this._key ? this._key(item) : item; + i = newIndices.get(key)!; + newIndicesNext[j - start] = i === undefined ? -1 : i; newIndices.set(key, j); - } else { - (removed ??= []).push(this._nodes[i]); - if (__OBSERVE__ && attrHooks !== null) (removedItems ??= []).push(item); } - } - // 2) create new rows into the temp arrays; an abort disposes only these - try { - for (j = start; j <= newEnd; j++) { - if (tempNodes[j] !== undefined) continue; - (created ??= []).push((tempNodes[j] = createOwner())); - if (__OBSERVE__ && attrHooks !== null) (createdItems ??= []).push(newItems[j]); - temp[j] = runWithOwner(tempNodes[j], mapper)!; + // 1) step through the old changed window and see if items can be found + // in the new set; if so, stage them at their new positions; if not, + // queue them for disposal at commit + for (i = start; i <= end; i++) { + item = this._items[i]; + key = this._key ? this._key(item) : item; + j = newIndices.get(key)!; + if (j !== undefined && j !== -1) { + temp[j] = this._mappings[i]; + tempNodes[j - start] = this._nodes[i]; + rows && (rows[j - start] = this._rows![i]); + indexes && (indexes[j - start] = this._indexes![i]); + j = newIndicesNext[j - start]; + newIndices.set(key, j); + } else { + (removed ??= []).push(this._nodes[i]); + if (__OBSERVE__ && attrHooks !== null) (removedItems ??= []).push(item); + } } - } catch (err) { - if (created) for (i = 0; i < created.length; i++) created[i].dispose(); - throw err; - } - // 3) commit: land the retained prefix and suffix plus the staged window - // into the fresh arrays, swap them in (new identity for downstream - // change propagation), then dispose exited rows - for (i = 0; i < start; i++) { - temp[i] = this._mappings[i]; - tempNodes[i] = this._nodes[i]; - rows && (rows[i] = this._rows![i]); - indexes && (indexes[i] = this._indexes![i]); - } - for (j = start; j <= newEnd; j++) { - if (rows) write(rows[j], newItems[j]); - if (indexes) write(indexes[j], j); - } - for (j = newEnd + 1; j < newLen; j++) { - temp[j] = this._mappings[j - dif]; - tempNodes[j] = this._nodes[j - dif]; - if (rows) { - rows[j] = this._rows![j - dif]; - write(rows[j], newItems[j]); - } - if (indexes) { - indexes[j] = this._indexes![j - dif]; - if (dif !== 0) write(indexes[j], j); + // 2) create new rows into the window stage; an abort disposes only these + try { + for (j = start; j <= newEnd; j++) { + if (tempNodes[j - start] !== undefined) continue; + (created ??= []).push((tempNodes[j - start] = createOwner())); + if (__OBSERVE__ && attrHooks !== null) (createdItems ??= []).push(newItems[j]); + slot = j - start; + temp[j] = runWithOwner(tempNodes[j - start], mapper)!; + } + } catch (err) { + if (created) for (i = 0; i < created.length; i++) created[i].dispose(); + throw err; } } + + // 3) commit: the retained prefix and suffix land in the fresh mapped + // array; the bookkeeping arrays take the staged window IN PLACE (own + // function: kept out of this body on purpose, see commitWindow); then + // the exited rows dispose + for (i = 0; i < start; i++) temp[i] = this._mappings[i]; + for (j = newEnd + 1; j < newLen; j++) temp[j] = this._mappings[j - dif]; + commitWindow(this, newItems, newLen, start, end, newEnd, tempNodes, rows, indexes); this._mappings = temp; - this._nodes = tempNodes; - rows && (this._rows = rows); - indexes && (this._indexes = indexes); this._len = newLen; // save a copy of the mapped items for the next update this._items = newItems.slice(0); diff --git a/packages/signals/tests/mapArray-window-commit.test.ts b/packages/signals/tests/mapArray-window-commit.test.ts new file mode 100644 index 0000000000..afff4a0d58 --- /dev/null +++ b/packages/signals/tests/mapArray-window-commit.test.ts @@ -0,0 +1,541 @@ +/** + * mapArray general-path commit (2026-10-15): the pass stages only the changed + * window and commits the bookkeeping arrays in place, shifting the suffix with + * `copyWithin`. These tests pin the semantics against first principles for + * the edit shapes that used to pay full-length staging — one-item and sparse + * removals and insertions at the head, tail and middle — in every mode: + * survivors keep their owner (same mapped object), leavers dispose exactly + * once, after commit, row signals take the new item, index signals take the + * new index, duplicates pair the way they always did, the small-move path + * keeps its shapes, and an aborted pass leaves everything untouched. + */ +import { describe, expect, it } from "vitest"; +import { + createLoadingBoundary, + createMemo, + createRenderEffect, + createRoot, + createSignal, + flush, + mapArray, + onCleanup +} from "../src/index.js"; +import { __smallMoveHits } from "../src/map.js"; + +interface Item { + id: number; +} +interface Mapped { + item: Item; + seq: number; +} + +const items = (n: number, from = 0): Item[] => + Array.from({ length: n }, (_, i) => ({ id: from + i })); +const ids = (m: { item: Item }[]) => m.map(x => x.item.id); +const smallMoveHits = () => __smallMoveHits(); + +/** xorshift32 in integer space: the multiplicative LCG used elsewhere loses + * precision past 2^53 and can fall into a fixed point, which hangs a + * "pick k distinct indices" loop. */ +function xorshift(seed: number) { + let s = seed | 0 || 1; + return (n: number) => { + s ^= s << 13; + s ^= s >>> 17; + s ^= s << 5; + return (s >>> 0) % n; + }; +} + +/** Plain mode: arity-1 mapper, identity keyed. */ +function plain(initial: Item[]) { + let seq = 0; + const disposed: number[] = []; + const [$src, setSrc] = createSignal(initial); + let map!: () => Mapped[]; + const dispose = createRoot(d => { + map = mapArray($src, (item: Item) => { + const m = { item, seq: seq++ }; + onCleanup(() => disposed.push(m.seq)); + return m; + }); + map(); + return d; + }); + const set = (next: Item[]) => { + setSrc(next); + flush(); + }; + return { map, set, disposed, dispose, created: () => seq }; +} + +/** Survivors (by id) must keep the exact mapped object they had before — + * checked with plain compares and ONE assertion, so the randomized oracles + * below stay cheap under coverage on a slow CI runner (per-row `expect` + * calls timed the index-mode oracle out at 5 s there). */ +function survivorMismatch(before: Mapped[], after: Mapped[]): string | null { + const byId = new Map(before.map(m => [m.item.id, m])); + for (const m of after) { + const prev = byId.get(m.item.id); + if (prev && prev !== m) return `row ${m.item.id} lost its owner`; + } + return null; +} +function expectSurvivorsKept(before: Mapped[], after: Mapped[]) { + expect(survivorMismatch(before, after)).toBeNull(); +} + +describe("mapArray window commit — edit shapes", () => { + it("removeLast / removeFirst / remove one in the middle: one owner disposed, survivors kept", () => { + for (const pos of [499, 0, 250]) { + const p = plain(items(500)); + const src = p.map().map(m => m.item); + const before = p.map().slice(); + const s0 = smallMoveHits(); + const next = src.filter((_, i) => i !== pos); + p.set(next); + if (__DEV__) expect(smallMoveHits()).toBe(s0); + expect(ids(p.map())).toEqual(next.map(i => i.id)); + expectSurvivorsKept(before, p.map()); + expect(p.disposed).toEqual([before[pos].seq]); + expect(p.created()).toBe(500); + p.dispose(); + } + }); + + it("insertLast / insertFirst / insert one in the middle: one owner created, survivors kept", () => { + for (const pos of [500, 0, 250]) { + const p = plain(items(500)); + const src = p.map().map(m => m.item); + const before = p.map().slice(); + const next = [...src.slice(0, pos), { id: 9999 }, ...src.slice(pos)]; + p.set(next); + expect(ids(p.map())).toEqual(next.map(i => i.id)); + expectSurvivorsKept(before, p.map()); + expect(p.map()[pos].seq).toBe(500); // the one created row + expect(p.disposed).toEqual([]); + p.dispose(); + } + }); + + it("scattered removals (every 3rd of 300) and a contiguous run (50 at 100)", () => { + const p = plain(items(300)); + const src = p.map().map(m => m.item); + let before = p.map().slice(); + const sparse = src.filter((_, i) => i % 3 !== 0); + p.set(sparse); + expect(ids(p.map())).toEqual(sparse.map(i => i.id)); + expectSurvivorsKept(before, p.map()); + expect(p.disposed.length).toBe(100); + expect(new Set(p.disposed)).toEqual( + new Set(before.filter((_, i) => i % 3 === 0).map(m => m.seq)) + ); + + before = p.map().slice(); + const run = [...sparse.slice(0, 100), ...sparse.slice(150)]; + p.set(run); + expect(ids(p.map())).toEqual(run.map(i => i.id)); + expectSurvivorsKept(before, p.map()); + expect(p.disposed.length).toBe(150); + p.dispose(); + }); + + it("scattered insertions (one every 4th slot) and a contiguous run (100 at 50)", () => { + const p = plain(items(200)); + const src = p.map().map(m => m.item); + let before = p.map().slice(); + const next: Item[] = []; + let fresh = 1000; + src.forEach((it, i) => { + if (i % 4 === 0) next.push({ id: fresh++ }); + next.push(it); + }); + p.set(next); + expect(ids(p.map())).toEqual(next.map(i => i.id)); + expectSurvivorsKept(before, p.map()); + expect(p.created()).toBe(250); + expect(p.disposed).toEqual([]); + + before = p.map().slice(); + const cur = p.map().map(m => m.item); + const run = [...cur.slice(0, 50), ...items(100, 5000), ...cur.slice(50)]; + p.set(run); + expect(ids(p.map())).toEqual(run.map(i => i.id)); + expectSurvivorsKept(before, p.map()); + expect(p.created()).toBe(350); + p.dispose(); + }); + + it("a replacement, a swap, a reverse, and a remove-plus-insert: correct, survivors kept", () => { + const p = plain(items(100)); + const src = p.map().map(m => m.item); + const shapes: Array<[string, Item[], number]> = [ + ["replace middle", src.map((it, i) => (i === 50 ? { id: 777 } : it)), 1], + [ + "swap", + (() => { + const n = [...src]; + [n[1], n[98]] = [n[98], n[1]]; + return n; + })(), + 0 + ], + ["reverse", [...src].reverse(), 0], + ["remove one + insert another", [...src.slice(0, 10), { id: 888 }, ...src.slice(11)], 1] + ]; + for (const [name, next, disposals] of shapes) { + const q = plain(src); + const before = q.map().slice(); + q.set(next); + expect(ids(q.map()), name).toEqual(next.map(i => i.id)); + expectSurvivorsKept(before, q.map()); + expect(q.disposed.length, name).toBe(disposals); + q.dispose(); + } + p.dispose(); + }); + + it("a large plain window with displaced survivors still goes to the small-move path", () => { + // Drop 5 rows from the middle and rotate the rest by one: the small-move + // tests pin this shape to their counter; the subsequence path must not + // take it from them. + const p = plain(items(300)); + const src = p.map().map(m => m.item); + const kept = src.filter((_, i) => i < 100 || i >= 105); + const rotated = [...kept.slice(1), kept[0]]; + const s0 = smallMoveHits(); + p.set(rotated); + expect(smallMoveHits()).toBe(s0 + 1); + expect(ids(p.map())).toEqual(rotated.map(i => i.id)); + p.dispose(); + }); +}); + +describe("mapArray window commit — index signals (arity-2 mapper)", () => { + function indexed(initial: Item[]) { + let seq = 0; + const disposed: number[] = []; + const [$src, setSrc] = createSignal(initial); + let map!: () => (Mapped & { index: () => number })[]; + const dispose = createRoot(d => { + map = mapArray($src, (item: Item, index: () => number) => { + const m = { item, seq: seq++, index }; + onCleanup(() => disposed.push(m.seq)); + return m; + }); + map(); + return d; + }); + return { + map, + set: (n: Item[]) => { + setSrc(n); + flush(); + }, + disposed, + dispose + }; + } + + it("removeFirst shifts every surviving index down by one", () => { + const p = indexed(items(50)); + const before = p.map().slice(); + p.set(before.slice(1).map(m => m.item)); + const after = p.map(); + expect(after.length).toBe(49); + after.forEach((m, i) => { + expect(m).toBe(before[i + 1]); + expect(m.index()).toBe(i); + }); + expect(p.disposed).toEqual([before[0].seq]); + p.dispose(); + }); + + it("insert at the head and in the middle gives survivors their new index and the new rows theirs", () => { + const p = indexed(items(50)); + const before = p.map().slice(); + const src = before.map(m => m.item); + const next = [{ id: 900 }, ...src.slice(0, 25), { id: 901 }, ...src.slice(25)]; + p.set(next); + const after = p.map(); + expect(ids(after)).toEqual(next.map(i => i.id)); + after.forEach((m, i) => expect(m.index(), `index of row ${m.item.id}`).toBe(i)); + expectSurvivorsKept(before, after); + expect(p.disposed).toEqual([]); + p.dispose(); + }); +}); + +describe("mapArray window commit — key function (row accessors)", () => { + function keyed(initial: Item[]) { + let seq = 0; + const disposed: number[] = []; + const [$src, setSrc] = createSignal(initial); + let map!: () => { row: () => Item; index: () => number; seq: number }[]; + const dispose = createRoot(d => { + map = mapArray( + $src, + (row: () => Item, index: () => number) => { + const m = { row, index, seq: seq++ }; + onCleanup(() => disposed.push(m.seq)); + return m; + }, + { keyed: (it: Item) => it.id } + ); + map(); + return d; + }); + return { + map, + set: (n: Item[]) => { + setSrc(n); + flush(); + }, + disposed, + dispose, + created: () => seq + }; + } + + it("removal with every survivor re-created as a new object: same owners, row accessors serve the new objects", () => { + const p = keyed(items(40)); + const before = p.map().slice(); + const next = before.map(m => ({ ...m.row() })).filter((_, i) => i !== 7 && i !== 20); + p.set(next); + const after = p.map(); + expect(after.length).toBe(38); + after.forEach((m, i) => { + expect(m.row()).toBe(next[i]); // the NEW object, same key + expect(m.index()).toBe(i); + }); + const removedSeqs = [before[7].seq, before[20].seq]; + expect(new Set(p.disposed)).toEqual(new Set(removedSeqs)); + expect(p.created()).toBe(40); + const survivorsBefore = before.filter((_, i) => i !== 7 && i !== 20); + after.forEach((m, i) => expect(m.seq).toBe(survivorsBefore[i].seq)); + p.dispose(); + }); + + it("insertion by key: created rows land at their positions, survivors keep owners and take new objects", () => { + const p = keyed(items(40)); + const before = p.map().slice(); + const copies = before.map(m => ({ ...m.row() })); + const next = [ + ...copies.slice(0, 5), + { id: 500 }, + ...copies.slice(5, 30), + { id: 501 }, + { id: 502 }, + ...copies.slice(30) + ]; + p.set(next); + const after = p.map(); + expect(after.map(m => m.row().id)).toEqual(next.map(i => i.id)); + after.forEach((m, i) => { + expect(m.row()).toBe(next[i]); + expect(m.index()).toBe(i); + }); + expect(p.created()).toBe(43); + expect([after[5].seq, after[31].seq, after[32].seq]).toEqual([40, 41, 42]); + expect(p.disposed).toEqual([]); + p.dispose(); + }); +}); + +describe("mapArray window commit — keyed: false (by index)", () => { + it("tail growth and tail shrink: row accessors read the new items, owners kept", () => { + const [$src, setSrc] = createSignal(items(10)); + let map!: () => { row: () => Item; index: number }[]; + const dispose = createRoot(d => { + map = mapArray($src, (row: () => Item, index: number) => ({ row, index }), { keyed: false }); + map(); + return d; + }); + const before = map().slice(); + setSrc(items(13, 100)); + flush(); + expect(map().length).toBe(13); + for (let i = 0; i < 10; i++) expect(map()[i]).toBe(before[i]); + map().forEach((m, i) => { + expect(m.row().id).toBe(100 + i); + expect(m.index).toBe(i); + }); + setSrc(items(4, 200)); + flush(); + expect(map().length).toBe(4); + for (let i = 0; i < 4; i++) { + expect(map()[i]).toBe(before[i]); + expect(map()[i].row().id).toBe(200 + i); + } + dispose(); + }); +}); + +describe("mapArray window commit — duplicates and the randomized oracle", () => { + it("identity duplicates: [A, A, B] → [A, B] disposes one A; [A, B] → [A, A, B] creates one", () => { + const A = { id: 1 }, + B = { id: 2 }; + const p = plain([A, A, B]); + const before = p.map().slice(); + p.set([A, B]); + expect(ids(p.map())).toEqual([1, 2]); + expect(p.disposed.length).toBe(1); + expect(p.map()[0]).toBe(before[0]); + expect(p.map()[1]).toBe(before[2]); + p.set([A, A, B]); + expect(ids(p.map())).toEqual([1, 1, 2]); + expect(p.created()).toBe(4); + expect(p.disposed.length).toBe(1); + p.dispose(); + }); + + it("random pure removals and insertions, 150 rounds, plain mode: survivors keep owners, leavers dispose once, counts balance", () => { + const rnd = xorshift(0x9e3779b9); + const p = plain(items(200)); + let nextId = 200; + for (let round = 0; round < 150; round++) { + const cur = p.map().map(m => m.item); + const before = p.map().slice(); + const disposedBefore = p.disposed.length; + const createdBefore = p.created(); + let next: Item[]; + let removed = 0, + inserted = 0; + if (cur.length > 20 && rnd(2) === 0) { + const k = 1 + rnd(Math.min(10, cur.length - 10)); + const drop = new Set(); + while (drop.size < k) drop.add(rnd(cur.length)); + next = cur.filter((_, i) => !drop.has(i)); + removed = k; + } else { + const k = 1 + rnd(10); + next = [...cur]; + for (let n = 0; n < k; n++) next.splice(rnd(next.length + 1), 0, { id: nextId++ }); + inserted = k; + } + p.set(next); + expect(ids(p.map()), `round ${round} order`).toEqual(next.map(i => i.id)); + expectSurvivorsKept(before, p.map()); + expect(p.disposed.length - disposedBefore, `round ${round} disposals`).toBe(removed); + expect(p.created() - createdBefore, `round ${round} creations`).toBe(inserted); + } + p.dispose(); + flush(); + expect(p.disposed.length).toBe(p.created()); + }, 30_000); +}); + +describe("mapArray window commit — randomized oracle in index mode", () => { + it("random pure removals and insertions, 150 rounds, arity-2 mapper", () => { + const rnd = xorshift(0x2545f491); + let seq = 0; + const disposed: number[] = []; + const [$src, setSrc] = createSignal(items(200)); + let map!: () => (Mapped & { index: () => number })[]; + const dispose = createRoot(d => { + map = mapArray($src, (item: Item, index: () => number) => { + const m = { item, seq: seq++, index }; + onCleanup(() => disposed.push(m.seq)); + return m; + }); + map(); + return d; + }); + let nextId = 200; + for (let round = 0; round < 150; round++) { + const cur = map().map(m => m.item); + const before = map().slice(); + const disposedBefore = disposed.length; + const createdBefore = seq; + let next: Item[]; + let removed = 0, + inserted = 0; + if (cur.length > 20 && rnd(2) === 0) { + const k = 1 + rnd(Math.min(10, cur.length - 10)); + const drop = new Set(); + while (drop.size < k) drop.add(rnd(cur.length)); + next = cur.filter((_, i) => !drop.has(i)); + removed = k; + } else { + const k = 1 + rnd(10); + next = [...cur]; + for (let n = 0; n < k; n++) next.splice(rnd(next.length + 1), 0, { id: nextId++ }); + inserted = k; + } + setSrc(next); + flush(); + expect(ids(map()), `round ${round} order`).toEqual(next.map(i => i.id)); + expectSurvivorsKept(before, map()); + const badIndex = map().findIndex((m, i) => m.index() !== i); + expect(badIndex, `round ${round}: first row whose index signal is wrong`).toBe(-1); + expect(disposed.length - disposedBefore, `round ${round} disposals`).toBe(removed); + expect(seq - createdBefore, `round ${round} creations`).toBe(inserted); + } + dispose(); + flush(); + expect(disposed.length).toBe(seq); + }, 30_000); +}); + +describe("mapArray window commit — NotReadyError abort on an insertion (#2903 contract)", () => { + function deferred() { + let resolve!: (value: T) => void; + const promise = new Promise(res => (resolve = res)); + return { promise, resolve }; + } + const settle = async () => { + await new Promise(r => setTimeout(r, 0)); + flush(); + }; + + it("an inserted row that suspends aborts the pass with the live arrays untouched; the retry lands it", async () => { + const gate = deferred(); + const [$source, setSource] = createSignal(["a", "b", "c"]); + const [$useAsync, setUseAsync] = createSignal(false); + const created: string[] = []; + const disposed: string[] = []; + let result = ""; + let dispose!: () => void; + createRoot(d => { + dispose = d; + const asyncVal = createMemo(async () => ($useAsync() ? gate.promise : "sync")); + const map = mapArray( + () => $source(), + (item: string) => { + created.push(item); + onCleanup(() => disposed.push(item)); + return item === "x" ? `x:${asyncVal()}` : item; + } + ); + const b = createLoadingBoundary( + () => map().join(","), + () => "loading" + ); + createRenderEffect( + () => (result = b()), + () => {} + ); + }); + flush(); + expect(result).toBe("a,b,c"); + + setUseAsync(true); + setSource(["a", "b", "x", "c"]); // a pure insertion of "x", and it suspends + flush(); + expect(result).toBe("a,b,c"); // held: nothing committed + expect(created.filter(i => i === "x").length).toBeGreaterThan(0); + expect(disposed).toEqual(expect.arrayContaining(["x"])); // the aborted row's owner was disposed with the pass + + gate.resolve("go"); + await settle(); + expect(result).toBe("a,b,x:go,c"); + + dispose(); + flush(); + const counts = new Map(); + for (const c of created) counts.set(c, (counts.get(c) ?? 0) + 1); + for (const d of disposed) counts.set(d, (counts.get(d) ?? 0) - 1); + for (const [item, n] of counts) expect(n, `owner balance for row "${item}"`).toBe(0); + }); +}); diff --git a/packages/web/test/single-item-ops.bench.tsx b/packages/web/test/single-item-ops.bench.tsx new file mode 100644 index 0000000000..9c040cd494 --- /dev/null +++ b/packages/web/test/single-item-ops.bench.tsx @@ -0,0 +1,177 @@ +/** + * @jsxImportSource @solidjs/web + * @vitest-environment jsdom + */ + +// Tier-1 DOM-lane bench. Single-item and sparse structural edits on a keyed +// 1k-row `` — the shape the Octane board's uibench one-item tree ops +// (4–6× Octane on 2026-10-15), js-framework-reorder `removefirst` (2.5×) and +// the table filters measure, and the one neither `reconcile-permute` (moves) +// nor `replace-full` / `mount-clear-cycle` (bulk) covers. Each iteration pairs +// a removal with the insertion that undoes it so the list stays 1000 rows and +// both directions are timed; the number to watch is µs per pair. +// +// Profiled 2026-10-15 (uibench `tree/[500]/[removeLast(1)]`, CDP): the edit +// itself is a few hundred nanoseconds; the cost was four newLen-sized staged +// arrays plus a Map in `updateKeyedMap`'s general path, then normalize's +// flattened copy and the reconcile prefix scan. `mapArray`'s subsequence fast +// paths remove the first part; this bench is the regression gate for it and +// the measuring stick for the rest. +// +// Also a memory-leak gate: survivors must keep their row owners, so the owner +// tree must not drift across iterations. + +import { afterAll, bench } from "vitest"; +import { createRoot, createSignal, flush, For, getOwner } from "solid-js"; +import { insert } from "../src/index.js"; + +interface Row { + id: number; + label: string; +} + +const ROWS = 1000; + +function makeRows(from = 0): Row[] { + const rows = new Array(ROWS); + for (let i = 0; i < ROWS; i++) rows[i] = { id: from + i, label: `row-${from + i}` }; + return rows; +} + +function ownerTotal(node: any): number { + let count = 1; + for (let s = node._firstChild; s; s = s._nextSibling) count += ownerTotal(s); + return count; +} + +interface Harness { + current: Row[]; + set: (next: Row[]) => Row[]; + root: any; + baselineOwners: number; +} + +const cleanups: Array<() => void> = []; +const harnesses: Array<{ name: string; harness: Harness }> = []; + +function setup(name: string, keyed?: (row: Row) => number): Harness { + let h!: Harness; + const dispose = createRoot(d => { + const root = getOwner(); + const [rows, setRows] = createSignal(makeRows()); + const container = document.createElement("div"); + insert( + container, + () => + keyed ? ( + + {row => ( +
+ {row().id} + {row().label} +
+ )} +
+ ) : ( + + {({ id, label }) => ( +
+ {id} + {label} +
+ )} +
+ ), + null + ); + h = { current: rows(), set: setRows, root, baselineOwners: 0 }; + return d; + }); + cleanups.push(dispose); + flush(); + h.baselineOwners = ownerTotal(h.root); + harnesses.push({ name, harness: h }); + return h; +} + +function commit(h: Harness, next: Row[]) { + h.current = next; + h.set(next); + flush(); +} + +// --------------------------------------------------------------------------- +// Tail: remove last, insert last (uibench removeLast(1) / insertLast(1)) +// --------------------------------------------------------------------------- + +const tailH = setup("tail"); +bench("tail: remove last + insert last (1000 rows)", () => { + const removed = tailH.current[ROWS - 1]!; + commit(tailH, tailH.current.slice(0, -1)); + commit(tailH, [...tailH.current, removed]); +}); + +// --------------------------------------------------------------------------- +// Head: remove first, insert first (jfb-reorder removefirst, uibench *First(1)) +// --------------------------------------------------------------------------- + +const headH = setup("head"); +bench("head: remove first + insert first (1000 rows)", () => { + const removed = headH.current[0]!; + commit(headH, headH.current.slice(1)); + commit(headH, [removed, ...headH.current]); +}); + +// --------------------------------------------------------------------------- +// Middle: remove at 500, insert back at 500 +// --------------------------------------------------------------------------- + +const midH = setup("middle"); +bench("middle: remove at 500 + insert at 500 (1000 rows)", () => { + const cur = midH.current; + const removed = cur[500]!; + commit(midH, [...cur.slice(0, 500), ...cur.slice(501)]); + const c2 = midH.current; + commit(midH, [...c2.slice(0, 500), removed, ...c2.slice(500)]); +}); + +// --------------------------------------------------------------------------- +// Scattered: remove every 10th row (100), insert them back (uibench filter, +// effectful-list remove_100_scattered, jfb-reorder removeevery10) +// --------------------------------------------------------------------------- + +const scatterH = setup("scattered"); +bench("scattered: remove every 10th + insert back (1000 rows)", () => { + const cur = scatterH.current; + commit( + scatterH, + cur.filter((_, i) => i % 10 !== 9) + ); + const c2 = scatterH.current; + const back = new Array(ROWS); + for (let i = 0, j = 0; i < ROWS; i++) back[i] = i % 10 === 9 ? cur[i]! : c2[j++]!; + commit(scatterH, back); +}); + +// --------------------------------------------------------------------------- +// Key-function mode: row signals + index signals on the same tail edit +// --------------------------------------------------------------------------- + +const keyedTailH = setup("tail keyed-fn", row => row.id); +bench("tail, keyed by fn: remove last + insert last (1000 rows)", () => { + const removed = keyedTailH.current[ROWS - 1]!; + commit(keyedTailH, keyedTailH.current.slice(0, -1)); + commit(keyedTailH, [...keyedTailH.current, removed]); +}); + +afterAll(() => { + for (const { name, harness } of harnesses) { + const final = ownerTotal(harness.root); + if (final - harness.baselineOwners > 5) { + throw new Error( + `Owner leak in ${name} bench: baseline=${harness.baselineOwners}, final=${final}` + ); + } + } + for (const dispose of cleanups) dispose(); +}); diff --git a/scripts/size/floor-caps.json b/scripts/size/floor-caps.json index 5abcdc9a31..8e279c63d0 100644 --- a/scripts/size/floor-caps.json +++ b/scripts/size/floor-caps.json @@ -8,16 +8,16 @@ "minified": 26664 }, "app: hydrating (no stores) with Show/For/Loading/Errored/lazy": { - "cap": "17.87 KB", - "minified": 52593 + "cap": "18.19 KB", + "minified": 53236 }, "page: base server components (hydrating + dynamic + frames + sf reference)": { - "cap": "34.62 KB", - "minified": 106175 + "cap": "34.84 KB", + "minified": 106829 }, "page: live server components (base + live/GET + action + isPending/latest)": { - "cap": "38.70 KB", - "minified": 119540 + "cap": "39.01 KB", + "minified": 120185 }, "server: floor (getRequestEvent + isServer)": { "cap": "1.34 KB", diff --git a/scripts/size/scenarios.js b/scripts/size/scenarios.js index efec7ec203..845b5307d6 100644 --- a/scripts/size/scenarios.js +++ b/scripts/size/scenarios.js @@ -2586,8 +2586,9 @@ module.exports = [ // -> 93,933 B; measured locally (linux) at 29,908 B (93,933 B minified). // Lower only: cap at measured + 10 B rounded up to 0.01 KB; recorded // minified never raised. - limit: "29.91 KB", - capMinified: 93933, + // 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, alias }, { @@ -2870,8 +2871,9 @@ module.exports = [ // 36,256 B; measured locally (linux) at 12,857 B (36,256 B minified). Lower // only: cap at measured + 10 B rounded up to 0.01 KB; recorded minified // never raised. - limit: "12.87 KB", - capMinified: 36256, + // mapArray window-staged commit (perf, #4114, 2026-10-16, merged over next @ dbc5d0005 after #4104's ratchet and #4108's observe raises): 12.87 -> 13.14 KB, measured at 13,122 B brotli / 36,900 B minified (+252 B brotli over the cap, +644 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: "13.14 KB", + capMinified: 36900, alias }, { @@ -3207,8 +3209,9 @@ module.exports = [ // router or cache can stamp without the engine, and `lazy()` stamps its // import and the read wrapper. Prod unchanged. Cap at measured + 10 B // rounded up to 0.01 KB. - limit: "14.60 KB", - capMinified: 41212, + // mapArray window-staged commit (perf, #4114, 2026-10-16, merged over next @ dbc5d0005 after #4104's ratchet and #4108's observe raises): 14.60 -> 14.91 KB, measured at 14,892 B brotli / 41,859 B minified (+292 B brotli over the cap, +647 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: "14.91 KB", + capMinified: 41859, alias: observeAlias }, { @@ -3696,8 +3699,9 @@ module.exports = [ // 29,108 (+72 B; +210 B minified, 87,372 -> 87,582) — the observe scenario's // change (the slot's stamps, `lazy()`'s marks) less the engine's own // origin map, which now delegates to the slot. - limit: "29.19 KB", - capMinified: 87582, + // mapArray window-staged commit (perf, #4114, 2026-10-16, merged over next @ dbc5d0005 after #4104's ratchet and #4108's observe raises): 29.19 -> 29.48 KB, measured at 29,462 B brotli / 88,235 B minified (+272 B brotli over the cap, +653 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: "29.48 KB", + capMinified: 88235, alias: observeAlias }, // Compiled-template scenarios (2026-10-05): the four `app:` fixtures above @@ -3895,8 +3899,9 @@ module.exports = [ // 80,206 B; measured locally (linux) at 25,646 B (80,206 B minified). Lower // only: cap at measured + 10 B rounded up to 0.01 KB; recorded minified // never raised. - limit: "25.66 KB", - capMinified: 80206, + // 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, alias }, { @@ -4042,8 +4047,9 @@ module.exports = [ // 100,760 B; measured locally (linux) at 31,705 B (100,760 B minified). // Lower only: cap at measured + 10 B rounded up to 0.01 KB; recorded // minified never raised. - limit: "31.72 KB", - capMinified: 100760, + // 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, alias }, { @@ -5239,8 +5245,9 @@ module.exports = [ // 109,100 B; measured locally (linux) at 35,353 B (109,100 B minified). // Lower only: cap at measured + 10 B rounded up to 0.01 KB; recorded // minified never raised. - limit: "35.37 KB", - capMinified: 109100, + // mapArray window-staged commit (perf, #4114, 2026-10-16, merged over next @ dbc5d0005 after #4104's ratchet and #4108's observe raises): 35.37 -> 35.78 KB, measured at 35,768 B brotli / 109,754 B minified (+398 B brotli over the cap, +654 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: "35.78 KB", + capMinified: 109754, alias: pageAlias }, { @@ -5319,8 +5326,9 @@ module.exports = [ // 124,433 -> 124,010 B; measured locally (linux) at 41,483 B (124,010 B // minified). Lower only: cap at measured + 10 B rounded up to 0.01 KB; // recorded minified never raised. - limit: "41.214 KB", - capMinified: 124010, + // mapArray window-staged commit (perf, #4114, 2026-10-16, merged over next @ dbc5d0005 after #4104's ratchet and #4108's observe raises): 41.21 -> 41.77 KB, measured at 41,758 B brotli / 124,658 B minified (+544 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: "41.77 KB", + capMinified: 124658, alias: pageAlias }, { @@ -5476,8 +5484,9 @@ module.exports = [ // 130,066 B; measured locally (linux) at 41,944 B (130,066 B minified). // Lower only: cap at measured + 10 B rounded up to 0.01 KB; recorded // minified never raised. - limit: "41.96 KB", - capMinified: 130066, + // mapArray window-staged commit (perf, #4114, 2026-10-16, from CI run 38867513870 — a local bundle of this scenario resolves the router differently and measures ~7 KB smaller, so CI's bytes set this cap): 41.96 -> 42.25 KB, measured at 42,233 B brotli / 130,720 B minified (+295 B brotli over base, +273 B over the cap, +654 B minified over the recorded size): the same window-staged commit as the other list scenarios. + limit: "42.25 KB", + capMinified: 130720, alias: pageAlias, conditions: solidConditions, compile: { hydratable: true } @@ -5602,8 +5611,9 @@ module.exports = [ // already sat 19 B over the recorded minified, so the allowance was spent. // Re-based on the measurement: cap at measured + 10 B rounded up to // 0.01 KB, recorded minified 144,773. - limit: "48.16 KB", - capMinified: 144773, + // mapArray window-staged commit (perf, #4114, 2026-10-16, from CI run 38867513870 — a local bundle of this scenario resolves the router differently and measures ~7 KB smaller, so CI's bytes set this cap): 48.16 -> 48.46 KB, measured at 48,444 B brotli / 145,422 B minified (+294 B brotli over base, +284 B over the cap, +649 B minified over the recorded size): the same window-staged commit as the other list scenarios. + limit: "48.46 KB", + capMinified: 145422, alias: pageAlias, conditions: solidConditions, compile: { hydratable: true }