test(web,signals): tier-1 coverage for the headless-UI props chain - #3448
Conversation
|
Every element a component library like Kobalte renders is reached through a stack of merge(defaults) → omit(consumed) → mergeProps(call-site statics) layers ending in a polymorphic `dynamic(() => props.as)` and one spread. Nothing in the repo measured or pinned that shape: the existing utilities and spread benches exercise one merge, one omit, or one spread in isolation, and each looks cheap. The chain is where the cost lives — every layer re-processes every key of the layer below — and it was invisible to CodSpeed. One shared fixture (`test/harness/polymorphic.tsx`: Dialog.Trigger → Button.Root → Polymorphic, plus a compiled floor twin rendering the same `<a>`) feeds all of: - `web/test/polymorphic-chain.bench.tsx` — DOM lane: mount+clear 1k rows, update 10th ×16, owner-drift gated. Chain vs compiled floor. - `web/test/server/polymorphic-chain.bench.tsx` — SSR lane: renderToString of 200 rows, chain vs floor. Construction cost only; no update phase to amortize it. - `signals/tests/store/props-chain.bench.ts` — the pure merge/omit chain at depth 1/3/7, build and build+consume reported separately so a change that moves cost between construction and read is visible. - Parity-harness scenarios `polymorphic-chain` and `polymorphic-chain-compiled-floor`: hydration ids through the chain, element identity across an update, adopt-all, no separators. - `web/test/polymorphic-chain.spec.tsx` / `server/polymorphic-chain.spec.tsx`: the chain resolves to the floor's exact attribute set (shadowing through the layers, consumed keys and `as` absent), reactive attrs stay live, the element is kept across updates; SSR serializes the same set with keys. - `utilities.test.ts`: chain-shaped semantics for merge/omit — shadowing order, hidden keys accumulating through nested omits and surviving re-merges, live reads through every layer, spread-copy snapshot, and the descriptor kind (data vs getter) surviving to the bottom of the chain. Baseline on next, per element: SSR chain ≈18× the compiled floor (≈19 µs vs ≈1 µs), DOM mount ≈2.5×, DOM update ≈2.3×; signals chain ≈21 µs at depth 3, ≈65 µs at depth 7. These are the numbers the props view work is measured against. Co-authored-by: Cursor <cursoragent@cursor.com>
aa666b4 to
9f465d5
Compare
Coverage Report for CI Build 34934352482Warning No base build found for commit Coverage: 71.842%Details
Uncovered ChangesNo uncovered changes found. Coverage RegressionsRequires a base build to compare against. How to fix this → Coverage Stats
💛 - Coveralls |
Merging this PR will improve performance by 12.09%
|
| Benchmark | BASE |
HEAD |
Efficiency | |
|---|---|---|---|---|
| ⚡ | projection derive: write one NESTED field (reference) |
2.8 ms | 2.5 ms | +12.09% |
| 🆕 | build |
N/A | 286.3 µs | N/A |
| 🆕 | build + consume |
N/A | 271.1 µs | N/A |
| 🆕 | build |
N/A | 509.1 µs | N/A |
| 🆕 | build + consume |
N/A | 486.4 µs | N/A |
| 🆕 | build |
N/A | 2.4 ms | N/A |
| 🆕 | build + consume |
N/A | 1.1 ms | N/A |
| 🆕 | polymorphic-chain mount+clear 1000 rows: chain |
N/A | 841.9 ms | N/A |
| 🆕 | polymorphic-chain mount+clear 1000 rows: compiled |
N/A | 380.9 ms | N/A |
| 🆕 | polymorphic-chain update 100/1000 rows × 16: chain |
N/A | 93.1 ms | N/A |
| 🆕 | polymorphic-chain update 100/1000 rows × 16: compiled |
N/A | 45 ms | N/A |
| 🆕 | polymorphic-chain: 200 rows (renderToString): chain |
N/A | 53.2 ms | N/A |
| 🆕 | polymorphic-chain: 200 rows (renderToString): compiled |
N/A | 5.7 ms | N/A |
Tip
Curious why performance improved? Comment @codspeedbot explain why performance improved on this PR, or directly use the CodSpeed MCP with your agent.
Comparing bench/polymorphic-chain (9f465d5) with next (a5d8eae)
What
Tier-1 benches and tests for the shape every headless-UI library (Kobalte) renders per element:
merge(defaults)→omit(consumed)→ compilermergeProps(call-site statics)stacked per component layer, ending in a polymorphicdynamic(() => props.as)and one spread.The existing
utilities.bench.ts/spread-enumerate.bench.tsx/dynamic-tag.bench.tsxeach measure one op in isolation, and each looks cheap. The chain is where the cost lives — every layer re-processes every key of the layer below — and nothing in the repo measured or pinned it, so it was invisible to CodSpeed.Fixture
One shared source,
packages/web/test/harness/polymorphic.tsx:Dialog.Trigger → Button.Root → Polymorphicmirroring the kobaltesolid2branch (context-driven aria, defaults, consumed keys,as="a"override shadowing the default), plus aCompiledRowfloor twin that writes the same<a>directly. The DOM bench, SSR bench, parity scenario, and specs all render this identical tree, compiled by each project's generate.Added
web/test/polymorphic-chain.bench.tsxweb/test/server/polymorphic-chain.bench.tsxrenderToString200 rows; chain vs floor (pure construction cost)signals/tests/store/props-chain.bench.tsbuildandbuild + consumeseparatelypolymorphic-chain,polymorphic-chain-compiled-floorscenariosweb/test/polymorphic-chain.spec.tsx,server/polymorphic-chain.spec.tsxasabsent; reactive attrs live; element kept across updates; SSR same set + keyssignals/tests/store/utilities.test.ts"props chain"The last one is the contract the upcoming view work must keep: a consumer at the bottom of the chain (spread's children fast path, static-source detection) has to be able to read the compiler's static/dynamic verdict for a key.
Baseline (next, this machine)
No source changes, no changeset. Both compilers verified (
JSX_COMPILER=babelA/B on the specs and harness).