diff --git a/src/core/h.ts b/src/core/h.ts index b18cec9..10900ec 100644 --- a/src/core/h.ts +++ b/src/core/h.ts @@ -3,7 +3,8 @@ import type { UNode, UElement, URoot, UType, UComponent, UniversalProps } from " let _idCounter = 0; export function h(type: UType, props?: UniversalProps | null, ...children: UNode[]): UElement | URoot { - const resolvedProps: UniversalProps = props ? { ...props } : {}; + const { key, ...restProps } = props ?? {}; + const resolvedProps: UniversalProps = restProps; const flatChildren = children.flat(Infinity as 1).filter((c: UNode) => c != null && c !== false) as UNode[]; if (type === "root") { @@ -18,6 +19,7 @@ export function h(type: UType, props?: UniversalProps | null, ...children: UNode type: type as string, props: resolvedProps, children: flatChildren, + ...(key != null ? { key: key as string } : {}), } as UElement; } diff --git a/src/core/schema.ts b/src/core/schema.ts index 4c060a6..59ee1ae 100644 --- a/src/core/schema.ts +++ b/src/core/schema.ts @@ -23,6 +23,7 @@ export const UJSX = Type.Module({ type: Type.String(), props: Type.Ref("UniversalProps"), children: Type.Array(Type.Ref("UNode")), + key: Type.Optional(Type.String()), }), URoot: Type.Object({ @@ -45,6 +46,7 @@ export type UElement = { type: string; props: UniversalProps; children: UNode[]; + key?: string; }; export type URoot = { type: "root"; diff --git a/test/mod.test.ts b/test/mod.test.ts index 4da727c..50f5856 100644 --- a/test/mod.test.ts +++ b/test/mod.test.ts @@ -1,7 +1,8 @@ import { describe, it, expect } from "vitest"; import { h, createRoot, createComponent, Fragment, jsx, jsxs, jsxDEV } from "../src/core/h.js"; -import { isUElement, isURoot, isUPrimitive } from "../src/core/schema.js"; +import { isUElement, isURoot, isUPrimitive, UJSX } from "../src/core/schema.js"; import type { UNode, UElement } from "../src/core/schema.js"; +import { Value } from "@alkdev/typebox/value"; import { Context } from "../src/core/context.js"; import { TransformRegistry, childCtx, ctx as transformCtx } from "../src/transform/registry.js"; import type { Direction } from "../src/core/context.js"; @@ -98,6 +99,60 @@ describe("type guards", () => { }); }); +describe("UElement key field", () => { + it("h() extracts key from props and promotes to element level", () => { + const el = h("div", { key: "item-1", class: "test" }, "hello"); + expect(isUElement(el)).toBe(true); + if (isUElement(el)) { + expect(el.key).toBe("item-1"); + expect(el.props.key).toBeUndefined(); + expect(el.props.class).toBe("test"); + } + }); + + it("h() without key does not add key field", () => { + const el = h("div", { class: "test" }, "hello"); + expect(isUElement(el)).toBe(true); + if (isUElement(el)) { + expect(el.key).toBeUndefined(); + } + }); + + it("h() with null props has no key", () => { + const el = h("div", null, "hello"); + expect(isUElement(el)).toBe(true); + if (isUElement(el)) { + expect(el.key).toBeUndefined(); + } + }); + + it("UElement with key validates against TypeBox schema", () => { + const UElementSchema = UJSX.Import("UElement"); + const el = h("li", { key: "a" }, "item"); + expect(Value.Check(UElementSchema, el)).toBe(true); + }); + + it("UElement without key validates against TypeBox schema (backward compat)", () => { + const UElementSchema = UJSX.Import("UElement"); + const el = h("li", { class: "item" }, "item"); + expect(Value.Check(UElementSchema, el)).toBe(true); + }); + + it("URoot does not have a key field", () => { + const root = createRoot("r"); + expect("key" in root).toBe(false); + }); + + it("h() with root type does not promote key to URoot", () => { + const root = h("root", { key: "should-not-appear", id: "test" }, "child"); + expect(isURoot(root)).toBe(true); + if (isURoot(root)) { + expect("key" in root).toBe(false); + expect(root.props.key).toBeUndefined(); + } + }); +}); + describe("Context", () => { it("stores and updates values", () => { const ctx = new Context({ density: "full", target: "markdown" });