perf(signals,web): views build their key table on enumeration or after 16 reads; ssrElement walks a view's entries - #3475
Merged
Conversation
🦋 Changeset detectedLatest commit: 7f5f902 The changes in this PR will be included in the next version bump. This PR includes changesets to release 11 packages
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 |
Coverage Report for CI Build 35033187446Coverage remained the same at 71.539%Details
Uncovered ChangesNo uncovered changes found. Coverage RegressionsNo coverage regressions found. Coverage Stats
💛 - Coveralls |
Merging this PR will regress 4 benchmarks
Warning Please fix the performance issues or acknowledge them on CodSpeed. Performance Changes
Tip Investigate this regression by commenting Comparing |
7 tasks
ryansolid
force-pushed
the
perf/view-table-on-enumeration
branch
from
September 15, 2026 21:59
2cb22e1 to
454fe60
Compare
…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
force-pushed
the
perf/view-table-on-enumeration
branch
from
September 15, 2026 22:53
454fe60 to
7f5f902
Compare
This was referenced Sep 16, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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 clientspreadthat reruns, orObject.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.Trigger→Button.Root→Polymorphic) paid for a table per layer per element.CPU profile of the tier-1
polymorphic-chainbench underrenderToString, before:combineHidden(=Array.prototype.concat)mergeTabletableSetomitTableisHiddenisStatic's single descriptor lookup was one of the triggers (12% inclusive). After: no table function appears in the profile;combineHiddenis 0.3%.Change
Signals. A per-key
get/has/getOwnPropertyDescriptoranswers by a source walk (last source first, oneineach) 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'sresolvedTable) builds outright, unchanged. An omit over one object never builds one (it didn't before either). Combined omit filters are copied by hand (concatis 4× the cost for the three-or-four-key lists this sees).Web (SSR).
ssrElementno 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.resolvedTableimport dropped fromserver.ts.Numbers
All same-process (round-robin, min of rounds) — the machine was under load and sequential runs moved the untouched
compiledcontrol 10–25%.SSR polymorphic-chain, 200 rows
renderToStringSignals props-chain (interleaved before/after dists in one process)
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
spreadstill builds its table viaresolvedTable, 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 tonulland 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 typespass.🤖 Generated with Claude Code via Cursor
Made with Cursor