Skip to content

perf(web,signals): spread() reads a merge() proxy through its sources - #3325

Merged
ryansolid merged 3 commits into
nextfrom
perf/spread-direct-sources
Sep 10, 2026
Merged

ryansolid merged 3 commits into
nextfrom
perf/spread-direct-sources

Conversation

@ryansolid

@ryansolid ryansolid commented Sep 9, 2026

Copy link
Copy Markdown
Member

Rebased over #3326. What remains here is the one piece #3326 did not cover: reading a merge() proxy through its flattened sources rather than through the proxy.

A spread mixed with other attributes compiles to spread(el, merge(statics, () => rest)). On next, that source now costs one ownKeys trap — but for a merge proxy the trap is merge's keys(): a Set plus an own-enumerable scan (Reflect.ownKeys + propertyIsEnumerable filter) of every source; then every key's get is a right-to-left in walk of the sources. Iterating the sources directly removes all of it: own string keys per source, later sources overriding earlier (merge's own contract), children/ref excluded, each source enumerated through the same single-trap ownKeys helper readShallow() uses. omit() is not a merge and stays opaque (enumerated through its own filtering trap).

Dropped from the original commit as subsumed by #3326: the style() proxy branch (style() now receives a plain copy from readShallow), the collectProps per-source descriptor-trap fix (now the shared ownKeys()), and the spread-style bench (the merge(static, reactive) row of spread-enumerate.bench.tsx is the guard).

Semantics pinned in spread-sources.spec.tsx: later-wins, children/ref/symbols excluded, omit() inside a merge and alone, store key add/remove tracked, whole-object replace, own-properties-only. One documented contract: own keys per source — a key an earlier source owns and a later source merely inherits resolves to the earlier source (the proxy's in walk saw the inherited one); spread has always applied own properties only.

Size: ~+50 B on the app scenarios vs next; all limits hold.

Perf (Tier-1 spread-enumerate, 500 elements × 8 keys, median of 3 runs each, paired on the same machine):

source next (after #3326) this PR Δ
merge(static, reactive) 602 ops/s 1,243 ops/s +107%
store record (untouched path) 427 ops/s 478 ops/s +12% (drift; same code)
plain object (untouched path) 1,462 ops/s 1,599 ops/s +9% (drift; same code)

The merge row is the change; it now sits within ~25% of the plain-object floor. The two untouched rows moved together by ~10%, which is machine drift between the paired runs, not the patch — the merge row's 2× is far outside it.

@changeset-bot

changeset-bot Bot commented Sep 9, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: e01022e

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/signals Patch
solid-js Patch
@solidjs/babel-plugin Patch
@solidjs/element Patch
@solidjs/h Patch
@solidjs/html Patch
test-integration Patch
@solidjs/universal Patch
@solidjs/compiler Patch
@solidjs/diagnostics 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 9, 2026

Copy link
Copy Markdown

Merging this PR will improve performance by 25.37%

⚠️ 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
✅ 149 untouched benchmarks

Performance Changes

Benchmark BASE HEAD Efficiency
spread merge(static, reactive) × 500 24.4 ms 16.6 ms +47.18%
spread store record × 500 35.2 ms 33 ms +6.8%

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-direct-sources (e01022e) with next (3a5fe8c)

Open in CodSpeed

@ryansolid
ryansolid marked this pull request as draft September 9, 2026 20:04
@ryansolid

Copy link
Copy Markdown
Member Author

Parking as draft: while writing the tests, style={store.obj} turned out to be identity-reactive only — style() (and className() for object values) enumerate the live object in the effect callback, so per-key reads are untracked (in-place store mutations don't apply; STRICT_READ_UNTRACKED fires in dev). Fixing that moves enumeration into the tracked half (a compute-side snapshot, as spread() already does), which is where this PR's style() change then belongs. Sequencing: the tracked-read fix first, with its cost model measured on the new Tier-1 bench, then this rebased on top. The spread half is unaffected.

Claude via Cursor

A spread mixed with other attributes compiles to
`spread(el, merge(statics, () => rest))`. Going through the merge proxy
costs merge's `keys()` (a Set plus an own-enumerable scan of every source)
and then, per key, a right-to-left `in` walk of the sources, every run. The
spread now iterates the flattened sources directly — own string keys, later
sources overriding earlier, children/ref excluded — and enumerates each
source through the same single-trap `ownKeys` path readShallow() uses
(#3326). omit() is not a merge and stays opaque.

Rebased over #3326, which landed the shared proxy enumeration and the
tracked style/class read; the style() proxy branch and the per-source
descriptor-trap fix from the original commit are subsumed by it and dropped
here, as is the duplicate spread-style bench (spread-enumerate's merge row is
the guard). @solidjs/signals gains an @internal mergeSources().

Co-authored-by: Cursor <cursoragent@cursor.com>
@ryansolid
ryansolid force-pushed the perf/spread-direct-sources branch from f1e297c to 7ad9354 Compare September 10, 2026 01:00
@ryansolid ryansolid changed the title perf(web,signals): spread() and style() read proxy inputs directly — no per-key descriptor traps perf(web,signals): spread() reads a merge() proxy through its sources Sep 10, 2026
ryansolid and others added 2 commits September 9, 2026 21:18
Copy-on-write for the array branch bought identity passthrough for
proxy-free class arrays at the cost of a slice/push loop. className()
already allocates two objects per array value, so the one extra array from
value.map(readShallow) is invisible: paired Tier-1 bench, 500 elements with
class={[...]}, 1,981 vs 1,981 ops/s. −243 B raw / −58 B brotli on web.js.

Co-authored-by: Cursor <cursoragent@cursor.com>
merge() has two output forms. The proxy form's writes are no-ops, so its
sources are the whole truth. The plain-object form (all sources plain)
also records $SOURCES so nested merges flatten — but it is a real object
callers mutate afterwards: @solidjs/html builds `props = merge(props,
spread)` and then assigns later props onto the result. Reading that form
through its sources missed those own writes (html: "handles multiple
spread attributes with complex override behavior" — class stayed
"override" instead of "final"). mergeSources() now returns undefined
unless the object is the proxy itself; spread() reads the plain form
directly, as before. Pinned in spread-sources.spec with html's shape.

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

Copy link
Copy Markdown

Coverage Report for CI Build 34437797850

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 →

Coverage remained the same at 71.851%

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: 1006
Covered Lines: 771
Line Coverage: 76.64%
Relevant Branches: 788
Covered Branches: 518
Branch Coverage: 65.74%
Branches in Coverage %: Yes
Coverage Strength: 15.0 hits per line

💛 - Coveralls

@ryansolid
ryansolid marked this pull request as ready for review September 10, 2026 05:11
@ryansolid
ryansolid merged commit 4e730a9 into next Sep 10, 2026
7 checks passed
@ryansolid
ryansolid deleted the perf/spread-direct-sources branch September 10, 2026 05:12
ryansolid added a commit that referenced this pull request Sep 15, 2026
…internal

`merge()`/`omit()` returning lazy views (#3454) gave `spread()` and
`ssrElement()` a protocol for reading props leaf by leaf instead of trapping
through the proxy per key. `@solidjs/web` and `@solidjs/universal` depend on
`solid-js` alone — never on `@solidjs/signals` directly, so an app holds
exactly one reactive engine — so every piece of that protocol went out through
`solid-js`'s main export: eleven names, `mergeSources` before them in #3325,
on the public surface with no marking. None of it is API.

`solid-js/internal` is now their home: the view protocol (`viewOf`,
`mergeView`/`omitView`, `MergeView`/`OmitView`, `sourceKeys`/`sourceHas`/
`sourceGet`, `hasStaticKeys`, `resolvedTable`, the `SOURCE_*` kinds), and the
server-scope seams that were already `@internal` in JSDoc and consumed only by
`@solidjs/web` (`ssrHandleError`, `ssrScope`, `runInServerComponentScope`,
`inServerComponentScope`, `creationStamp`, `getProjectionTrace`,
`materializeContainerTrace`).

The names stay exported from the main entries AT RUNTIME, so the subpath
shares one module state and the single-engine guarantee is untouched; they
carry `@internal` and `stripInternal` keeps them out of the generated
declarations, which is what makes them not-public for TypeScript. The protocol
half re-exports `@solidjs/signals` as-is; the seams are read back from
"solid-js" (external, so the platform/tier conditions pick the same build the
app runs) and declared with their signatures spelled out, since the main
entries no longer type them.

Dropped from the entries as dead: `storeIsShallow`, `storeHasFamily`,
`storeHasOptimisticFamily` (leftovers of the gutted patch channel),
`storePath` and `$REFRESH` (referenced only by `@solidjs/signals`'s own
internals), `NoHydrateContext` (`@internal`, used only by `solid-js`'s server
code). Nothing in the repo consumed them.

internal-surface.spec.ts pins the boundary in both directions — no internal
name in either main entry's declarations, all of them in internal.d.ts — which
is the check that would have caught #3454 leaking eleven of these.

No runtime behavior change. Size, measured on real app bundles: the client is
byte-identical raw and 13 bytes smaller gzipped (the seams tree-shake out
entirely, so the namespace import costs nothing), the server is +24 gzipped
from two alias consts the typed declarations require. solid-js's own entries
shrink 122/141 gzipped bytes from the dead exports.

Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
ryansolid added a commit that referenced this pull request Sep 15, 2026
…internal (#3470)

* chore(solid,web,universal): the runtimes' seams move behind solid-js/internal

`merge()`/`omit()` returning lazy views (#3454) gave `spread()` and
`ssrElement()` a protocol for reading props leaf by leaf instead of trapping
through the proxy per key. `@solidjs/web` and `@solidjs/universal` depend on
`solid-js` alone — never on `@solidjs/signals` directly, so an app holds
exactly one reactive engine — so every piece of that protocol went out through
`solid-js`'s main export: eleven names, `mergeSources` before them in #3325,
on the public surface with no marking. None of it is API.

`solid-js/internal` is now their home: the view protocol (`viewOf`,
`mergeView`/`omitView`, `MergeView`/`OmitView`, `sourceKeys`/`sourceHas`/
`sourceGet`, `hasStaticKeys`, `resolvedTable`, the `SOURCE_*` kinds), and the
server-scope seams that were already `@internal` in JSDoc and consumed only by
`@solidjs/web` (`ssrHandleError`, `ssrScope`, `runInServerComponentScope`,
`inServerComponentScope`, `creationStamp`, `getProjectionTrace`,
`materializeContainerTrace`).

The names stay exported from the main entries AT RUNTIME, so the subpath
shares one module state and the single-engine guarantee is untouched; they
carry `@internal` and `stripInternal` keeps them out of the generated
declarations, which is what makes them not-public for TypeScript. The protocol
half re-exports `@solidjs/signals` as-is; the seams are read back from
"solid-js" (external, so the platform/tier conditions pick the same build the
app runs) and declared with their signatures spelled out, since the main
entries no longer type them.

Dropped from the entries as dead: `storeIsShallow`, `storeHasFamily`,
`storeHasOptimisticFamily` (leftovers of the gutted patch channel),
`storePath` and `$REFRESH` (referenced only by `@solidjs/signals`'s own
internals), `NoHydrateContext` (`@internal`, used only by `solid-js`'s server
code). Nothing in the repo consumed them.

internal-surface.spec.ts pins the boundary in both directions — no internal
name in either main entry's declarations, all of them in internal.d.ts — which
is the check that would have caught #3454 leaking eleven of these.

No runtime behavior change. Size, measured on real app bundles: the client is
byte-identical raw and 13 bytes smaller gzipped (the seams tree-shake out
entirely, so the namespace import costs nothing), the server is +24 gzipped
from two alias consts the typed declarations require. solid-js's own entries
shrink 122/141 gzipped bytes from the dead exports.

Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(size): route solid-js/internal in the size scenarios

esbuild's `alias` matches by PREFIX, so the bare `solid-js` entry remapped
`solid-js/internal` to `.../dist/solid.js/internal` and the prod and observe
scenarios failed to resolve — the same trap this file already documents for
`solid-js/attribution`. Subpath alias listed first in both maps, and added to
the frames scenario's `external` (its client entry imports the seam).

Two caps move, both measured against `next` on the same machine:

- frames 11.40 -> 11.45 KB: 11442 B against 11370 (+72 brotli on +31
  minified). Nothing in that bundle changed but one import's specifier —
  `materializeContainerTrace` used to fold into the single `from "solid-js"`
  statement and is now its own `from "solid-js/internal"` statement.
- hydrating + store family 28.90 -> 28.95 KB: 28901 B against 28861 (+40).
  Mangler noise: the minified bundle is byte-identical (89072 B both sides)
  and differs only in which short names the minifier hands out, the extra
  module boundary having shifted its allocation. The same swap compresses
  simple-app -33, hydrating -45 and CSR -21; this one came out 1 B over a
  cap #3459 had just used the room under.

The other seven scenarios are unchanged or smaller.

Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: Cursor <cursoragent@cursor.com>

---------

Co-authored-by: Claude <noreply@anthropic.com>
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