Skip to content

perf(web): rerun only the changed props layer in a view spread - #4566

Draft
brenelz wants to merge 2 commits into
solidjs:nextfrom
brenelz:perf/spread-view-layer-effects
Draft

brenelz wants to merge 2 commits into
solidjs:nextfrom
brenelz:perf/spread-view-layer-effects

Conversation

@brenelz

@brenelz brenelz commented Nov 1, 2026

Copy link
Copy Markdown
Contributor

Draft to see the CodSpeed run; two design questions below are open.

What was slow

On the Kobalte-shaped polymorphic chain (test/harness/polymorphic.tsx), updating one row label took twice as long as the same element written as plain JSX. The DOM writes are identical in both forms (1600 each of aria-label, title and text per pass), so the gap was all JavaScript.

spread() puts every prop of an element in one attribute effect. A label change reran all 13 getters through the merge/omit layers, including two compiled memo(() => !!isNativeButton()) conditions that are rebuilt on every run, and assign() then wrote only the two attributes that changed.

What changed

When the spread source is a merge/omit view over plain objects (hasStaticKeys), spread() now:

  • applies the data properties in one effect that never reruns;
  • gives the getters one effect per owning object, which in a component chain is one per props layer, so a change reruns only that layer's getters;
  • claims the element after the first application, as before.

class/style always go to the tracked layer effects, because a data store object can change in place. Form elements (DOMWithState) and views with a ref, a namespaced key (prop:, attr:, on:) or a child-content key (innerHTML, textContent, innerText) keep the single effect, whose application order they depend on.

Measurements

The numbers come from a standalone harness on production builds: vite compiles the polymorphic harness, and Node with jsdom runs it with --conditions=browser. Each figure is the minimum over many batches. Vitest roughly doubles production times here (its module transform), so I didn't use it for these.

1000 rows Before After Plain JSX
Update 100 rows x 16, chain 6.23 ms 3.5 ms 3.0 ms
Mount and clear, chain 33.3 ms 35.6 ms 20.0 ms

The other spread benches (spread-enumerate, style-class-object, dynamic-tag) moved between -8% and +1%.

Open questions

  • Atomicity. An element's attributes no longer update as one unit. If one layer's getter is pending, the other layers' attributes can apply in the same frame.
  • The spread() creates three reactive nodes per element #3388 cap. A spread could create at most two reactive nodes. A view spread now creates one effect per getter-bearing layer, plus one for its data properties.

Verification

  • npx vitest run, npx vitest run --config vite.config.hydrate.mjs and npx vitest run --config vite.config.server.mjs in packages/web all pass (1626, 716 and 1911 tests).
  • New specs in test/spread-nodes.spec.tsx: a change reruns only its own layer's getters (fails on next); a data class store stays tracked; class beside prop:className keeps the single effect's winner.
  • I did not check the size caps.

🤖 Generated with Claude Code

Data class/style values stay in the tracked layer effects (a store object can change in place), data keys apply inside an effect so hydration owns the writes, dev tagElement runs, and form elements or views with namespaced or child-content keys keep the single attribute effect.
@changeset-bot

changeset-bot Bot commented Nov 1, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 5424730

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

This PR includes changesets to release 15 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
canvas-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

@codspeed

codspeed Bot commented Nov 1, 2026

Copy link
Copy Markdown

Merging this PR will regress 2 benchmarks

1 warning

⚠️ 1 benchmark is dominated by system calls

System calls cannot be simulated consistently and are left out of the measure. The reported value understates the real cost. Switch to the Walltime instrument to measure them.

Learn more about system calls

See affected benchmarks

⚡ 3 improved benchmarks
❌ 2 regressed benchmarks
✅ 195 untouched benchmarks
⏩ 5 skipped benchmarks1

Warning

Please fix the performance issues or acknowledge them on CodSpeed.

Performance Changes

Benchmark BASE HEAD Efficiency
❌ polymorphic-chain mount+clear 1000 rows: chain 571.1 ms 642.8 ms -11.17%
❌ polymorphic-chain mount+clear 1000 rows: chain-static 516.3 ms 564.5 ms -8.53%
⚡ polymorphic-chain update 100/1000 rows × 16: chain 108.1 ms 56.7 ms +90.57%
⚡ polymorphic-chain update 100/1000 rows × 16: chain-static 104.6 ms 56.5 ms +84.99%
⚡ dynamic-tag update 100/1000 rows × 16: Dynamic 37.3 ms 33.6 ms +11.04%

Tip

Investigate this regression by commenting @codspeedbot fix this regression on this PR, or directly use the CodSpeed MCP with your agent.


Comparing brenelz:perf/spread-view-layer-effects (5424730) with next (62745f6)

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. ↩

@brenelz

brenelz commented Nov 1, 2026

Copy link
Copy Markdown
Contributor Author

meh not sure its worth it. Targets one very specific benchmark

Copy link
Copy Markdown
Member

Thanks for this, @brenelz. Halving the update time on the polymorphic chain is a real win. Ryan read the review and wants a rework rather than landing it as is. Both of your open questions are the reasons:

  • The spread() creates three reactive nodes per element #3388 cap is a hard contract. Effect count per element is the cost we guard most closely. Going from 1 or 2 nodes to 3 or 4 per spread element shows up as a mount regression; you measured it yourself (33.3 → 35.6 ms mount/clear). In js-framework-benchmark terms, even 2 vs 1 effects per row has been a serious hit. So splitting the spread into one effect per props layer won't land.
  • Atomicity. An element's attributes need to keep updating as one unit. With per-layer effects, a pending getter in one layer lets the other layers apply in the same frame, which breaks the whole-frame rule (F-1).
  • Size. It adds about +1 KB on the compiled CSR and hydrating rows, and we're holding hello world under 10 KB.
  • Smaller issues. It changes the order attributes are applied in, and the job CI step fails on a test-types error in the new spec.

Where the time actually goes, per your own write-up: the two compiled memo(() => !!isNativeButton()) conditions are rebuilt on every run of the single effect, and all 13 getters rerun through the merge/omit layers. A rework that keeps one effect per element would be very welcome. For example:

  • stop those conditions being rebuilt per run, whether in the compiler output or in how the view resolves them;
  • cut the per-run cost of walking merge/omit layers in spread()/assign().

That way the update win stays without paying in mount time, effect count or bytes.


Generated by Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

2 participants