Skip to content

perf: reduce props proxy closure allocations - #3391

Draft
DaniGuardiola wants to merge 2 commits into
solidjs:mainfrom
DaniGuardiola:perf/shared-prop-handlers
Draft

DaniGuardiola wants to merge 2 commits into
solidjs:mainfrom
DaniGuardiola:perf/shared-prop-handlers

Conversation

@DaniGuardiola

@DaniGuardiola DaniGuardiola commented Sep 12, 2026

Copy link
Copy Markdown
Contributor

Summary

Replace per-instance proxy target closures in mergeProps and splitProps with private-symbol data read by shared handlers. Proxy counts and public types stay unchanged. Local benchmarks showed 19–46% lower median creation time across four runs.

What changes?

The proxy handlers are already shared today. However, each output proxy's target contains three newly created methods (get, has, keys) closing over that instance's data. This change stores the data on the target and moves that work into shared functions.

Simplified target construction:

// Before: fresh methods closing over sources for every instance.
new Proxy({
  get(key) { return lookup(sources, key); }
  // Fresh has() and keys() methods too.
}, propTraps);

// After: instance data only; shared handlers read target[sourcesKey].
new Proxy({ [sourcesKey]: sources }, mergeHandler);

For 10,000 merge instances, both versions allocate 10,000 proxies and targets, but the new version avoids 30,000 instance-specific methods. Reads also skip the forwarding call from handler to target method. splitProps uses the same approach for source, selected keys, and remainder state.

Private symbols prevent Object.defineProperty on names such as source or sources from overwriting internal state. The regression test covers that isolation, symbol visibility, and live reads after store updates.

Performance

Local comparison on Apple M4 Pro, Node 25.9.0 / V8 14.1, Vitest 4.1.10 with jsdom. Four fresh processes, alternating implementation order. Each operation handles 10,000 retained instances backed by distinct real stores; source setup is outside timing. Reads access four fields per instance.

Times are milliseconds per batch, reported as the median of four per-run means:

Operation Before After Less time
mergeProps creation 0.7132 0.5748 19.4%
splitProps creation 1.9326 1.1410 41.0%
merge → split creation 2.9793 1.6095 46.0%
mergeProps reads 3.9004 3.8863 0.4%
splitProps reads 4.1958 4.0641 3.1%
merge → split reads 5.1147 4.8254 5.7%

Creation improved in every paired run, but reductions varied substantially: 14–40% for merge, 13–54% for split, and 13–56% for the chain. Read results were less consistent, including one noisy merge-read result (8.36 ms, ±23% RME) that did not recur. These are exploratory local measurements, not browser-wide performance guarantees; allocation, GC, and engine optimization effects have not been separated.

The PR includes the six retained-instance benchmarks for reproducing the workload across revisions; the local comparison harness and saved results are not included.

How did you test this change?

  • pnpm run build
  • pnpm test — 492 runtime tests passed, plus type, Babel preset, and import checks.
  • pnpm --dir packages/solid exec vitest bench test/props-retained.bench.ts --run — all 6 cases passed.

@changeset-bot

changeset-bot Bot commented Sep 12, 2026

Copy link
Copy Markdown

⚠️ No Changeset found

Latest commit: 0e2c009

Merging this PR will not cause a version bump for any packages. If these changes should not result in a new version, you're good to go. If these changes should result in a version bump, you need to add a changeset.

This PR includes no changesets

When changesets are added to this PR, you'll see the packages that this PR includes changesets for and the associated semver types

Click here to learn what changesets are, and how to add one.

Click here if you're a maintainer who wants to add a changeset to this PR

ryansolid added a commit that referenced this pull request Sep 13, 2026
…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>
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.

1 participant