Skip to content

perf(signals): shared traps for merge()/omit() proxies, state on the target - #3403

Closed
ryansolid wants to merge 1 commit into
nextfrom
perf/shared-prop-traps
Closed

ryansolid wants to merge 1 commit into
nextfrom
perf/shared-prop-traps

Conversation

@ryansolid

Copy link
Copy Markdown
Member

Port of #3391 (main, mergeProps/splitProps) to the 2.0 primitives in @solidjs/signals.

What changes

Each merge() / omit() proxy used to allocate a target holding three fresh closures (get/has/keys) over that instance's sources, with the shared propTraps forwarding every trap call through them. Now the per-instance state lives on the target under symbol keys — { [$SOURCES]: flattened } for merge, { [$OMIT_PROPS]: props, [$OMIT_KEYS]: keys } for omit — and each primitive has one shared handler that reads it directly.

// before: 1 proxy + 1 target + 3 closures per instance, trap → closure → sources
new Proxy({ get(p) {}, has(p) {}, keys() {} }, propTraps)
// after: 1 proxy + 1 one/two-slot object, trap → sources
new Proxy({ [$SOURCES]: flattened }, mergeTraps)

Semantics are unchanged: same right-to-left in lookup, same has/ownKeys filtering, $SOURCES still answers the flat list on a merge proxy (what mergeSources()/spread() read) and undefined on an omit proxy (#3014). The state keys are never reported by ownKeys nor answered by get/has, so they're invisible through the proxy; a string Object.defineProperty on the proxy lands on the target without touching them (before, defining "get" would have overwritten the target's method and broken the proxy). Proxy invariants hold: the target is extensible and its state keys configurable.

Tests

New test: state symbols not visible, Object.defineProperty(merged, "sources", …) / (rest, "props", …) don't affect reads or in/keys, live reads after a store write, descriptor getters work. Existing #3014 test and the whole merge/omit suite unchanged. Suites: signals 1768, solid 595, html 200, web dom 735 / server 795 / hydrate 169.

Performance

Measured on battery, so only directions are reliable (±20% run-to-run). Interleaved A/B, old vs new dist/prod in one process, 31 alternating rounds:

path ratio new/old
retained 200-instance omit(merge(defaults, store), "id") batch 0.87 / 0.93 (two runs)
nested merge(mergeProxy, {x}) 0.65 / 0.75
Object.keys / {...proxy} 0.90–0.98
single construct, single reads, in within noise (process-isolated alternating runs show no regression)

A computed-symbol literal costs ~13 ns vs ~46 ns for the three-closure literal in isolation, so the construct win is real but small next to the Proxy itself; the retained batch is where it shows. CodSpeed's merge-proxy-keys / omit-proxy construct+read benches in utilities.bench.ts cover this on the PR.

…target

Each merge()/omit() proxy allocated a target holding three fresh closures
(get/has/keys) over the instance's sources and forwarded every trap call
through them. Keep the sources/keys on the target under symbol keys and
share one handler per primitive instead: a props proxy is a Proxy plus a
one- or two-slot object, and a read goes straight from the trap to the
sources. The state keys are never reported by ownKeys nor answered by
get/has; a string defineProperty on the proxy lands on the target without
touching them (previously it could overwrite the target's `get` method).

Behaviour is unchanged: `$SOURCES` still answers the flat list on a merge
proxy (mergeSources/spread) and undefined on an omit proxy (#3014); the
has/ownKeys filtering is the same. Port of #3391 (main) to next.

Interleaved A/B, old vs new dist in one process, on battery (±20% noise):
retained 200-instance merge+omit batch 0.87–0.93×, nested merge over a
merge proxy 0.65–0.75×, Object.keys / spread 0.90–0.98×; single
construct and reads within noise in process-isolated alternating runs.

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

changeset-bot Bot commented Sep 13, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 833cbdd

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

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

@coveralls

Copy link
Copy Markdown

Coverage Report for CI Build 34730721456

Coverage remained the same at 71.842%

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: 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.02 hits per line

💛 - Coveralls

@codspeed-hq

codspeed-hq Bot commented Sep 13, 2026

Copy link
Copy Markdown

Merging this PR will degrade performance by 24.76%

⚠️ 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
❌ 9 regressed benchmarks
✅ 150 untouched benchmarks

Warning

Please fix the performance issues or acknowledge them on CodSpeed.

Performance Changes

Benchmark BASE HEAD Efficiency
merge 274.5 µs 2,357.8 µs -88.36%
construct 34.8 µs 40.9 µs -14.74%
construct 34.8 µs 40.6 µs -14.33%
construct 37.7 µs 43.6 µs -13.47%
construct 46.4 µs 52.1 µs -11%
construct 56.6 µs 62.3 µs -9.15%
construct 56.8 µs 62.3 µs -8.84%
construct 57 µs 62.3 µs -8.46%
construct 58.6 µs 63.4 µs -7.56%
merge 109 µs 86 µs +26.69%

Tip

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


Comparing perf/shared-prop-traps (833cbdd) with next (084e621)

Open in CodSpeed

@ryansolid

Copy link
Copy Markdown
Member Author

Closing: the premise doesn't hold up under CodSpeed. Against `next`, every shape this was meant to speed up regresses — `omit-proxy-store` construct −11% to −15%, `merge-proxy-keys-store` construct −7.5% to −9%, and `merge-merge-signal(deep 0,100,3,2)` at −88% (275 µs → 2.36 ms). The one win (`merge-static(0,100)` +27%) is on a path this PR doesn't touch, so it's noise. The local wall-clock A/B in the description was measured on battery at ±20%, which is wider than the effect it was trying to detect; the instruction-count numbers are the ones to trust here.

The plausible reason: three closures over locals in a fresh object literal are cheap and monomorphic, while symbol-keyed state read off `target` inside a shared handler adds a keyed load per trap and turns the target's shape into something V8 handles worse than the closure literal. The nested-merge outlier suggests the flatten path over `$SOURCES` became a slow-property read on every hop.

If someone wants to revisit, it needs to start from a CodSpeed-visible win, not a local timing run.

Claude via Cursor

@ryansolid ryansolid closed this Sep 13, 2026
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