diff --git a/.chronus/changes/witemple-msft-custom-element-2025-6-22-19-3-51.md b/.chronus/changes/witemple-msft-custom-element-2025-6-22-19-3-51.md new file mode 100644 index 000000000..81f0a6dba --- /dev/null +++ b/.chronus/changes/witemple-msft-custom-element-2025-6-22-19-3-51.md @@ -0,0 +1,7 @@ +--- +changeKind: feature +packages: + - "@alloy-js/core" +--- + +Adds a new type of Alloy child, CustomChildElement, that is based on the presence of a symbol property. \ No newline at end of file diff --git a/packages/core/src/render.ts b/packages/core/src/render.ts index 1d3d99b6d..c63a66a80 100644 --- a/packages/core/src/render.ts +++ b/packages/core/src/render.ts @@ -20,7 +20,9 @@ import { Children, Component, isComponentCreator, + isRenderableObject, Props, + RENDERABLE, } from "./runtime/component.js"; import { IntrinsicElement, isIntrinsicElement } from "./runtime/intrinsic.js"; import { flushJobs, flushJobsAsync } from "./scheduler.js"; @@ -548,6 +550,9 @@ function normalizeChild(child: Child): NormalizedChildren { return sfContext.reference({ refkey: child }); }; + } else if (isRenderableObject(child)) { + // For custom renderable objects, we will just normalize them to a bound function. + return child[RENDERABLE].bind(child); } else if (isCustomContext(child)) { return child; } else if (isIntrinsicElement(child)) { @@ -573,6 +578,8 @@ function debugPrintChild(child: Children): string { return "$ref"; } else if (isIntrinsicElement(child)) { return `<${child.name}>`; + } else if (isRenderableObject(child)) { + return `CustomChildElement(${JSON.stringify(child)})`; } else { return JSON.stringify(child); } diff --git a/packages/core/src/runtime/component.ts b/packages/core/src/runtime/component.ts index c4727d7a2..53c13321f 100644 --- a/packages/core/src/runtime/component.ts +++ b/packages/core/src/runtime/component.ts @@ -3,7 +3,38 @@ import { CustomContext } from "../reactivity.js"; import { Refkey } from "../refkey.js"; import { IntrinsicElement } from "./intrinsic.js"; +export const RENDERABLE = Symbol.for("Alloy.CustomElement"); + +/** + * A renderable object is any object that has an `[ay.RENDERABLE]` method that + * returns children. This is used to allow custom object types to be used as + * children in Alloy components. + */ +export interface RenderableObject { + /** + * Renders this object to children. + */ + [RENDERABLE](): Children; +} + +/** + * Returns true if the item is a renderable object, meaning it has an `[ay.RENDERABLE]` + * method. + * + * @param item - The item to check. + * @returns True if the item is a renderable object. + */ +export function isRenderableObject(item: unknown): item is RenderableObject { + return ( + typeof item === "object" && + item !== null && + RENDERABLE in item && + typeof (item as any)[RENDERABLE] === "function" + ); +} + export type Child = + | RenderableObject | string | boolean | number diff --git a/packages/core/test/rendering/basic.test.tsx b/packages/core/test/rendering/basic.test.tsx index 6e0ab6262..10e115d69 100644 --- a/packages/core/test/rendering/basic.test.tsx +++ b/packages/core/test/rendering/basic.test.tsx @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { Children } from "../../src/runtime/component.js"; +import { Children, RENDERABLE } from "../../src/runtime/component.js"; import "../../testing/extend-expect.js"; describe("string nodes", () => { it("renders string nodes with substitutions", () => { @@ -49,6 +49,40 @@ describe("component nodes", () => { , ).toRenderTo("Str Str"); }); + + it("renders custom elements", () => { + const customElement = { + [RENDERABLE]() { + return ( + <> + + + ); + }, + }; + + expect(customElement).toRenderTo("Str Str"); + }); + + it("renders nested custom elements", () => { + const e1 = { + [RENDERABLE]() { + return ; + }, + }; + + const e2 = { + [RENDERABLE]() { + return ( + <> + {e1} {e1} + + ); + }, + }; + + expect(e2).toRenderTo("Str Str"); + }); }); describe("memo nodes", () => {