Compiler: props literals with accessors are the largest SSR self-time bucket on composition cases (computed keys + closure-per-getter)
Nobody has claimed this yet.
- Dominant language
- TypeScript
- Stars
- 36.1k
- Forks
- 1.1k
- Avg merge
- 9h 18m
- Merged PRs (30d)
- 195
Description
Status (Sep 17): finding 1 landed in #3514. Finding 2: options (a)/(b) below are withdrawn — prototype getters break
{...props}/destructuring in reactive expressions, which is supported. The plan is (c) re-measured: per-site class behind aProxy, SSR generate only, with a literal fallback for sites whose captured bindings aren't provably constant. Verdict, microbenchmarks, and the measured fallback rate (≤0.4% library / 2–4% apps): https://github.com/solidjs/solid/issues/3511#issuecomment-5719473252
Tracking item 6 of #3389. Profiling the composition cases in yak-bench (polymorphic-chain, tabs) after #3497/#3509 shows the compiled component code itself — not merge/omit, not the serializer — as the largest single self-time bucket on the server: 24–26% of SSR time on both cases, and ~18% of allocation. Almost all of it is the props literal the compiler emits at each component call site.
Exhibit
ButtonRoot from polymorphic-chain, as compiled (SSR, @solidjs/compiler; babel output is identical by parity):
function ButtonRoot(props) {
const merged = merge({ type: "button", disabled: false, variant: "solid" }, props);
const others = omit(merged, "as", "type", "disabled", "loading", "variant");
return Polymorphic(merge({
get as() { return merged.as ?? "button"; },
get type() { return memo(() => merged.as === "a")() ? void 0 : merged.type; },
get disabled() { return merged.disabled; },
get ["aria-disabled"]() { return merged.disabled || void 0; },
get ["data-disabled"]() { return merged.disabled ? "" : void 0; },
get ["data-loading"]() { return merged.loading ? "" : void 0; },
get ["data-variant"]() { return merged.variant; }
}, others));
}
Every render of every ButtonRoot allocates seven closures and an accessor-bearing object literal. React's equivalent is { ...rest, type, disabled, "aria-disabled": ... } — one plain object.
What it costs
Microbenchmark of exactly this shape (7 getters over a captured merged; build the object, read all 7 keys once — what ssrElement does), min of 7 × 200k, Node 26:
| form | TurboFan | --no-opt |
retained |
|---|---|---|---|
| literal, computed keys — today | 966 ns | 715 ns | 1274 B |
literal, get "aria-disabled"() (string-literal keys) |
525 ns | 485 ns | 1096 B |
| literal, all identifier keys | 482 ns | 482 ns | 1096 B |
own accessors, shared getters via Object.defineProperties |
869 ns | — | 400 B |
| per-site class, getters on the prototype | 66 ns | 68 ns | 40 B |
| plain object (React's shape) | 46 ns | 45 ns | 88 B |
Two independent findings in that table.
1. Computed keys cost 45% on their own — pure codegen bug, zero semantics
Both compilers emit non-identifier keys as computed accessors:
packages/babel-plugin/src/shared/component.ts—t.objectMethod("get", id, [], body, !t.isValidIdentifier(key)), three sites around L286–L318 (alsossr/element.tsL1041,universal/element.tsL457).packages/compiler/src/shared/ast.rsL220 — mirrors it deliberately: "Babel: … non-identifier getter keys are computed (get ["hyphen-ated"]())".
get ["aria-disabled"]() and get "aria-disabled"() are the same property, but a computed key in an object literal drops V8 off the literal boilerplate path into DefineKeyedOwnPropertyInLiteral per property. Every aria-*, data-*, and class prop pays it. Fix is to pass computed: false with a string-literal key (Babel accepts objectMethod("get", stringLiteral, …, false); oxc PropertyKey::StringLiteral likewise). Fixtures pinning the computed form: 14 files under packages/babel-plugin/test/__*_fixtures__/, plus packages/compiler/__tests__/fixtures/** and one expected-cross parity diff.
This is a small PR with no design question in it.
2. The accessor literal is structural — 7× time, 27× memory vs prototype getters
Even with identifier keys, a 7-getter literal is ~480 ns / 1.1 KB because each render allocates 7 closures plus an object whose accessor properties can't share a boilerplate. A per-call-site hidden class whose getters live on the prototype and read the captured scope through instance fields gets it to 66 ns / 40 B — within 1.5× of React's plain object, with the laziness intact:
// emitted once per call site
class ButtonRoot$props1 {
constructor($0) { this.$0 = $0; }
get as() { return this.$0.as ?? "button"; }
get type() { return memo(() => this.$0.as === "a")() ? void 0 : this.$0.type; }
get disabled() { return this.$0.disabled; }
get "aria-disabled"() { return this.$0.disabled || void 0; }
// …
static keys = ["as", "type", "disabled", "aria-disabled", …];
}
// at the site
Polymorphic(merge(new ButtonRoot$props1(merged), others));
The compiler already knows each getter body's free variables (it has to, to hoist templates and detect statics), so the constructor's fields are mechanical.
The cost is own-key semantics. Prototype getters are not own properties, so:
Object.keys(props),for…in,{...props},Object.entries,Reflect.ownKeysall return["$0"].Object.getOwnPropertyDescriptor(props, k)isundefined→isStatic()and the truthful-descriptor protocol from #3497 need a prototype-aware path.merge/omit/ssrElement/spreadtoday enumerate own keys of plain sources — they'd need to recognize the class (astatic keysor a$KEYSsymbol) and read viain/get. That part is contained: it's the same protocol surface that already special-cases views.- User code doing
splitProps/{...props}on props that arrived as a compiled literal would silently see nothing. Today it works because the literal has own accessors.
Which is why this can't be a silent codegen swap the way (1) can. Options as I see them:
- (a) Accept the semantics change for 2.0: compiled props objects are opaque and must be read through the protocol (
merge/omit/splitProps/property access). Document thatObject.keys(props)is unsupported — arguably already true in spirit, since props are oftenmerge/omitviews whose keys come from a trap. - (b) Emit the class form only where the compiler can prove the receiver is a protocol consumer — i.e. the literal is an argument to
merge()/omit()/spreadat the same site (exactlyButtonRoot's case:merge({…}, others)). DirectComp({…})calls keep the literal. Covers the polymorphic pattern that dominates these profiles without touching user-visible props shape. - (c) Make the class iterable/enumerable through a
Proxy— no; that reintroduces the allocation and trap cost we're removing.
(b) is the conservative one and gets most of the measured win on these cases; (a) is the one that gets all of it. Either needs a decision before code.
Expected impact
Composition-case SSR: compiled components are 24–26% of self time. Fix (1) removes ~45% of literal-construction cost at sites with hyphenated keys (most of polymorphic-chain's are); how much of the bucket that is depends on the key mix, so it needs measuring in the harness rather than estimating. Fix (2) takes most of what remains. It also cuts ~18% of allocation → GC (13–15% of SSR time on these cases). Client hydrate/mount pay the same literal cost per component instance.
Repro: yak-bench nested2 lanes, node --cpu-prof on dist/nested2/solid-mprim/ssr/entry.js with --no-turbo-inlining, attributed via sourcemap; microbenchmark shape above.
— Claude via Cursor
Contributor guide
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- Open a pull request that references the issue number.
Research direction
Start with the measured status and inspect packages/babel-plugin/src/shared/component.ts, packages/compiler/src/shared/ast.rs, and the SSR/universal element locations cited in the issue. Run the yak-bench nested2 SSR profile and review the listed fixture directories before evaluating the SSR-only per-site class plan and its fallback rate. Done means the selected code-generation path preserves the required props semantics and the measured benchmark and fixture results support the change.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- babel, javascript, node.js, rust, typescript
- Domain
- compilers, performance, testing-qa
- Issue type
- Refactor
- Difficulty
- 5/5
- Estimated time
- Over a week
- Activity status
- Active
- Clarity
- Mostly clear
- Newbie friendliness
- 38/100