Skip to content

Server For keeps its rows across live-hole sweeps (#4056) - #4057

Merged
ryansolid merged 5 commits into
nextfrom
claude/project-thread-ico60t
Oct 14, 2026
Merged

ryansolid merged 5 commits into
nextfrom
claude/project-thread-ico60t

Conversation

@ryansolid

@ryansolid ryansolid commented Oct 14, 2026 •

Copy link
Copy Markdown
Member

Requested by Ryan · project thread

Summary

Before: a <For> inside a markup hole that re-emits re-ran mapFn for every row on every sweep. That covers live server components and standing sources. In the chat example, each streaming write re-rendered the markdown and highlighting of the whole conversation, so server CPU per reply grew by about 1.2 s with each earlier reply.

After: during a sweep, a row whose item at the same index is unchanged reuses the row it rendered on the previous sweep. "Unchanged" means the same reference or a shallow-equal object, since a live source usually hands back a fresh snapshot. Chat's server CPU per reply stays flat as the thread grows, and the app needs no extra machinery.

How:

  • Server mapArray (packages/solid/src/server/signals.ts): keeps { items, rows } from the last pass, but only while sharedConfig.context.liveHoles.sweeping is true. A row is reused when sameItem(prev.items[i], items[i]) holds, meaning === or a shallow compare of own keys.
    • Sweeps are already owner-free and mint-suppressed, because the impurity gate closes any hole that creates owners. So a kept row carries no markers or bindings.
    • Plain SSR, a server component's first render and its retries never read or fill the cache. For them the cost is one property read per pass.
  • No reuse key. Reuse only happens at the same index, so a key would only help rows that moved, and those render again. $key stays morph-only identity for the client.
  • Chat example: renders its finished messages through <For>. The Retry/Regenerate form now rides on every finished reply, and CSS shows it only on the last message (.messages > li:not(:last-child) form.again), so a row depends only on its own message. The server already refuses to regenerate anything but the last reply.

Semantics: a row is a function of its item and index. An item mutated in place keeps its old row, and a row that reads outside data keeps the value it rendered with. Repeat is deliberately left out. Its rows depend only on their index, so most real rows read outside data and reuse would go stale.

How did you test this change?

  • New packages/web/test/server/live-hole-for-rows-4056.spec.tsx:
    • Emitted holes are unchanged.
    • mapFn runs A,B,BB instead of A,A,A,B,A,BB,A,BB, both for the same item objects and for fresh shallow-equal snapshots.
    • A row that moves to another index renders again.
  • solid-js: 860/860 pass. web client 1392/1392 and hydrate 501 pass. web server 1671 pass with 1 failure: the adapter-request "rebuild failure" test. It fails only on Node 22, which the local container runs, and passes on Node 24, which is what .nvmrc and CI use.
  • Chat example: vite build plus check-bundle are clean. In a browser after six replies, only the last message's Regenerate form is visible.
  • Chat benchmark, run locally and not committed: simulated model, 12 KB replies, about 258 writes each, client CPU at 4x.
prior replies server CPU per reply, before after markdown calls per reply, before → after
0 1.5 s 2.2 s* 262 → 264
1 2.5 s 1.7 s 518 → 260
3 5.1 s 1.3 s 1036 → 260
5 7.4 s 1.6 s 1554 → 259

*The first reply includes server warm-up. Markdown calls show the row reuse directly: they stay at about one per write however long the thread gets.

The wire was flat in both runs, at about 27 KB brotli per reply. Client time is essentially unchanged: the wire still carries whole-hole HTML. Per-row ops on the wire are the follow-up in #4056.

🤖 Generated with Claude Code

https://claude.ai/code/session_01VywmD8RauUVvMzG8pYPUX8

A markup hole that re-emits re-evaluated every row of a list on every
write. During a sweep, a row whose item is the same reference at the same
index now reuses the row rendered on the previous sweep. Plain SSR and a
server component's first render never read or fill the cache.

The chat example renders its finished messages through <For> with rows
that keep their identity, so a streaming reply no longer re-renders the
whole history on every write.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VywmD8RauUVvMzG8pYPUX8
@changeset-bot

changeset-bot Bot commented Oct 14, 2026 •

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 93f17ce

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

This PR includes changesets to release 13 packages
Name Type
solid-js Patch
@solidjs/diagnostics Patch
@solidjs/element Patch
@solidjs/h Patch
@solidjs/html Patch
test-integration Patch
@solidjs/universal Patch
@solidjs/web Patch
board-example Patch
todos-server-example Patch
@solidjs/babel-plugin Patch
@solidjs/compiler Patch
@solidjs/signals 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

@ryansolid ryansolid self-assigned this Oct 14, 2026
@github-actions

github-actions Bot commented Oct 14, 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.47 KB 0 B 0 B −8 B 7.48 KB ✅
signals: + createStore 14.94 KB 0 B 0 B −22 B 14.94 KB ⚠️ over by 3 B, 42 B minified headroom
signals: + isPending/latest 9.75 KB 0 B 0 B −8 B 9.77 KB ✅
app: render + one signal (the simple-app floor) 9.95 KB 0 B 0 B −8 B 9.98 KB ✅
app: hydrating (no stores) with Show/For/Loading/Errored/lazy 17.91 KB 0 B 0 B −4 B 17.93 KB ✅ lazy-page.js 0.04 KB
app: hydrating + every store primitive family 29.56 KB 0 B 0 B −22 B 29.57 KB ✅ lazy-page.js 0.04 KB
app: CSR with Show/For/Loading/Errored/lazy 13.04 KB 0 B 0 B −22 B 13.07 KB ✅ lazy-page.js 0.04 KB
app: CSR, observe tier (same app on the observe artifacts) 14.64 KB 0 B 0 B −22 B 14.64 KB ✅ lazy-page.js 0.04 KB
app: CSR, observe tier + attribution engine enabled 29.23 KB 0 B 0 B +14 B 29.24 KB ✅ lazy-page.js 0.04 KB
app: compiled floor (one template, one text hole, one delegated click) 10.16 KB 0 B 0 B −8 B 10.17 KB ✅
app: compiled CSR (JSX todo app: spread/merge/omit, events, class/style, keyed For, Show, Loading + lazy, store) 25.80 KB 0 B 0 B −99 B 25.79 KB ⚠️ over by 12 B, 119 B minified headroom stats.js 0.18 KB
app: compiled hydrating (the same JSX todo app through hydrate(), compiled hydratable) 31.64 KB 0 B 0 B +20 B 31.64 KB ⚠️ over by 4 B, 0 B minified headroom stats.js 0.20 KB
frames: eager client consumer (frames client + transport, lazy codec) 11.54 KB 0 B 0 B 0 B 11.54 KB ✅
page: base server components (hydrating + dynamic + frames + sf reference) 34.21 KB 0 B 0 B −1274 B 34.33 KB ✅ assets.js 0.78 KB, bind.js 1.83 KB, decode.js 6.23 KB, lazy-page.js 0.04 KB, regions.js 0.80 KB, trace.js 9.01 KB, wire.js 0.93 KB
page: live server components (base + live/GET + action + isPending/latest) 37.97 KB 0 B 0 B −1343 B 38.15 KB ✅ assets.js 0.78 KB, bind.js 1.83 KB, decode.js 6.23 KB, lazy-page.js 0.04 KB, regions.js 0.80 KB, trace.js 9.02 KB, wire.js 0.93 KB
page: compiled base server components (the base page as JSX: templates with class/style/attributes/events, For/Show; no spread) 35.06 KB 0 B 0 B −2364 B 35.66 KB ✅ assets.js 0.78 KB, bind.js 1.83 KB, decode.js 6.23 KB, regions.js 0.80 KB, sc-comments.js 0.20 KB, trace.js 8.99 KB, wire.js 0.93 KB
page: compiled live server components (the compiled base page + live/GET + action + isPending/latest) 40.74 KB 0 B 0 B −2348 B 41.21 KB ✅ eager (counted): web.js 21.57 KB; assets.js 0.78 KB, bind.js 1.84 KB, decode.js 6.23 KB, regions.js 0.79 KB, sc-comments.js 0.19 KB, trace.js 8.97 KB, wire.js 0.93 KB
page: base + router (base page + @solidjs/router: createRouter, two routes, preload, useNavigate) 41.65 KB 0 B 0 B −1274 B 41.79 KB ✅ assets.js 0.78 KB, bind.js 1.85 KB, decode.js 6.23 KB, lazy-page.js 0.04 KB, regions.js 0.80 KB, server.js 1.02 KB, serverForms.js 3.58 KB, trace.js 9.01 KB, wire.js 0.94 KB
page: live + router (live page + @solidjs/router: createRouter, two routes, preload, useNavigate) 47.31 KB 0 B 0 B −1264 B 47.49 KB ✅ eager (counted): client.js 27.71 KB; assets.js 0.78 KB, bind.js 1.84 KB, decode.js 6.23 KB, lazy-page.js 0.04 KB, regions.js 0.81 KB, server.js 1.02 KB, serverForms.js 3.30 KB, trace.js 8.97 KB, wire.js 0.94 KB
server: floor (getRequestEvent + isServer) 1.33 KB 0 B 0 B 0 B 1.34 KB ✅
server: renderToString (the server-render floor) 20.57 KB 0 B 0 B 0 B 20.58 KB ✅

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

  • signals: + createStore: over brotli cap by 3 B; minified 45,343 B vs 45,365 B recorded with the cap (−22 B) — 42 B of the 20 B minified allowance left; +0 B minified over this PR's base
  • app: compiled CSR (JSX todo app: spread/merge/omit, events, class/style, keyed For, Show, Loading + lazy, store): over brotli cap by 12 B; minified 80,624 B vs 80,723 B recorded with the cap (−99 B) — 119 B of the 20 B minified allowance left; +0 B minified over this PR's base
  • app: compiled hydrating (the same JSX todo app through hydrate(), compiled hydratable): over brotli cap by 4 B; minified 100,981 B vs 100,961 B recorded with the cap (+20 B) — 0 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 14, 2026 •

Copy link
Copy Markdown

Coverage Report for CI Build 38599202096

Coverage remained the same at 77.515%

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: 1326
Covered Lines: 1084
Line Coverage: 81.75%
Relevant Branches: 1040
Covered Branches: 750
Branch Coverage: 72.12%
Branches in Coverage %: Yes
Coverage Strength: 27.4 hits per line

💛 - Coveralls

@codspeed

codspeed Bot commented Oct 14, 2026 •

Copy link
Copy Markdown

Merging this PR will improve performance by 23.4%

⚠️ 1 benchmark 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

⚡ 1 improved benchmark
✅ 194 untouched benchmarks

Performance Changes

Benchmark BASE HEAD Efficiency
⚡ memo + sync render effect only (reference) 32.4 ms 26.2 ms +23.4%

Tip

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


Comparing claude/project-thread-ico60t (93f17ce) with next (f286a40)

Open in CodSpeed

Copy link
Copy Markdown
Member Author

The CodSpeed Performance Analysis failure isn't caused by this PR. The two benchmarks it flags are both in packages/signals: mount-rows.bench.ts "memo + sync render effect only" and projection-root-write.bench.ts "createStore setter: delete + set one root key". This PR doesn't touch that package. Its only runtime change is the server build's mapArray (packages/solid/src/server/signals.ts), and none of the signals benches import it. CodSpeed's own report also warns that base and head ran in "different runtime environments". No fix exists to port. I'm re-running the benchmark job once to confirm.


Generated by Claude Code

@ryansolid
ryansolid marked this pull request as ready for review October 14, 2026 15:23
#4056)

A live source usually hands back a fresh snapshot on every change, so a
reference check alone never hits. A row whose item at the same index is
shallow-equal to last sweep's now reuses its row, which removes the
identity-keeping helper from the chat example. Chat's Regenerate button
moves to CSS so a row depends only on its own message.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VywmD8RauUVvMzG8pYPUX8
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VywmD8RauUVvMzG8pYPUX8
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.

3 participants