Skip to content

perf(signals,web): views build their key table on enumeration or after 16 reads; ssrElement walks a view's entries - #3475

Merged
ryansolid merged 1 commit into
nextfrom
perf/view-table-on-enumeration
Sep 16, 2026
Merged

ryansolid merged 1 commit into
nextfrom
perf/view-table-on-enumeration

Conversation

@ryansolid

Copy link
Copy Markdown
Member

What

Follow-up to #3454, from profiling the polymorphic-chain SSR gap that was left after #3471.

A merge()/omit() view over plain objects keeps a resolved key table (every key → the leaf that owns it) so a client spread that reruns, or Object.keys/{...props}, is one lookup per key. Since #3454 the first per-key trap read built it too. On the server that is the wrong trade: a component reads its merged props a few times, the element serializes them once, and the view is gone — so a Kobalte-shaped chain (Dialog.TriggerButton.RootPolymorphic) paid for a table per layer per element.

CPU profile of the tier-1 polymorphic-chain bench under renderToString, before:

self
GC 14.4%
combineHidden (= Array.prototype.concat) 11.0%
mergeTable 9.7%
tableSet 4.5%
omitTable 3.0%
isHidden 2.7%

isStatic's single descriptor lookup was one of the triggers (12% inclusive). After: no table function appears in the profile; combineHidden is 0.3%.

Change

Signals. A per-key get/has/getOwnPropertyDescriptor answers by a source walk (last source first, one in each) until the view has been read 16 times, then builds the table as before. 16 is the break-even — a build is ~60 ns per key of every leaf, a walk ~20 ns per source, a leaf carries ~5 keys — and it is derived and then measured: a walk is 2× a lookup at depth 1 and up to 10× for a first-source key at depth 7, so a long-lived client view read on every rerun still wants the table and gets it within its first few updates. Enumeration (ownKeys, or a consumer's resolvedTable) builds outright, unchanged. An omit over one object never builds one (it didn't before either). Combined omit filters are copied by hand (concat is 4× the cost for the three-or-four-key lists this sees).

Web (SSR). ssrElement no longer prefers a view's table; it walks the view's entries the way it already walked the array form — an omit over a merge as its filtered leaf entries — so serializing an element builds no table. Attribute order is the same merged order. resolvedTable import dropped from server.ts.

Numbers

All same-process (round-robin, min of rounds) — the machine was under load and sequential runs moved the untouched compiled control 10–25%.

SSR polymorphic-chain, 200 rows renderToString

before after
chain 1.83–1.92 ms (8.2× compiled) 1.29–1.40 ms (5.7–5.9×)
chain-static 1.74–1.77 ms (7.7×) 1.24–1.33 ms (5.5–5.6×)

Signals props-chain (interleaved before/after dists in one process)

depth 1 depth 3 depth 7
build −5% −10% −17%
build + consume −10% −6% −14%
18 reads, no enumeration +17% +4% −11%
steady-state read on prebuilt view 23 → 23 ns 23 → 22 ns 24 → 23 ns

The +17% row is the transition band the threshold is designed around (18 reads at depth 1 sits right on the break-even: 15 walks then a build).

DOM lane (jsdom, mount+clear 1k / update 100×16): flat — 1.68–1.73× → 1.67–1.71× compiled on mount, 2.31–2.35× → 2.31–2.33× on update. The client spread still builds its table via resolvedTable, by design.

What's left in the SSR profile

No single item: element serialization ~35% (part of it the compiled floor's own cost), ~10% per component layer (createComponent + owner + hydration key + the compiled props literal with a closure per getter), GC ~16%.

Tests

  • utilities.test.ts: table undecided after a handful of reads on a merge and an omit-over-merge, built after the 16th with identical answers before/after; enumeration builds on a fresh view; plain omit never; store-leaf view settles to null and keeps walking.
  • spread-omit.spec.tsx (server): spread over an omit-over-merge and over a bare merge serializes the merged order and builds no table.

Full build, signals/web/solid/universal suites, and pnpm types pass.


🤖 Generated with Claude Code via Cursor

Made with Cursor

@changeset-bot

changeset-bot Bot commented Sep 15, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 7f5f902

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

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

coveralls commented Sep 15, 2026

Copy link
Copy Markdown

Coverage Report for CI Build 35033187446

Coverage remained the same at 71.539%

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: 1016
Covered Lines: 772
Line Coverage: 75.98%
Relevant Branches: 790
Covered Branches: 520
Branch Coverage: 65.82%
Branches in Coverage %: Yes
Coverage Strength: 14.96 hits per line

💛 - Coveralls

@codspeed

codspeed Bot commented Sep 15, 2026

Copy link
Copy Markdown

Merging this PR will regress 4 benchmarks

⚡ 47 improved benchmarks
❌ 4 regressed benchmarks
✅ 124 untouched benchmarks

Warning

Please fix the performance issues or acknowledge them on CodSpeed.

Performance Changes

Benchmark BASE HEAD Efficiency
construct 32.1 µs 36.1 µs -11.06%
construct 33 µs 35.9 µs -8.27%
construct 33.4 µs 36.4 µs -8.16%
ownKeys 714.1 µs 761.4 µs -6.22%
merge 100.8 µs 23.6 µs ×4.3
merge 109.2 µs 26.5 µs ×4.1
merge 98.6 µs 24 µs ×4.1
merge 110 µs 29.9 µs ×3.7
merge 107 µs 29.3 µs ×3.6
merge 92.4 µs 25.4 µs ×3.6
merge 82.6 µs 23.6 µs ×3.5
merge 109.8 µs 31.8 µs ×3.5
merge 91.4 µs 26.7 µs ×3.4
merge 90.1 µs 26.7 µs ×3.4
merge 90.3 µs 26.8 µs ×3.4
merge 81.5 µs 24.2 µs ×3.4
merge 98.3 µs 31.5 µs ×3.1
merge 93.1 µs 29.8 µs ×3.1
merge 82.2 µs 26.4 µs ×3.1
merge 86.5 µs 27.9 µs ×3.1
... ... ... ... ...

ℹ️ Only the first 20 benchmarks are displayed. Go to the app to view all benchmarks.

Tip

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


Comparing perf/view-table-on-enumeration (7f5f902) with next (d9fdfe3)

Open in CodSpeed

…r 16 reads, not on first read; ssrElement walks a view's entries

A merge()/omit() view over plain objects keeps a resolved key table so a
client spread that reruns, or Object.keys/{...props}, is one lookup per
key. Since #3454 the first per-key trap read built it too. On the server
that is the wrong trade: a component reads its merged props a few times,
the element serializes them once, and the view is gone — a Kobalte-shaped
chain (Dialog.Trigger → Button.Root → Polymorphic) paid for a table per
layer per element. Profiled under renderToString: a third of the time in
mergeTable/tableSet/omitTable and their garbage, another 11% in
Array.prototype.concat combining omit filters.

Signals: get/has/getOwnPropertyDescriptor answer by a source walk until
the view has been read 16 times — the break-even between a build (~60 ns
per key of every leaf) and a walk (~20 ns per source) — then build as
before, so a long-lived client view read on every rerun is one lookup per
read from its first few updates on. Enumeration builds outright. An omit
over one object never builds one. Combined hidden lists are copied by
hand (concat is 4× the cost for three-or-four-key lists).

Web: ssrElement never asks for a view's table; an omit over a merge is
walked as its filtered leaf entries, the array-form walk it already had.
Merged attribute order is unchanged.

Same-process A/B, tier-1 polymorphic-chain SSR (200 rows): chain 8.2× →
5.8× the compiled floor, chain-static 7.7× → 5.6× (−27%). Signals
props-chain: build −5…−17%, build+consume −6…−14%, steady-state reads on
a prebuilt view identical. DOM lane flat (the client spread still builds
its table by design). Tests: table undecided after a handful of reads on
a merge and an omit-over-merge, built after the 16th with identical
answers; enumeration builds on a fresh view; plain omit never; store-leaf
view settles to none. SSR: spread over omit-over-merge and bare merge
serializes the merged order and builds no table.

Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
@ryansolid
ryansolid force-pushed the perf/view-table-on-enumeration branch from 454fe60 to 7f5f902 Compare September 15, 2026 22:53
@ryansolid
ryansolid merged commit 37ea885 into next Sep 16, 2026
6 of 7 checks passed
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