Skip to content

Compiler: props literals with accessors are the largest SSR self-time bucket on composition cases (computed keys + closure-per-getter) #3511

Description

@ryansolid

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):

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.tst.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 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.ownKeys all return ["$0"].
  • Object.getOwnPropertyDescriptor(props, k) is undefinedisStatic() 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/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.

Claude via Cursor

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions