Skip to content

perf(web): spread() creates fewer reactive nodes and accepts a sources array - #3419

Merged
ryansolid merged 2 commits into
nextfrom
perf/spread-fewer-nodes
Sep 14, 2026
Merged

ryansolid merged 2 commits into
nextfrom
perf/spread-fewer-nodes

Conversation

@ryansolid

@ryansolid ryansolid commented Sep 14, 2026

Copy link
Copy Markdown
Member

Closes #3388 (corrected target — see the last comment there).

What

spread() goes from three reactive nodes per element to two when children flow through the spread, one when they don't, and gains an array-of-sources form matching the server ssrElement array form.

  • ref folds into the attribute effect. It is collected with the other props in the compute half and re-applied in the commit half only when its identity differs from the last applied one. ref() runs the callback untracked with a null owner, so nothing a ref creates is owned by — or disposed with — the effect; that is what makes the fold safe.
  • Children stay in their own owned insert. That effect owns the child subtree (components, memos, effects created while the children getter runs are disposed when it reruns), so folding it into the attribute effect would tear down and rebuild the children on every attribute change. This is why the floor is two, not one. A plain object whose children is a data property inserts the value directly — no effect at all. Compiled JSX children are getters and keep the effect.
  • spread(el, [a, b], skipChildren, skip): union of own string keys, later sources win, only the winning source is read (a shadowed getter never runs), function sources are called inline in the compute half — no memo, no hydration id — skip(key) is never read nor applied. Same contract as ssrElement's array form (companion PR: feat(web): ssrElement accepts an array of prop sources and a skip predicate #3418). Single-source and lone-accessor behavior (fix(compiler): align lone spread hydration ids #3105, Solid 2: Nullish reactive JSX spreads crash rendering and halt updates #3297) unchanged.

Hydration

Web effect() is created transparent (shares the owner's id, consumes none), so dropping the ref effect shifts no sibling ids. The array form allocates exactly what a compiled double spread allocates: nothing beyond the element's own key. The two PRs are independent — nothing emits the array form until the compiler follow-up, which will emit both halves together.

Tests

test/spread-nodes.spec.tsx — 25 cases: ref applied once and only on identity change, ref surviving attribute reruns, children not rebuilt on attribute change, data-property children with no effect, getter children keep the effect, sources array later-wins / shadowed getter unread / function sources / nullish sources / skip / skipChildren, lone reactive accessor and nullish source (#3105, #3297), node-count assertions. Suites: dom 80/761, hydrate 29/174.

Follow-ups

  • Compilers (babel + oxc) emit the array form for <el {...a} {...b}> once both runtimes are on next.
  • Browser bench of next-yak's runtime on this spread (yak-bench prim-element lane).

Measured

yak-bench browser pass (Playwright Chromium), next-yak's runtime calling spread(el, [props, attrs, computed], skipChildren, skip) on this branch instead of building its copyProps object / proxyProps proxy (prim-props lane), against yak's hand-rolled runtime (pr), X / pr geomean over 14 cases:

before (object spread) after (sources array, this PR)
hydrate 1.05× 1.00×
mount 1.05× 0.98×
INP (re-render) 1.08× 0.92×

On the re-render path the dynamic/grid cases go from 10–20% behind the hand-written path to ahead (dyn-inline 1.15→0.76, product-grid 1.23→0.80, realistic-button 1.12→0.77). Hydrate/mount deltas are within run noise; the INP shift is consistent across all 14 cases. Every lane adopts the SSR DOM (jsdom adoption check) with no hydration warnings.

…s array (#3388)

`ref` folds into the attribute effect: it is collected with the other props
in the compute half and re-applied in the commit half only when its identity
differs from the last applied one. `ref()` runs the callback untracked with
no owner, so nothing a ref creates is disposed when the effect reruns; that
is what makes the fold safe.

Children keep their own owned `insert`. That effect owns the child subtree
(components, memos and effects created while the children getter runs are
disposed when it reruns), so merging it into the attribute effect would tear
the children down and rebuild them on every attribute change. A plain object
whose `children` is a data property inserts the value with no effect at all.
Three nodes become two when children flow through the spread, one when they
don't.

`spread(el, [a, b], skipChildren, skip)` takes an array of sources: the
union of own string keys with later sources winning, only the winning source
read, function sources called inline with no memo and so no hydration id,
`skip(key)` never read nor applied. Same contract as the server `ssrElement`
array form. Web effects are transparent for hydration ids, so dropping the
ref effect shifts no sibling ids.

Co-authored-by: Cursor <cursoragent@cursor.com>
@changeset-bot

changeset-bot Bot commented Sep 14, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 238e0e6

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

This PR includes changesets to release 11 packages
Name Type
@solidjs/web Patch
@solidjs/babel-plugin Patch
@solidjs/element Patch
@solidjs/h Patch
@solidjs/html Patch
test-integration Patch
@solidjs/compiler Patch
@solidjs/diagnostics 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 Sep 14, 2026

Copy link
Copy Markdown

Merging this PR will improve performance by 20.12%

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

⚡ 2 improved benchmarks
✅ 158 untouched benchmarks

Performance Changes

Benchmark BASE HEAD Efficiency
spread plain object × 500 10.3 ms 8.4 ms +22.93%
spread merge(static, reactive) × 500 16.7 ms 14.2 ms +17.37%

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/spread-fewer-nodes (238e0e6) with next (c66130d)

Open in CodSpeed

HTMLAttributes.children is JSX.Element; insert() resolves a function at
runtime, the type does not. test-types was failing on it.

Co-authored-by: Cursor <cursoragent@cursor.com>
@coveralls

Copy link
Copy Markdown

Coverage Report for CI Build 34811933233

Warning

Build has drifted: This PR's base is out of sync with its target branch, so coverage data may include unrelated changes.
Quick fix: rebase this PR. Learn more →

Warning

No base build found for commit 14ded24 on next.
Coverage changes can't be calculated without a base build.
If a base build is processing, this comment will update automatically when it completes.

Coverage: 71.842%

Details

  • Patch coverage: No coverable lines changed in this PR.

Uncovered Changes

No uncovered changes found.

Coverage Regressions

Requires a base build to compare against. How to fix this →


Coverage Stats

Coverage Status
Relevant Lines: 1007
Covered Lines: 772
Line Coverage: 76.66%
Relevant Branches: 790
Covered Branches: 519
Branch Coverage: 65.7%
Branches in Coverage %: Yes
Coverage Strength: 15.01 hits per line

💛 - Coveralls

@ryansolid
ryansolid merged commit 7f6332a into next Sep 14, 2026
6 checks passed
ryansolid added a commit that referenced this pull request Sep 14, 2026
…urce element spreads (#3423)

Native elements with several spread sources compile to the runtimes' array
form instead of a mergeProps() call, in DOM and SSR output, in both the
Babel plugin and the native compiler:

  <div id="x" {...a} {...b}>
  → spread(el, [{ id: "x" }, a, b], skipChildren)          (dom)
  → ssrElement("div", [{ id: "x" }, a, b], children, id)   (ssr)

The runtimes read the sources directly — later sources win per key, only
the winning source is read — so there is no merge proxy to build and walk
once. A reactive spread is a plain thunk called inside the tracking scope:
no memo, so no hydration id on either side. That also retires the
hydratable SSR `() => mergeProps(...)` wrapper, which existed only to order
the merge memo's id after the element's key; a lone dynamic spread left
behind once the server drops on*/prop:/children keys now passes through as
the props thunk itself. A lone spread still passes straight through
(#3105). Universal output is unchanged; its runtime spread has no array
form yet.

Requires @solidjs/web with the array forms (#3418, #3419). Fixtures
regenerated for both compilers; the recorded cross-mode Babel/Oxc
divergences changed only in context (mergeProps → array).

Co-authored-by: Cursor <cursoragent@cursor.com>
ryansolid added a commit that referenced this pull request Sep 14, 2026
…ay; compilers emit it (#3424)

Port of @solidjs/web's spread (#3388, #3419) to the universal renderer:

- `ref` folds into the props effect and is re-applied only when its
  identity changes. `ref()` runs the callback untracked with no owner, so
  nothing a ref creates is disposed when the effect reruns.
- Children keep their own owned `insert` — that effect owns the child
  subtree, and merging it would rebuild the children on every prop change.
  A plain object whose `children` is a data property inserts the value with
  no effect at all. Three nodes become two with children through the
  spread, one without.
- A lone function source resolves inside each tracking scope; an ARRAY of
  sources is the union of their keys, later sources winning, only the
  winning source read, function sources called inline with no merge and no
  memo. Nullish sources are empty. Keys are still enumerated with for...in
  so a renderer's proxy props answer through their traps.

Both compilers' universal generate follows the dom generate: a lone spread
passes straight through (reactive included — the `mergeProps(() => expr)`
wrap that existed only because the runtime could not resolve a function is
gone), and several sources compile to the array instead of a mergeProps()
call. Universal has no hydration ids, so the runtime and compiler halves
land together.

Co-authored-by: Cursor <cursoragent@cursor.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