Skip to content

perf(web): fix #574 by bounding the reconciler's suffix walk, not a per-row liveness check - #4133

Merged
ryansolid merged 6 commits into
nextfrom
perf/reconcile-walk-gate
Oct 18, 2026
Merged

ryansolid merged 6 commits into
nextfrom
perf/reconcile-walk-gate

Conversation

@ryansolid

@ryansolid ryansolid commented Oct 17, 2026 •

Copy link
Copy Markdown
Member

What

reconcileArrays (packages/web/src/reconcile.ts) goes back to the slot-ownership design from May (dom-expressions 78bb855c) and fixes #574 at its cause instead of with a per-row check:

  • Walks. The common-prefix and common-suffix walks match by identity, with no per-node liveness check, as they did from May 16 until the `children` helper seems to break Context, need help. #574 patch. The suffix walk is bounded to the open range [aStart, aEnd).
  • Append anchor. The append branch anchors on the last owned prefix node's successor, else the first suffix node the slot still owns, else the region end.
  • Map branch. An old node that is no longer ours is consumed up front, so the new node lands when its own turn comes.

Every destructive operation keeps its ownership gate.

Why

#574, at its cause. The single-anchor swap (2fe6310f, May 7) could leave the suffix walk reaching back past aStart into entries the pass had already consumed, meeting a node a map-branch replace had taken out while it was still pending in b; it counted that node as a live common end and never reinserted it. The August patch (9052dcf5) fixed this by adding the ownership check to every row of both walks. Bounding the walk to the open range removes the case: inside it, this pass never detaches a node — replace, remove and swap all consume the node they take out. On clean arrays the result matches next exactly (review's differential fuzz: 0 divergences in 100k seeds).

Cost of the per-row check. It was never measured when it went in. On a 1000-row list the walks run once per row per update, so it was ~9 µs of a 30 µs swap and a matching share of every long reorder. On js-framework-benchmark's millisecond, paint-dominated timings it is invisible.

Migration rule, as promised in May. Nothing throws; a node moved out of the slot is never destroyed by it; destructive operations touch only nodes the slot owns. A node moved away that sits in the unchanged prefix or suffix stays where it was moved. That was the behaviour from May 16 to Aug 21; the August per-row check had turned it into a reclaim. Framework-performed migration (#2030, #2357 and the rest of the May tests) passes unchanged.

Two bugs the review surfaced.

  • Append anchor. With identity-only walks, the append branch could anchor on a node user code moved away. reconcileArrays(p, [x], [y, x]) with x detached threw NotFoundError, which halts reactivity. The suggested isLive(b[bEnd]) ? b[bEnd] : after stops the throw but lands the insert past the live suffix when the moved node is not the suffix's last; the anchor is now the first node the slot still owns.
  • Map branch order (present since May). An old node that is no longer ours was "replaced" by inserting the new node at the region's end, and the sequence branch anchored on the region's end the same way. [a, b] with a moved away, then [d, b, a], came out [b, d, a]. Consuming the dead node fixes it.

Verified

An oracle fuzz of the May rule checked that nothing throws, that every unmoved node of the new list is in the slot in list order, and that a moved node the list dropped stays put. It ran 20,000 seeds with and without a slot marker:

reconciler rule violations
next 9,714 out of order
this PR before the map-branch change 7,510 out of order
this PR 0

No variant threw or lost an owned node.

Measured

All on next @ e1ced78 (with #4129, #4132, #4136 and #4138), unminified Octane fixtures, same machine, interleaved.

Call profiler, js-framework swap of rows 1 and 998, µs per click:

build medians, three rounds
next 29.8 / 29.4 / 29.8
this PR without the map-branch change 25.6 / 25.6 / 26.1
this PR 24.8 / 25.9 / 25.5
Vue Vapor 9.0 / 9.1 / 9.0

Octane board, two rounds each, Solid ÷ Vapor (same-run pairing), this PR against next:

suite / family this PR vs next noise (PR round 2 ÷ round 1)
memo-wall −7.9% 0.4%
… one_change −25.2% 0.8%
js-framework-reorder −5.8% 4.0%
… rotations −9.7% 0.9%
… displacements −5.9% 0.8%
uibench −1.9% 0.3%
… single insert/remove/move −4.8% 0.8%
… filters −7.0% 1.5%
… reverse, removeAll flat

The map-branch change costs nothing on swap and about 1–2% on rotations and displacements (+1.7% / +2.2% vs Vapor, near the ~1% noise there); it is a correctness fix for the order of the changed window. uibench's 96-case oracle was identical in all six runs (23,331 elements, 32,543 shared identities).

A variant that kept the August reclaim with the cheapest possible check (parentNode only when there is no marker) kept none of the swap gain and only 15–30% of the rest: the cost is the per-row DOM read itself.

CodSpeed: single-item-ops 1000 rows head +53%, tail +30%, tail keyed by fn +24%, middle +16%; reconcile-permute unchanged.

Size

List-bearing scenarios ~10 B minified under their recorded sizes; brotli moves are layout noise.

Tests

test/runtime/insert.spec.js, new:

  • a moved-away node in the unchanged prefix stays where it was moved;
  • a removed node in the unchanged suffix stays removed;
  • a moved-away node the new list drops stays where it was moved;
  • the changed window is placed in list order around a moved-away node;
  • inserts around a moved-away suffix or prefix node don't throw and land before the live suffix, directly and through insert.

On next six of these fail; without the map-branch change, the ordering test does. The #574 test and the May migration tests pass unchanged. Web default 1387, hydrate 509, server 1719.

🤖 Generated with Claude Code

… liveness check

The suffix walk is bounded to the open range [aStart, aEnd); inside that
range this pass never detaches a node, so neither walk needs isLive. The
bound alone closes #574, where the unbounded suffix walk reached back past
aStart to a node a map-branch replace had taken out while it was still
pending in b. Every anchor read and destructive branch keeps its ownership
gate.

Measured with the call profiler on the Octane js-framework fixture, swap of
rows 1 and 998 in 1000: 29.5 -> 25.0 µs (Vapor 9.0). reconcileArrays
inclusive 24.4 -> 13.8 µs; isLive (8.4 µs self) gone. uibench through the
board runner, oracle identical to the previous run (96 cases, 32,543 shared
identities): tree/[500] virtual_dom_worst_case -17%, snabbdom_worst_case
-14%, moveFromEndToStart -11%, tree removeAll -7 to -11%, reverse flat.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@changeset-bot

changeset-bot Bot commented Oct 17, 2026 •

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: bfa6a89

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 14 packages
Name Type
@solidjs/web Patch
@solidjs/babel-plugin Patch
@solidjs/diagnostics Patch
@solidjs/element Patch
@solidjs/h Patch
@solidjs/html Patch
test-integration Patch
board-example Patch
server-dbmon-example Patch
todos-server-example Patch
@solidjs/compiler Patch
@solidjs/signals Patch
solid-js Patch
@solidjs/universal Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@github-actions

github-actions Bot commented Oct 17, 2026 •

Copy link
Copy Markdown

Size (brotli, eager entry chunk)

scenario head vs base minified vs base minified vs recorded cap lazy chunks (not counted)
signals: core floor (createSignal/Memo/Effect/Root/flush) 7.25 KB 0 B 0 B +8 B 7.25 KB ✅
signals: + createStore 14.81 KB 0 B 0 B +8 B 14.82 KB ✅
signals: + isPending/latest 9.96 KB 0 B 0 B 0 B 9.98 KB ✅
signals: + until/refresh (truth readers, no optimism) 7.86 KB 0 B 0 B +8 B 7.86 KB ✅
app: render + one signal (the simple-app floor) 9.56 KB −9 B (−0.1%) −10 B −2 B 9.56 KB ⚠️ over by 3 B, 22 B minified headroom
app: hydrating (no stores) with Show/For/Loading/Errored/lazy 18.17 KB −14 B (−0.1%) −10 B −2 B 18.19 KB ✅ lazy-page.js 0.04 KB
app: hydrating + every store primitive family 30.14 KB −10 B (−0.0%) −10 B −6 B 30.14 KB ✅ lazy-page.js 0.04 KB
app: CSR with Show/For/Loading/Errored/lazy 13.13 KB +6 B (+0.0%) −10 B −2 B 13.14 KB ✅ lazy-page.js 0.04 KB
app: CSR, observe tier (same app on the observe artifacts) 14.87 KB +2 B (+0.0%) −10 B −2 B 14.91 KB ✅ lazy-page.js 0.04 KB
app: CSR, observe tier + attribution engine enabled 29.49 KB +29 B (+0.1%) −10 B −2 B 29.48 KB ⚠️ over by 13 B, 22 B minified headroom lazy-page.js 0.04 KB
app: compiled floor (one template, one text hole, one delegated click) 9.76 KB −2 B (−0.0%) −10 B 0 B 9.77 KB ✅
app: compiled CSR (JSX todo app: spread/merge/omit, events, class/style, keyed For, Show, Loading + lazy, store) 25.97 KB −5 B (−0.0%) −10 B −2 B 26.03 KB ✅ stats.js 0.18 KB
app: compiled hydrating (the same JSX todo app through hydrate(), compiled hydratable) 31.93 KB −54 B (−0.2%) −10 B −2 B 31.98 KB ✅ stats.js 0.20 KB
frames: eager client consumer (frames client + transport, lazy codec) 12.23 KB 0 B 0 B 0 B 12.25 KB ✅
page: base server components (hydrating + dynamic + frames + sf reference) 33.45 KB +57 B (+0.2%) −10 B −2 B 33.46 KB ✅ assets.js 0.78 KB, decode.js 6.25 KB, lazy-page.js 0.04 KB, regions.js 1.07 KB, trace.js 8.99 KB, wire.js 1.33 KB
page: live server components (base + live/GET + action + isPending/latest) 37.60 KB +30 B (+0.1%) −10 B −6 B 37.65 KB ✅ assets.js 0.78 KB, decode.js 6.25 KB, lazy-page.js 0.04 KB, regions.js 1.07 KB, trace.js 8.99 KB, wire.js 1.33 KB
page: compiled base server components (the base page as JSX: templates with class/style/attributes/events, For/Show; no spread) 35.40 KB +27 B (+0.1%) −10 B −2 B 35.42 KB ✅ assets.js 0.78 KB, decode.js 6.25 KB, regions.js 1.06 KB, sc-comments.js 0.20 KB, trace.js 9.01 KB, wire.js 1.33 KB
page: compiled live server components (the compiled base page + live/GET + action + isPending/latest) 41.47 KB −37 B (−0.1%) −10 B −10 B 41.52 KB ✅ eager (counted): web.js 21.59 KB; assets.js 0.78 KB, decode.js 6.25 KB, regions.js 1.07 KB, sc-comments.js 0.19 KB, trace.js 9.03 KB, wire.js 1.33 KB
page: base + router (base page + @solidjs/router: createRouter, two routes, preload, useNavigate) 40.81 KB −68 B (−0.2%) −10 B +1 B 40.90 KB ✅ assets.js 0.78 KB, decode.js 6.25 KB, lazy-page.js 0.04 KB, regions.js 1.07 KB, server.js 1.02 KB, serverForms.js 3.60 KB, trace.js 9.05 KB, wire.js 1.33 KB
page: live + router (live page + @solidjs/router: createRouter, two routes, preload, useNavigate) 47.04 KB +2 B (+0.0%) −10 B −5 B 47.05 KB ✅ eager (counted): client.js 26.71 KB; assets.js 0.78 KB, decode.js 6.25 KB, lazy-page.js 0.04 KB, regions.js 1.07 KB, server.js 1.02 KB, serverForms.js 3.30 KB, trace.js 9.05 KB, wire.js 1.34 KB
server: floor (getRequestEvent + isServer) 1.33 KB 0 B 0 B 0 B 1.34 KB ✅
server: renderToString (the server-render floor) 20.62 KB 0 B 0 B −23 B 20.59 KB ⚠️ over by 34 B, 43 B minified headroom

⚠️ Over the brotli cap within the minified allowance (passes)

  • app: render + one signal (the simple-app floor): over brotli cap by 3 B; minified 26,662 B vs 26,664 B recorded with the cap (−2 B) — 22 B of the 20 B minified allowance left; −10 B minified over this PR's base
  • app: CSR, observe tier + attribution engine enabled: over brotli cap by 13 B; minified 88,233 B vs 88,235 B recorded with the cap (−2 B) — 22 B of the 20 B minified allowance left; −10 B minified over this PR's base
  • server: renderToString (the server-render floor): over brotli cap by 34 B; minified 72,131 B vs 72,154 B recorded with the cap (−23 B) — 43 B of the 20 B minified allowance left; +0 B minified over this PR's base

Bundled with Rolldown (what Vite ships), brotli q11, decimal KB. A scenario fails only when it is over its brotli cap and its minified size is more than 20 B over the minified recorded with the cap; over the cap within that allowance is brotli layout noise and passes with a warning. Caps and their recorded minified in scripts/size/scenarios.js; the floor and page caps in floor-caps.json are frozen (lower only, or Size-Exception: in the PR body). npm run ratchet lowers caps per RC; it never raises one (scripts/size/README.md).

@coveralls

coveralls commented Oct 17, 2026 •

Copy link
Copy Markdown

Coverage Report for CI Build 39114342011

Coverage remained the same at 77.564%

Details

  • Coverage remained the same as the base build.
  • Patch coverage: No coverable lines changed in this PR.
  • No coverage regressions found.

Uncovered Changes

No uncovered changes found.

Coverage Regressions

No coverage regressions found.


Coverage Stats

Coverage Status
Relevant Lines: 1339
Covered Lines: 1097
Line Coverage: 81.93%
Relevant Branches: 1050
Covered Branches: 756
Branch Coverage: 72.0%
Branches in Coverage %: Yes
Coverage Strength: 27.83 hits per line

💛 - Coveralls

@codspeed

codspeed Bot commented Oct 17, 2026 •

Copy link
Copy Markdown

Merging this PR will improve performance by 26.64%

⚠️ 3 benchmarks spent significant time in system calls

System calls cannot be consistently instrumented, so they are not included in the measure, which understates the real cost. Please switch to the Walltime instrument to accurately measure system calls.

Measurement and system calls

⚠️ Different runtime environments detected

Some benchmarks with significant performance changes were compared across different runtime environments,
which may affect the accuracy of the results.

Open the report in CodSpeed to investigate

⚡ 5 improved benchmarks
✅ 191 untouched benchmarks
⏩ 5 skipped benchmarks1

Performance Changes

Benchmark BASE HEAD Efficiency
⚡ head: remove first + insert first (1000 rows) 3 ms 2 ms +51.9%
⚡ tail: remove last + insert last (1000 rows) 2.5 ms 1.9 ms +29.58%
⚡ tail, keyed by fn: remove last + insert last (1000 rows) 2.9 ms 2.4 ms +23.25%
⚡ memo + sync render effect only (reference) 33 ms 28.3 ms +16.59%
⚡ ⚠️ middle: remove at 500 + insert at 500 (1000 rows) 5 ms 4.3 ms +15.17%

Tip

Curious why performance improved? Comment @codspeedbot explain why performance improved on this PR, or directly use the CodSpeed MCP with your agent.


Comparing perf/reconcile-walk-gate (2b5f46f) with next (5c8ec96)

Open in CodSpeed

Footnotes

  1. 5 benchmarks were skipped, so the baseline results were used instead. If they were deleted from the codebase, click here and archive them to remove them from the performance reports. ↩

ryansolid added a commit that referenced this pull request Oct 18, 2026
Journal §5–§12 of the Vapor lane: the signal-favoring fixture form, the
selection cost traced to the store key-count drift (#4132) and the
rc.0/rc.1 comparison, swap's history back to rc.0 and the reconciler walk
change (#4133), and the move-hint probe — built, measured against a
back-to-back control, and parked (swap 25 -> 15.8 µs where it applies, a
3% tax where it does not, ~300 B brotli per app bundle, never for a
keyed-by-function For). A pointer under the May metadata-channel deferral
and the lane ledger updated.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
ryansolid added a commit that referenced this pull request Oct 18, 2026
… ÷ Octane 0.93 -> 0.85, ÷ Vapor 1.05 -> 0.97)

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
ryansolid added a commit that referenced this pull request Oct 18, 2026
 each buy

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

Copy link
Copy Markdown
Member Author

Review notes. The speedups are real: CodSpeed is +29%, with single-item-ops 1000 rows head +51%, tail +30%, tail keyed by fn +24% and middle +15%, and reconcile-permute unchanged. Size is −10 B minified on every web scenario. One bug and one behaviour change below.

Bug: the append branch can throw and halt reactivity. reconcile.ts:60 reads its insert anchor as node = b[bEnd - bStart] without a liveness check. Before this PR the suffix walk's isLive meant that node was always still ours. Now it can be a node user code moved away.

// via insert: render [z, x], then user code moves x (a ref into a modal, say)
other.appendChild(x);
setList([y, x]);
// next: parent is [y, x]. This PR: NotFoundError, then REACTIVITY_HALTED, parent left empty.
// Smallest form: reconcile(p, [x], [y, x]) with x detached.

Suggested fix, which keeps the walks check-free:

} else node = isLive(b[bEnd]) ? b[bEnd] : after;

In a differential fuzz against next (jsdom, 30k seeds with user-side removes and moves), that guard takes the throws only this PR produces from 416 to 0.

Behaviour change: whether a migrated node comes back now depends on where it sits. On next, a node user code removed or moved away, but which is still in the new list, is always reclaimed. Here the prefix and suffix walks skip it. [n1,n2,n3], n2 moved out, then [n1,n2,n3,n4]: next puts n2 back and this PR leaves it in other. If the user called n2.remove(), it stays out of the DOM while the list still holds it. The same node is still reclaimed when the order around it changes: for [n2,n1,n3] the swap branch moves it back. If "left alone" is the intended rule, a test should pin it. Otherwise keep the per-node check.

On clean arrays (100k seeds, no user mutation) there were 0 divergences from next, so the claim that the reconciler never detaches a node inside the open range holds. Nothing pins the new behaviour yet. Worth adding: a migrated tail node followed by a prepend (no throw, correct region), and whichever rule is chosen for a migrated node in the stable prefix or suffix. CI is green.

— Claude via Claude Code


Generated by Claude Code

ryansolid and others added 2 commits October 18, 2026 01:15
…pped a migrated one

Review on #4133: with the per-node check gone from the prefix and suffix
walks, the node the append branch anchors on can be one user code moved
away. `reconcileArrays(p, [x], [y, x])` with x detached threw NotFoundError
from insertBefore, which halts reactivity; through insert, rendering [z, x],
moving x into a modal and then rendering [y, x] left the slot empty.

The suggested `isLive(b[bEnd]) ? b[bEnd] : after` stops the throw but lands
the insert past the live suffix when the migrated node is not the suffix's
last, and the bStart branch had the same fallback when the node before the
insert is the migrated one. The anchor is now the last live prefix node's
successor, else the first suffix node still ours, else `after`. The walks
stay check-free; the loop runs only in the append branch and stops at its
first live node, which is the first node in every case without migration.

Tests: a migrated suffix node under reconcileArrays (no throw); a prepend
over a migrated tail through insert (no throw, insert inside the slot,
still reactive after); an insert after a migrated prefix node and before a
migrated suffix head (both land before the live suffix). All four fail
without the fix, and the first failure halts reactivity for the rest of the
file. Web default 1383, hydrate 509, server 1719.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… pin the May migration rule

Review on #4133. The reconciler keeps the slot-ownership design from May
(78bb855c): identity-only prefix and suffix walks, every destructive op
gated on ownership. A node moved away that sits in the unchanged prefix or
suffix stays where it was moved; #574 stays fixed by the bounded suffix walk.

Map branch: an old node that is no longer ours was "replaced" by inserting
the new node at the region's end, and the sequence branch anchored on the
region's end the same way — since May. [a, b] with a moved away, then
[d, b, a], came out [b, d, a]. The dead node is now consumed up front and
the new node lands when its turn comes. An oracle fuzz of the May rule
(nothing throws; every unmoved node of the new list is in the slot, in list
order; a moved node the list dropped stays put), 20k seeds with and without
a slot marker: this PR before the change 7,510 out of order, next 9,714,
now 0. The map branch is also smaller: list-bearing scenarios come out
~10 B minified under their recorded sizes.

Tests pin the rule: a moved-away node in the unchanged prefix, a removed
node in the unchanged suffix, a moved-away node the list drops, and the
window placed in order around a moved-away node. On next six of the new
tests fail; on this PR without the change, the ordering test.
Web default 1387, hydrate 509, server 1719.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@ryansolid ryansolid changed the title perf(web): the reconciler's prefix and suffix walks skip the per-node liveness check perf(web): fix #574 by bounding the reconciler's suffix walk, not a per-row liveness check Oct 18, 2026
ryansolid and others added 3 commits October 18, 2026 02:07
…ogether on next

Each passed under the cap alone (#4129 +51 B minified, #4136 +4 B); together
on next @ 5c8ec96 they measure 9,963 B brotli / 28,064 B minified, 3 B
over the brotli cap and 39 B past the minified allowance, so the size gate
failed on every PR against next. This PR touches no signals code; the merge
of next brought the row in. Cap 9.96 -> 9.98 KB, recorded minified 28,005 ->
28,064, with a ledger line.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…oncile-walk-gate

# Conflicts:
#	scripts/size/scenarios.js
@ryansolid
ryansolid merged commit 8e2446f into next Oct 18, 2026
7 of 8 checks passed
ryansolid added a commit that referenced this pull request Oct 18, 2026
…im, the May rule, final numbers; lane closed

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants