perf(web): spread() creates fewer reactive nodes and accepts a sources array - #3419
Conversation
…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 detectedLatest commit: 238e0e6 The changes in this PR will be included in the next version bump. This PR includes changesets to release 11 packages
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 |
Merging this PR will improve performance by 20.12%
|
| 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)
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>
Coverage Report for CI Build 34811933233Warning Build has drifted: This PR's base is out of sync with its target branch, so coverage data may include unrelated changes. Warning No base build found for commit Coverage: 71.842%Details
Uncovered ChangesNo uncovered changes found. Coverage RegressionsRequires a base build to compare against. How to fix this → Coverage Stats
💛 - Coveralls |
…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>
…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>
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 serverssrElementarray form.reffolds 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.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 whosechildrenis 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 asssrElement'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
<el {...a} {...b}>once both runtimes are onnext.spread(yak-benchprim-elementlane).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 itscopyPropsobject /proxyPropsproxy (prim-propslane), against yak's hand-rolled runtime (pr),X / prgeomean over 14 cases: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.