Skip to content

perf(compiler): SSR props literals with getters compile to hoisted constructors (hoistProps) - #3550

Open
ryansolid wants to merge 1 commit into
nextfrom
perf/ssr-props-constructors
Open

ryansolid wants to merge 1 commit into
nextfrom
perf/ssr-props-constructors

Conversation

@ryansolid

Copy link
Copy Markdown
Member

What

With hoistProps (default on) both compilers emit a component's props literal that has getters as a module-level constructor instead of an object literal:

// before (per instance: dictionary-mode object + a closure per getter)
createComponent(Button, { get active() { return sig(); }, variant: "ghost", children: i })

// after (per instance: one fast-mode object with symbol slots; getters shared on the prototype)
const _p$ = Symbol(); function _P$(_p) { this[_p$] = _p; this.variant = "ghost"; ... }
Object.defineProperty(_P$.prototype, "active", { get() { return this[_p$](); }, enumerable: true, configurable: true });
...
createComponent(Button, new _P$(sig))

Own keys, order, descriptors and prototype are unchanged, so Object.keys, spread, hasOwn, isStatic() and hasStaticKeys() answer as before. Sites that close over a reassigned or later-declared binding, this/super/arguments, or sit at module level keep the literal. Babel (src/ssr/props.ts) and the native compiler (src/ssr/props.rs) emit byte-identical output.

Tracker: #3389 · design/issue: #3511

The contract that changes

A props getter is defined only for a read through its own object. Copying its descriptor onto another object (Object.defineProperty(target, k, Object.getOwnPropertyDescriptor(props, k))) no longer works — the getter reads its capture through the receiver. In dev the getter throws naming the rule.

Measured

yak-bench, SSR in-process renderToString, instances/sec, native compiler, mprim lane (Solid primitives), hoistProps on vs off, same run alternated. Round 1 is the clean pair; round 2 was disturbed mid-run (react control dropped 30% on tabs), so the two-round average is shown as the conservative figure.

case off → on (round 1) 2-round avg
polymorphic-chain 1.31× 1.12×
product-grid 1.25× 1.09×
multifile-composition 1.27× 1.20×
multifile-shop 1.29× 1.23×
realistic-button 1.19× 1.09×
tabs 1.15× 1.15×
dyn-inline 1.46× 1.47×
btn-variant, dyn-fair, dyn-translate, button-variants*, compose-* (no getter literal on the path) 0.98–1.04× 0.88–1.13×
geomean (15) 1.12× 1.09×

Octane ssr-throughput (news-50/500) and streaming-ssr Solid fixtures: flat on/off — element-heavy, no getter literals on the hot path — so no regression on the board.

Against yak's own hand-rolled merged runtime (last clean rc.8 run, same machine, indicative): mprim + hoisting now leads on every multi-component page (polymorphic-chain 1.49×, product-grid 1.42×, multifile-composition 1.27×, tabs 1.24×) and trails on the single-styled-element micro-cases (btn-variant / dyn-fair / dyn-translate 0.57×, realistic-button 0.86×).

Merge gate

That last row is the honest cost of the contract: once descriptor copying is off the table, a library like yak has to come through merge()/ssrElement, and today those trail their eager copy on the micro-cases. A profile of btn-variant puts the gap entirely in the runtime (props view ~40%, ssrElement multi-source path ~23%, attribute-name escaping ~13%, server memo owner ~10%); none of it is the compiler. That runtime work is in progress with btn-variant parity against merged as the exit criterion. hoistProps should not default on in a published rc before that lands; if it doesn't make the same rc, flip the default to false in config.ts / config.rs before release.

Tests

  • packages/babel-plugin/test/ssr-props.spec.js — hoisting rules, capture rules, name generation, and execution of the emitted code (getter semantics, own-key parity, dev receiver error).
  • Fixture outputs updated in both compilers; the compiler parity suite covers the byte-identical output.
  • pnpm test in packages/babel-plugin (264) and packages/compiler (5785) green.

…nstructors (hoistProps)

A component's props literal with getters — the compiler's own `{ get x() {
return sig() }, ... }` — is built by V8 in dictionary mode with a closure
per getter per instance. With `hoistProps` (default on) both compilers emit
a module-level constructor whose getters live on the prototype and read
their captures through symbol slots on the instance; the call site becomes
`new _P$(a, b)`. Own keys, order, descriptors and prototype are unchanged,
so `Object.keys`, spread, `hasOwn`, `isStatic()` and `hasStaticKeys()`
answer as before.

The contract that changes: a props getter is defined only for a read
through its own object. Copying its descriptor onto another object
(`Object.defineProperty(target, k, getOwnPropertyDescriptor(props, k))`)
no longer works; in dev the getter throws naming the rule. Our own
primitives never copy descriptors except omit()'s no-Proxy fallback, fixed
in #3544 to re-home accessors.

Sites the pass leaves as literals: getters that close over a binding that
is reassigned or declared after the site, `this`/`super`/`arguments`, or a
module-level site (nothing to hoist above). Compiled template temps that
belong to a single getter move into it.

Babel (`src/ssr/props.ts`) and the native compiler (`src/ssr/props.rs`)
emit byte-identical output, including Babel's `_p`, `_p2`…`_p9`, `_p0`,
`_p1`, `_p10` parameter sequence; the native compiler additionally marks
the IIFE it generates for module-level `this` capture as transparent to
the depth rule, which Babel does not need.

Measured (yak-bench, SSR CPU per render, native compiler, mprim lane):
component-heavy pages +15–31%, geomean 1.12×; Octane ssr-throughput and
streaming-ssr flat (element-heavy fixtures, no getter literals on the hot
path).

Co-Authored-By: Claude via Cursor <cursoragent@cursor.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
@changeset-bot

changeset-bot Bot commented Sep 18, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 0f90eae

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

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

Coverage remained the same at 71.366%

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: 1035
Covered Lines: 783
Line Coverage: 75.65%
Relevant Branches: 802
Covered Branches: 528
Branch Coverage: 65.84%
Branches in Coverage %: Yes
Coverage Strength: 15.52 hits per line

💛 - Coveralls

@codspeed

codspeed Bot commented Sep 18, 2026

Copy link
Copy Markdown

Merging this PR will degrade performance by 9.95%

⚠️ 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 regressed benchmark
✅ 171 untouched benchmarks
⏩ 3 skipped benchmarks1

Warning

Please fix the performance issues or acknowledge them on CodSpeed.

Performance Changes

Benchmark BASE HEAD Efficiency
polymorphic-chain: 200 rows (renderToString): chain 20.7 ms 23 ms -9.95%

Tip

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


Comparing perf/ssr-props-constructors (0f90eae) with next (49887e1)

Open in CodSpeed

Footnotes

  1. 3 benchmarks were skipped, so the baseline results were used instead. If they were deleted from the codebase, click here and archive them to remove them from the performance reports.

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