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 (
+
+ );
+}
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");
+ });
+});