From 9f465d5570aa7c47988df8c4af9d2da5ad1dae5e Mon Sep 17 00:00:00 2001 From: Ryan Carniato Date: Mon, 14 Sep 2026 22:04:07 -0700 Subject: [PATCH] test(web,signals): tier-1 coverage for the headless-UI props chain MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 ``) 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 --- .../signals/tests/store/props-chain.bench.ts | 99 ++++++++++ .../signals/tests/store/utilities.test.ts | 126 ++++++++++++ .../polymorphic-chain-compiled-floor.json | 5 + .../__artifacts__/polymorphic-chain.json | 5 + packages/web/test/harness/polymorphic.tsx | 187 ++++++++++++++++++ packages/web/test/harness/scenarios.tsx | 36 ++++ packages/web/test/polymorphic-chain.bench.tsx | 124 ++++++++++++ packages/web/test/polymorphic-chain.spec.tsx | 81 ++++++++ .../test/server/polymorphic-chain.bench.tsx | 33 ++++ .../test/server/polymorphic-chain.spec.tsx | 58 ++++++ 10 files changed, 754 insertions(+) create mode 100644 packages/signals/tests/store/props-chain.bench.ts create mode 100644 packages/web/test/harness/__artifacts__/polymorphic-chain-compiled-floor.json create mode 100644 packages/web/test/harness/__artifacts__/polymorphic-chain.json create mode 100644 packages/web/test/harness/polymorphic.tsx create mode 100644 packages/web/test/polymorphic-chain.bench.tsx create mode 100644 packages/web/test/polymorphic-chain.spec.tsx create mode 100644 packages/web/test/server/polymorphic-chain.bench.tsx create mode 100644 packages/web/test/server/polymorphic-chain.spec.tsx diff --git a/packages/signals/tests/store/props-chain.bench.ts b/packages/signals/tests/store/props-chain.bench.ts new file mode 100644 index 000000000..b14f18f5f --- /dev/null +++ b/packages/signals/tests/store/props-chain.bench.ts @@ -0,0 +1,99 @@ +// Tier-1 bench for the props-plumbing chain of a headless-UI component stack, +// at the signals layer (no renderer): the pure cost of composing `merge()` +// and `omit()` the way component libraries do. +// +// Every element such a library renders is reached through the same shape, +// repeated once per component layer: +// +// props ← compiled call-site object: data properties for static +// attributes, getters for reactive ones +// merge(defaults, props) ← component defaults +// omit(merged, ...consumedKeys) ← keys the component handles itself +// merge({…staticAttrs}, rest) ← the compiler's mergeProps for +// `` +// +// and the element at the bottom enumerates the result and reads every key +// once (what `spread` / `ssrElement` do). The existing utilities bench +// measures one merge or one omit in isolation; each looks cheap. The chain +// is where the cost lives, because every layer re-processes every key of the +// layer below. Depth 3 is a Kobalte `Dialog.Trigger` → `Button.Root` → +// `Polymorphic` stack; depth 7 is a deeply composed app component on top. +// +// "build" is what a component instantiation costs; "build + consume" adds the +// element's single pass over the result. Both are reported so a change that +// moves cost between construction and read (eager copy vs lazy view) is +// visible rather than hidden in a total. + +import { bench, describe } from "vitest"; +import { createSignal, merge, omit } from "../../src/index.js"; + +const [label] = createSignal("row"); +const [open] = createSignal(false); + +/** A compiled `` props object. */ +function userProps(): Record { + return { + as: "a", + class: "btn", + id: "t", + href: "#row", + "data-x": "1", + tabIndex: 0, + onClick() {}, + get "aria-label"() { + return label(); + }, + get title() { + return label(); + }, + get disabled() { + return open(); + }, + get children() { + return label(); + } + }; +} + +/** One component layer: defaults in, two keys consumed, static + reactive attrs added at the call site. */ +function layer(props: Record, i: number): Record { + const merged = merge({ type: "button", [`default${i}`]: i }, props); + const rest = omit(merged, "type", `default${i}`); + return merge( + { + [`data-layer${i}`]: "", + get [`aria-l${i}`]() { + return open() ? "true" : "false"; + } + }, + rest + ); +} + +function chain(depth: number): Record { + let props = userProps(); + for (let i = 0; i < depth; i++) props = layer(props, i); + // The polymorphic renderer at the bottom: hide `as`, read the rest. + return omit(props, "as"); +} + +/** What the element does with the result: enumerate once, read each key once. */ +function consume(props: Record): number { + let n = 0; + for (const key in props) if (props[key] !== undefined) n++; + return n; +} + +// No owner needed: no function sources, so merge() creates no memos here. +for (const depth of [1, 3, 7]) { + describe(`props chain depth ${depth}`, () => { + let sink: any; + bench("build", () => { + sink = chain(depth); + }); + bench("build + consume", () => { + sink = consume(chain(depth)); + }); + void sink; + }); +} diff --git a/packages/signals/tests/store/utilities.test.ts b/packages/signals/tests/store/utilities.test.ts index 35ed9cf26..d091968ce 100644 --- a/packages/signals/tests/store/utilities.test.ts +++ b/packages/signals/tests/store/utilities.test.ts @@ -592,6 +592,132 @@ describe("omit Props", () => { }); }); +// The shape headless-UI libraries (Kobalte) compose per element: a compiled +// props object → merge(defaults) → omit(consumed) → merge(call-site statics) +// … → omit("as") at the polymorphic renderer. These pin what the chain must +// mean regardless of whether the layers are eager copies or lazy views. +describe("props chain (component-library shape)", () => { + function compiledProps(label: () => string, open: () => boolean) { + return { + as: "a", + class: "btn", + href: "#row", + get "aria-label"() { + return label(); + }, + get disabled() { + return open(); + } + }; + } + + function buttonRoot(props: Record) { + // Button.Root: defaults in, consume type/disabled, add derived attrs at the call site. + const merged = merge({ type: "button" }, props); + const others = omit(merged, "type", "disabled"); + const isButton = () => (merged.as ?? "button") === "button"; + return merge( + { + as: "button", + get role() { + return isButton() ? undefined : "button"; + }, + get "data-disabled"() { + return merged.disabled ? "" : undefined; + } + }, + others + ); + } + + function polymorphic(props: Record) { + return omit(props, "as"); + } + + test("later layers shadow earlier ones and omitted keys stay hidden through re-merges", () => { + const [label] = createSignal("Open"); + const [open] = createSignal(false); + const props = compiledProps(label, open); + const atPolymorphic = buttonRoot(props); + // The user's as="a" (rightmost via `others`) beats Button.Root's as="button" default. + expect(atPolymorphic.as).toBe("a"); + expect(atPolymorphic.role).toBe("button"); + // Consumed keys don't reappear once merged with new statics. + expect("type" in atPolymorphic).toBe(false); + expect("disabled" in atPolymorphic).toBe(false); + expect((atPolymorphic as any).type).toBeUndefined(); + + const element = polymorphic(atPolymorphic); + expect("as" in element).toBe(false); + expect(Object.keys(element).sort()).toEqual( + ["aria-label", "class", "data-disabled", "href", "role"].sort() + ); + }); + + test("nested omits accumulate their hidden keys", () => { + const rest = omit(omit(omit({ a: 1, b: 2, c: 3, d: 4 }, "a"), "b"), "c"); + expect(Object.keys(rest)).toEqual(["d"]); + expect("a" in rest).toBe(false); + expect("b" in rest).toBe(false); + expect((rest as any).c).toBeUndefined(); + // …and a merge on top can't resurrect them. + const remerged = merge({ e: 5 }, rest); + expect(Object.keys(remerged).sort()).toEqual(["d", "e"]); + expect("a" in remerged).toBe(false); + }); + + test("reactive reads stay live through every layer", () => { + const [label, setLabel] = createSignal("Open"); + const [open, setOpen] = createSignal(false); + const seen: string[] = []; + createRoot(() => { + const element = polymorphic(buttonRoot(compiledProps(label, open))); + createEffect( + () => `${element["aria-label"]}|${element["data-disabled"]}`, + v => { + seen.push(v); + } + ); + }); + flush(); + expect(seen).toEqual(["Open|undefined"]); + setLabel("Close"); + setOpen(true); + flush(); + expect(seen).toEqual(["Open|undefined", "Close|"]); + }); + + test("a spread copy of the chain result is a plain snapshot with the surviving keys", () => { + const [label] = createSignal("Open"); + const [open] = createSignal(false); + const element = polymorphic(buttonRoot(compiledProps(label, open))); + const copy = { ...element }; + expect(copy).toEqual({ + class: "btn", + href: "#row", + "aria-label": "Open", + role: "button", + "data-disabled": undefined + }); + }); + + test("descriptor kind survives the chain: static stays data, reactive stays a getter", () => { + const [label] = createSignal("Open"); + const [open] = createSignal(false); + const element = polymorphic(buttonRoot(compiledProps(label, open))); + // A consumer (spread's children fast path, isStaticProp) must be able to + // tell a compiled static attribute from a reactive one at the bottom of + // the chain — that distinction is the compiler's verdict and must not be + // erased by the layers in between. + const staticDesc = Object.getOwnPropertyDescriptor(element, "class")!; + expect(staticDesc.get).toBeUndefined(); + expect(staticDesc.value).toBe("btn"); + const reactiveDesc = Object.getOwnPropertyDescriptor(element, "aria-label")!; + expect(typeof reactiveDesc.get).toBe("function"); + expect(Object.getOwnPropertyDescriptor(element, "as")).toBeUndefined(); + }); +}); + describe("deep", () => { // RULED (INTERNALS-STORE-STATE.md, recon-snap pin 2): pins the LEGACY graph // shape (one $TRACK dep per level). The rewrite's deep() subscribes the diff --git a/packages/web/test/harness/__artifacts__/polymorphic-chain-compiled-floor.json b/packages/web/test/harness/__artifacts__/polymorphic-chain-compiled-floor.json new file mode 100644 index 000000000..4ab6a26b0 --- /dev/null +++ b/packages/web/test/harness/__artifacts__/polymorphic-chain-compiled-floor.json @@ -0,0 +1,5 @@ +{ + "name": "polymorphic-chain-compiled-floor", + "shell": "", + "rest": "" +} \ No newline at end of file diff --git a/packages/web/test/harness/__artifacts__/polymorphic-chain.json b/packages/web/test/harness/__artifacts__/polymorphic-chain.json new file mode 100644 index 000000000..3828175b3 --- /dev/null +++ b/packages/web/test/harness/__artifacts__/polymorphic-chain.json @@ -0,0 +1,5 @@ +{ + "name": "polymorphic-chain", + "shell": "", + "rest": "" +} \ No newline at end of file diff --git a/packages/web/test/harness/polymorphic.tsx b/packages/web/test/harness/polymorphic.tsx new file mode 100644 index 000000000..49fd39cfd --- /dev/null +++ b/packages/web/test/harness/polymorphic.tsx @@ -0,0 +1,187 @@ +/** + * @jsxImportSource @solidjs/web + * + * Kobalte-shaped polymorphic component chain — shared fixture. + * + * Headless UI libraries build every element the same way: a public component + * merges defaults into its props, consumes a few keys, and forwards the rest + * into a polymorphic renderer that resolves an `as` prop. Composed components + * (`Dialog.Trigger` renders `Button.Root` renders `Polymorphic`) stack that + * pattern, so one `` in the DOM is reached through several `merge()` / + * `omit()` layers plus the compiler's own call-site `mergeProps` at each + * ``. The props plumbing IS the cost of these + * libraries; nothing about the element itself is expensive. + * + * This file is the one source for that shape. It is imported by: + * - test/polymorphic-chain.bench.tsx (dom lane, jsdom) + * - test/server/polymorphic-chain.bench.tsx (ssr lane) + * - test/harness/scenarios.tsx (hydration parity) + * so the DOM bench, the SSR bench, and the hydration invariants all measure + * the identical tree, compiled by the respective generate. + * + * Fidelity notes (vs kobalte `solid2` branch): + * - `Polymorphic` uses `dynamic()` rather than the deprecated ``. + * - `Button.Root`'s native-tag detection reads the resolved `as` instead of + * the mounted element's tagName (that path needs a DOM ref and would make + * the SSR and DOM trees diverge). Same attribute set results. + * - `Dialog.Trigger` reads open state from context, as Kobalte does; one + * `Dialog` provider wraps a list so the per-row cost is the trigger chain. + * + * `CompiledRow` is the floor: the same final element written directly, so a + * bench delta between it and `TriggerRow` is attributable to the chain alone. + */ +import { createContext, createSignal, merge, omit, useContext, For, type Accessor } from "solid-js"; +import { dynamic, type JSX } from "@solidjs/web"; + +// --- Layer 3: Polymorphic ------------------------------------------------ + +export type PolymorphicProps = { as: any } & Record; + +/** Renders its `as` prop with everything else forwarded (kobalte polymorphic.tsx). */ +export function Polymorphic(props: PolymorphicProps): JSX.Element { + const others = omit(props, "as"); + const Tag = dynamic(() => props.as); + return ; +} + +// --- Layer 2: Button.Root ----------------------------------------------- + +/** + * Defaults merged in, a few keys consumed, aria derived from what the + * element will be, rest forwarded (kobalte button-root.tsx). + */ +export function ButtonRoot(props: Record): JSX.Element { + const mergedProps = merge({ type: "button" }, props); + const others = omit(mergedProps, "type", "disabled"); + const isNativeButton = () => (mergedProps.as ?? "button") === "button"; + return ( + + ); +} + +// --- Layer 1: Dialog + Dialog.Trigger -------------------------------------- + +interface DialogContextValue { + isOpen: Accessor; + toggle: () => void; +} + +const DialogContext = createContext(); + +export function Dialog(props: { open?: boolean; children: JSX.Element }): JSX.Element { + const [isOpen, setIsOpen] = createSignal(props.open ?? false); + return ( + setIsOpen(o => !o) }}> + {props.children} + + ); +} + +/** Context-driven aria, consumes `onClick`, forwards the rest (kobalte dialog-trigger.tsx). */ +export function DialogTrigger(props: Record): JSX.Element { + const context = useContext(DialogContext)!; + const others = omit(props, "onClick"); + return ( + { + props.onClick?.(e); + context.toggle(); + }} + {...others} + /> + ); +} + +// --- Rows ------------------------------------------------------------------- + +export interface Row { + id: number; + label: Accessor; + setLabel: (next: string) => string; +} + +export function makeRows(start: number, count: number): Row[] { + const rows = new Array(count); + for (let i = 0; i < count; i++) { + const [label, setLabel] = createSignal(`row-${start + i}`); + rows[i] = { id: start + i, label, setLabel }; + } + return rows; +} + +/** + * What an application writes: a trigger rendered as a link, with a mix of + * static attributes (data properties on the compiled props object) and + * reactive ones (getters). `as="a"` overrides Button.Root's default, so the + * chain exercises shadowing through merge → omit → merge, not just pass-through. + */ +export function TriggerRow(props: { row: Row }): JSX.Element { + const { row } = props; + return ( + + {row.label()} + + ); +} + +/** Floor: the element `TriggerRow` resolves to, written directly. */ +export function CompiledRow(props: { row: Row }): JSX.Element { + const { row } = props; + const context = useContext(DialogContext)!; + return ( + context.toggle()} + > + {row.label()} + + ); +} + +export type RowRenderer = (row: Row) => JSX.Element; + +export const forms: Record<"compiled" | "chain", RowRenderer> = { + compiled: row => , + chain: row => +}; + +/** A `Dialog` wrapping a list of rows — the tree every consumer of this fixture renders. */ +export function TriggerList(props: { rows: Accessor; render: RowRenderer }): JSX.Element { + return ( + +
    + {row =>
  • {props.render(row)}
  • }
    +
+
+ ); +} diff --git a/packages/web/test/harness/scenarios.tsx b/packages/web/test/harness/scenarios.tsx index c08931723..ae3e4e5ff 100644 --- a/packages/web/test/harness/scenarios.tsx +++ b/packages/web/test/harness/scenarios.tsx @@ -45,6 +45,7 @@ import { ssrElement, type JSX } from "@solidjs/web"; +import { makeRows, TriggerList, forms, type Row } from "./polymorphic.jsx"; const sleep = (ms: number) => new Promise(r => setTimeout(r, ms)); @@ -1793,7 +1794,42 @@ function DynamicNamespaceLink() { ); } +// Kobalte-shaped component chain (test/harness/polymorphic.tsx): every +// element reached through merge → omit → merge layers and a per-instance +// `dynamic(() => props.as)`. Hydration must claim the `` through all of +// that with ids aligned on both sides, and a label update must flow through +// the chain's getters into aria-label/title/text without recreating the +// element. `compiled` is the floor twin, hydrated the same way so a chain +// failure can't hide behind a fixture problem. +let chainRows: Row[] = []; +function polymorphicChainApp(form: keyof typeof forms) { + return function PolymorphicChain() { + chainRows = makeRows(0, 3); + return chainRows} render={forms[form]} />; + }; +} + export const scenarios: Scenario[] = [ + { + name: "polymorphic-chain", + App: polymorphicChainApp("chain"), + expectedText: "row-0row-1row-2", + adoptAll: true, + noSeparators: true, + update: () => chainRows[1].setLabel("ROW-1"), + expectedTextAfterUpdate: "row-0ROW-1row-2", + stableSelector: "ul, li, a.btn" + }, + { + name: "polymorphic-chain-compiled-floor", + App: polymorphicChainApp("compiled"), + expectedText: "row-0row-1row-2", + adoptAll: true, + noSeparators: true, + update: () => chainRows[1].setLabel("ROW-1"), + expectedTextAfterUpdate: "row-0ROW-1row-2", + stableSelector: "ul, li, a.btn" + }, { name: "text-hole", App: TextHole, diff --git a/packages/web/test/polymorphic-chain.bench.tsx b/packages/web/test/polymorphic-chain.bench.tsx new file mode 100644 index 000000000..219cd4430 --- /dev/null +++ b/packages/web/test/polymorphic-chain.bench.tsx @@ -0,0 +1,124 @@ +/** + * @jsxImportSource @solidjs/web + * @vitest-environment jsdom + */ + +// Tier-1 DOM-lane bench: the props plumbing of a headless-UI component +// chain. Two forms render the SAME `` (see test/harness/polymorphic.tsx): +// +// compiled — the element written directly. Floor. +// chain — `` → `ButtonRoot` → `Polymorphic` → +// `dynamic(() => props.as)`: four `merge`s (two of them the +// compiler's call-site `mergeProps`), three `omit`s, and one +// spread reading through all of them. This is the Kobalte shape +// for every element it renders. +// +// Two workloads, mirroring JFB `01_run1k`/`09_clear1k` and `03_update10th1k`: +// mount+clear 1k rows, and update every 10th row's label (which flows through +// the chain into aria-label, title, and text) 16 times. Owner-tree drift is +// gated in afterAll. +// +// The delta between the two forms is the number that matters: it is the +// per-element cost of composing our props primitives, and the thing this +// bench exists to drive down. + +import { afterAll, bench } from "vitest"; +import { createRoot, createSignal, flush, getOwner } from "solid-js"; +import { insert } from "../src/index.js"; +import { forms, makeRows, TriggerList, type Row } from "./harness/polymorphic.jsx"; + +const ROWS = 1000; +const STEP = 10; +const ITERATIONS = 16; + +function ownerTotal(node: any): number { + let count = 1; + for (let s = node._firstChild; s; s = s._nextSibling) count += ownerTotal(s); + return count; +} + +const cleanups: Array<() => void> = []; +const drifts: Array<() => void> = []; + +// --- mount + clear ----------------------------------------------------- + +for (const name of Object.keys(forms) as Array) { + const render = forms[name]; + let rootOwner!: any; + let setRows!: (next: Row[]) => Row[]; + const dispose = createRoot(d => { + rootOwner = getOwner(); + const [rows, setR] = createSignal([]); + setRows = setR; + const container = document.createElement("div"); + insert(container, () => , null); + return d; + }); + cleanups.push(dispose); + flush(); + const baseline = ownerTotal(rootOwner); + let seed = 0; + + bench(`polymorphic-chain mount+clear ${ROWS} rows: ${name}`, () => { + setRows(makeRows(seed, ROWS)); + seed += ROWS; + flush(); + setRows([]); + flush(); + }); + + drifts.push(() => { + const final = ownerTotal(rootOwner); + if (final - baseline > 5) + throw new Error(`[${name}] owner leak on mount+clear: baseline=${baseline}, final=${final}`); + }); +} + +// --- update 10th ------------------------------------------------------ + +for (const name of Object.keys(forms) as Array) { + const render = forms[name]; + let rootOwner!: any; + let rows!: Row[]; + const dispose = createRoot(d => { + rootOwner = getOwner(); + rows = makeRows(0, ROWS); + const [getRows] = createSignal(rows); + const container = document.createElement("div"); + insert(container, () => , null); + return d; + }); + cleanups.push(dispose); + flush(); + const baseline = ownerTotal(rootOwner); + let counter = 0; + + bench(`polymorphic-chain update ${ROWS / STEP}/${ROWS} rows × ${ITERATIONS}: ${name}`, () => { + for (let iter = 0; iter < ITERATIONS; iter++) { + counter++; + for (let i = 0; i < ROWS; i += STEP) rows[i].setLabel(`updated-${counter}`); + flush(); + } + }); + + drifts.push(() => { + const final = ownerTotal(rootOwner); + // CodSpeed simulation mode disposes bench owner subtrees before afterAll + // fires, so the strict drift gate only runs in the local dev path. + if (!process.env.CODSPEED_RUNNER_MODE && final !== baseline) + throw new Error(`[${name}] owner drift on update: baseline=${baseline}, final=${final}`); + }); +} + +afterAll(() => { + const errors: string[] = []; + for (const check of drifts) { + try { + check(); + } catch (e) { + errors.push((e as Error).message); + } + } + for (const dispose of cleanups) dispose(); + if (errors.length) throw new Error(errors.join("\n")); +}); diff --git a/packages/web/test/polymorphic-chain.spec.tsx b/packages/web/test/polymorphic-chain.spec.tsx new file mode 100644 index 000000000..c74937a4a --- /dev/null +++ b/packages/web/test/polymorphic-chain.spec.tsx @@ -0,0 +1,81 @@ +/** + * @jsxImportSource @solidjs/web + * @vitest-environment jsdom + * + * The Kobalte-shaped component chain (test/harness/polymorphic.tsx) must + * resolve to exactly the element its compiled floor twin produces: same + * attribute set after shadowing through merge → omit → merge layers, consumed + * keys absent, `as` never reaching the DOM, reactive attributes live, and the + * element kept across updates. The benches measure this tree; these pin what + * it means so the plumbing can change underneath without moving the target. + */ +import { describe, expect, test } from "vitest"; +import { render } from "@solidjs/web"; +import { flush } from "solid-js"; +import { forms, makeRows, TriggerList, type Row } from "./harness/polymorphic.jsx"; + +function mount(form: keyof typeof forms, rows: Row[]) { + const container = document.createElement("div"); + const dispose = render(() => rows} render={forms[form]} />, container); + flush(); + return { container, dispose, anchors: () => [...container.querySelectorAll("a")] }; +} + +function attrs(el: Element): Record { + const out: Record = {}; + for (const { name, value } of el.attributes) out[name] = value; + return out; +} + +describe("polymorphic chain (Kobalte shape)", () => { + test("resolves to the same element as the compiled floor", () => { + const chain = mount("chain", makeRows(0, 2)); + const floor = mount("compiled", makeRows(0, 2)); + const [a] = chain.anchors(); + const [b] = floor.anchors(); + expect(a.tagName).toBe("A"); + expect(attrs(a)).toEqual(attrs(b)); + // What the chain decided along the way: + expect(a.getAttribute("role")).toBe("button"); // as="a" is not a native button + expect(a.getAttribute("tabindex")).toBe("0"); + expect(a.hasAttribute("type")).toBe(false); // consumed by Button.Root, not a button + expect(a.hasAttribute("disabled")).toBe(false); + expect(a.hasAttribute("as")).toBe(false); // hidden by Polymorphic + expect(a.getAttribute("aria-haspopup")).toBe("dialog"); + expect(a.hasAttribute("data-closed")).toBe(true); + expect(a.getAttribute("class")).toBe("btn"); + expect(a.getAttribute("href")).toBe("#row-0"); + expect(a.getAttribute("aria-label")).toBe("row-0"); + expect(a.textContent).toBe("row-0"); + chain.dispose(); + floor.dispose(); + }); + + test("reactive props flow through every layer without recreating the element", () => { + const rows = makeRows(0, 3); + const { anchors, dispose } = mount("chain", rows); + const before = anchors(); + rows[1].setLabel("ROW-1"); + flush(); + const after = anchors(); + expect(after).toEqual(before); + expect(after[1].getAttribute("aria-label")).toBe("ROW-1"); + expect(after[1].getAttribute("title")).toBe("ROW-1"); + expect(after[1].textContent).toBe("ROW-1"); + expect(after[0].getAttribute("aria-label")).toBe("row-0"); + dispose(); + }); + + test("the consumed onClick still runs, and context-driven aria updates", () => { + const rows = makeRows(0, 1); + const { anchors, dispose } = mount("chain", rows); + const [a] = anchors(); + expect(a.getAttribute("aria-expanded")).toBe("false"); + a.click(); + flush(); + expect(a.getAttribute("aria-expanded")).toBe("true"); + expect(a.hasAttribute("data-expanded")).toBe(true); + expect(a.hasAttribute("data-closed")).toBe(false); + dispose(); + }); +}); diff --git a/packages/web/test/server/polymorphic-chain.bench.tsx b/packages/web/test/server/polymorphic-chain.bench.tsx new file mode 100644 index 000000000..45337a668 --- /dev/null +++ b/packages/web/test/server/polymorphic-chain.bench.tsx @@ -0,0 +1,33 @@ +// Tier-1 SSR-lane bench: the props plumbing of a headless-UI component chain +// under `renderToString`. Same fixture and the same two forms as the DOM +// lane (test/harness/polymorphic.tsx): +// +// compiled — the `` written directly. Floor. +// chain — `` → `ButtonRoot` → `Polymorphic` → +// `dynamic(() => props.as)` → `ssrElement`: four `merge`s, three +// `omit`s, and one serializer reading every attribute through +// all of them. +// +// On the server every element is built exactly once and every prop is read +// exactly once, so this lane isolates the *construction* cost of the chain — +// descriptor copies, proxy traps, memo nodes — with no update phase to +// amortize it. Headless-UI SSR throughput is bounded by this number. +// +// Vitest's reported mean is the full `renderToString` cycle. + +/** + * @jsxImportSource @solidjs/web + */ +import { bench } from "vitest"; +import { renderToString } from "@solidjs/web"; +import { forms, makeRows, TriggerList } from "../harness/polymorphic.jsx"; + +const ROWS = 200; + +for (const name of Object.keys(forms) as Array) { + const render = forms[name]; + bench(`polymorphic-chain: ${ROWS} rows (renderToString): ${name}`, () => { + const rows = makeRows(0, ROWS); + renderToString(() => rows} render={render} />); + }); +} diff --git a/packages/web/test/server/polymorphic-chain.spec.tsx b/packages/web/test/server/polymorphic-chain.spec.tsx new file mode 100644 index 000000000..a6f7efeb2 --- /dev/null +++ b/packages/web/test/server/polymorphic-chain.spec.tsx @@ -0,0 +1,58 @@ +/** + * @jsxImportSource @solidjs/web + * + * Server twin of test/polymorphic-chain.spec.tsx: the Kobalte-shaped chain + * (test/harness/polymorphic.tsx) serializes to the same attribute set as its + * compiled floor, with consumed keys and `as` absent, and every element + * carrying a hydration key. Attribute ORDER legitimately differs between the + * two (the chain's statics are merged in layer order), so compare as sets. + */ +import { describe, expect, test } from "vitest"; +import { renderToString } from "@solidjs/web"; +import { forms, makeRows, TriggerList } from "../harness/polymorphic.jsx"; + +function renderForm(form: keyof typeof forms, count: number) { + const rows = makeRows(0, count); + return renderToString(() => rows} render={forms[form]} />); +} + +/** Attribute name→value maps of every `` in the markup, `_hk` dropped. */ +function anchorAttrs(html: string): Record[] { + return [...html.matchAll(/]*)>/g)].map(m => { + const out: Record = {}; + // values are quoted except the hydration key, which is emitted bare + for (const a of m[1].matchAll(/([^\s=]+)(?:=(?:"([^"]*)"|([^\s>]+)))?/g)) + if (a[1] !== "_hk") out[a[1]] = a[2] ?? a[3] ?? ""; + return out; + }); +} + +describe("polymorphic chain (Kobalte shape) — SSR", () => { + test("serializes the same attribute set as the compiled floor", () => { + const chain = anchorAttrs(renderForm("chain", 2)); + const floor = anchorAttrs(renderForm("compiled", 2)); + expect(chain).toHaveLength(2); + expect(chain).toEqual(floor); + expect(chain[0]).toEqual({ + role: "button", + tabindex: "0", + "aria-haspopup": "dialog", + "aria-expanded": "false", + "data-closed": "", + class: "btn", + href: "#row-0", + "data-x": "1", + "aria-label": "row-0", + title: "row-0" + }); + }); + + test("every anchor carries a hydration key and its label", () => { + const html = renderForm("chain", 3); + const anchors = [...html.matchAll(/]*>/g)].map(m => m[0]); + expect(anchors).toHaveLength(3); + for (const a of anchors) expect(a).toMatch(/ _hk=[^\s>]+/); + expect(html).toContain(">row-0"); + expect(html).toContain(">row-2"); + }); +});