You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
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 a Proxy, 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): #3511 (comment)
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):
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 (also ssr/element.ts L1041, universal/element.ts L457).
packages/compiler/src/shared/ast.rs L220 — 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 siteclassButtonRoot$props1{constructor($0){this.$0=$0;}getas(){returnthis.$0.as??"button";}gettype(){returnmemo(()=>this.$0.as==="a")() ? void0 : this.$0.type;}getdisabled(){returnthis.$0.disabled;}get"aria-disabled"(){returnthis.$0.disabled||void0;}// …statickeys=["as","type","disabled","aria-disabled",…];}// at the sitePolymorphic(merge(newButtonRoot$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.ownKeys all return ["$0"].
merge/omit/ssrElement/spread today enumerate own keys of plain sources — they'd need to recognize the class (a static keys or a $KEYS symbol) and read via in/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 that Object.keys(props) is unsupported — arguably already true in spirit, since props are often merge/omit views 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()/spread at the same site (exactly ButtonRoot's case: merge({…}, others)). Direct Comp({…}) 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.
Tracking item 6 of #3389. Profiling the composition cases in yak-bench (
polymorphic-chain,tabs) after #3497/#3509 shows the compiled component code itself — notmerge/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
ButtonRootfrompolymorphic-chain, as compiled (SSR,@solidjs/compiler; babel output is identical by parity):Every render of every
ButtonRootallocates 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 — whatssrElementdoes), min of 7 × 200k, Node 26:--no-optget "aria-disabled"()(string-literal keys)Object.definePropertiesTwo 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"]()andget "aria-disabled"()are the same property, but a computed key in an object literal drops V8 off the literal boilerplate path intoDefineKeyedOwnPropertyInLiteralper property. Everyaria-*,data-*, andclassprop pays it. Fix is to passcomputed: falsewith a string-literal key (Babel acceptsobjectMethod("get", stringLiteral, …, false); oxcPropertyKey::StringLiterallikewise). Fixtures pinning the computed form: 14 files underpackages/babel-plugin/test/__*_fixtures__/, pluspackages/compiler/__tests__/fixtures/**and oneexpected-crossparity 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:
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 perf(signals,web): omit over a merge holds the merge record — one record per layer, one-pass owners walk for ssrElement #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.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:
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.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.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
nested2lanes,node --cpu-profondist/nested2/solid-mprim/ssr/entry.jswith--no-turbo-inlining, attributed via sourcemap; microbenchmark shape above.— Claude via Cursor