Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
99 changes: 99 additions & 0 deletions packages/signals/tests/store/props-chain.bench.ts
Original file line number Diff line number Diff line change
@@ -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
// `<Child a="x" b={y()} {...rest} />`
//
// 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 `<Trigger as="a" class="btn" … aria-label={label()} …>` props object. */
function userProps(): Record<string, any> {
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<string, any>, i: number): Record<string, any> {
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<string, any> {
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<string, any>): 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;
});
}
126 changes: 126 additions & 0 deletions packages/signals/tests/store/utilities.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, any>) {
// 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<string, any>) {
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
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
{
"name": "polymorphic-chain-compiled-floor",
"shell": "<ul _hk=000><li _hk=00100><a _hk=001010 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\">row-0</a></li><li _hk=00110><a _hk=001110 role=\"button\" tabindex=\"0\" aria-haspopup=\"dialog\" aria-expanded=\"false\" data-closed=\"\" class=\"btn\" href=\"#row-1\" data-x=\"1\" aria-label=\"row-1\" title=\"row-1\">row-1</a></li><li _hk=00120><a _hk=001210 role=\"button\" tabindex=\"0\" aria-haspopup=\"dialog\" aria-expanded=\"false\" data-closed=\"\" class=\"btn\" href=\"#row-2\" data-x=\"1\" aria-label=\"row-2\" title=\"row-2\">row-2</a></li></ul>",
"rest": ""
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
{
"name": "polymorphic-chain",
"shell": "<ul _hk=000><li _hk=00100><a _hk=0010110 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\">row-0</a></li><li _hk=00110><a _hk=0011110 role=\"button\" tabindex=\"0\" aria-haspopup=\"dialog\" aria-expanded=\"false\" data-closed class=\"btn\" href=\"#row-1\" data-x=\"1\" aria-label=\"row-1\" title=\"row-1\">row-1</a></li><li _hk=00120><a _hk=0012110 role=\"button\" tabindex=\"0\" aria-haspopup=\"dialog\" aria-expanded=\"false\" data-closed class=\"btn\" href=\"#row-2\" data-x=\"1\" aria-label=\"row-2\" title=\"row-2\">row-2</a></li></ul>",
"rest": ""
}
Loading
Loading