From 25f2250b7aab00d8eb69bf7a290e13e78b8b09d1 Mon Sep 17 00:00:00 2001 From: Will Temple Date: Tue, 22 Jul 2025 19:03:13 -0400 Subject: [PATCH 1/4] Add a custom element child type --- packages/core/src/render.ts | 6 ++++++ packages/core/src/runtime/component.ts | 18 ++++++++++++++++++ packages/core/test/rendering/basic.test.tsx | 16 +++++++++++++++- 3 files changed, 39 insertions(+), 1 deletion(-) diff --git a/packages/core/src/render.ts b/packages/core/src/render.ts index 88441d822..deee47954 100644 --- a/packages/core/src/render.ts +++ b/packages/core/src/render.ts @@ -16,10 +16,12 @@ import { } from "./reactivity.js"; import { isRefkey } from "./refkey.js"; import { + AY_CUSTOM_ELEMENT, Child, Children, Component, isComponentCreator, + isCustomChildElement, Props, } from "./runtime/component.js"; import { IntrinsicElement, isIntrinsicElement } from "./runtime/intrinsic.js"; @@ -517,6 +519,8 @@ function normalizeChild(child: Child): NormalizedChildren { return sfContext.reference({ refkey: child }); }; + } else if (isCustomChildElement(child)) { + return child[AY_CUSTOM_ELEMENT].bind(child); } else if (isCustomContext(child)) { return child; } else if (isIntrinsicElement(child)) { @@ -542,6 +546,8 @@ function debugPrintChild(child: Children): string { return "$ref"; } else if (isIntrinsicElement(child)) { return `<${child.name}>`; + } else if (isCustomChildElement(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..04a64b828 100644 --- a/packages/core/src/runtime/component.ts +++ b/packages/core/src/runtime/component.ts @@ -3,7 +3,25 @@ import { CustomContext } from "../reactivity.js"; import { Refkey } from "../refkey.js"; import { IntrinsicElement } from "./intrinsic.js"; +export const AY_CUSTOM_ELEMENT = Symbol.for("Alloy.CustomElement"); + +export interface CustomChildElement { + [AY_CUSTOM_ELEMENT](): Children; +} + +export function isCustomChildElement( + item: unknown, +): item is CustomChildElement { + return ( + typeof item === "object" && + item !== null && + AY_CUSTOM_ELEMENT in item && + typeof (item as any)[AY_CUSTOM_ELEMENT] === "function" + ); +} + export type Child = + | CustomChildElement | string | boolean | number diff --git a/packages/core/test/rendering/basic.test.tsx b/packages/core/test/rendering/basic.test.tsx index 6e0ab6262..66a9affe8 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 { AY_CUSTOM_ELEMENT, Children } from "../../src/runtime/component.js"; import "../../testing/extend-expect.js"; describe("string nodes", () => { it("renders string nodes with substitutions", () => { @@ -49,6 +49,20 @@ describe("component nodes", () => { , ).toRenderTo("Str Str"); }); + + it("renders custom elements", () => { + const customElement = { + [AY_CUSTOM_ELEMENT]() { + return ( + <> + + + ); + }, + }; + + expect(customElement).toRenderTo("Str Str"); + }); }); describe("memo nodes", () => { From d747cf52f53e2865be1f93e85198758678f2bfbd Mon Sep 17 00:00:00 2001 From: Will Temple Date: Tue, 22 Jul 2025 19:04:01 -0400 Subject: [PATCH 2/4] chronus --- .../witemple-msft-custom-element-2025-6-22-19-3-51.md | 7 +++++++ 1 file changed, 7 insertions(+) create mode 100644 .chronus/changes/witemple-msft-custom-element-2025-6-22-19-3-51.md 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 From cc15a538cb763615c99801ba9d9c3b570279d022 Mon Sep 17 00:00:00 2001 From: Will Temple Date: Mon, 28 Jul 2025 12:44:36 -0400 Subject: [PATCH 3/4] Workshop the naming a little bit --- packages/core/src/render.ts | 10 ++++----- packages/core/src/runtime/component.ts | 16 ++++++-------- packages/core/test/rendering/basic.test.tsx | 24 +++++++++++++++++++-- 3 files changed, 34 insertions(+), 16 deletions(-) diff --git a/packages/core/src/render.ts b/packages/core/src/render.ts index deee47954..8c3a1a369 100644 --- a/packages/core/src/render.ts +++ b/packages/core/src/render.ts @@ -16,13 +16,13 @@ import { } from "./reactivity.js"; import { isRefkey } from "./refkey.js"; import { - AY_CUSTOM_ELEMENT, Child, Children, Component, isComponentCreator, - isCustomChildElement, + isRenderableObject, Props, + RENDERABLE, } from "./runtime/component.js"; import { IntrinsicElement, isIntrinsicElement } from "./runtime/intrinsic.js"; import { flushJobs } from "./scheduler.js"; @@ -519,8 +519,8 @@ function normalizeChild(child: Child): NormalizedChildren { return sfContext.reference({ refkey: child }); }; - } else if (isCustomChildElement(child)) { - return child[AY_CUSTOM_ELEMENT].bind(child); + } else if (isRenderableObject(child)) { + return child[RENDERABLE].bind(child); } else if (isCustomContext(child)) { return child; } else if (isIntrinsicElement(child)) { @@ -546,7 +546,7 @@ function debugPrintChild(child: Children): string { return "$ref"; } else if (isIntrinsicElement(child)) { return `<${child.name}>`; - } else if (isCustomChildElement(child)) { + } 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 04a64b828..69f5ac465 100644 --- a/packages/core/src/runtime/component.ts +++ b/packages/core/src/runtime/component.ts @@ -3,25 +3,23 @@ import { CustomContext } from "../reactivity.js"; import { Refkey } from "../refkey.js"; import { IntrinsicElement } from "./intrinsic.js"; -export const AY_CUSTOM_ELEMENT = Symbol.for("Alloy.CustomElement"); +export const RENDERABLE = Symbol.for("Alloy.CustomElement"); -export interface CustomChildElement { - [AY_CUSTOM_ELEMENT](): Children; +export interface RenderableObject { + [RENDERABLE](): Children; } -export function isCustomChildElement( - item: unknown, -): item is CustomChildElement { +export function isRenderableObject(item: unknown): item is RenderableObject { return ( typeof item === "object" && item !== null && - AY_CUSTOM_ELEMENT in item && - typeof (item as any)[AY_CUSTOM_ELEMENT] === "function" + RENDERABLE in item && + typeof (item as any)[RENDERABLE] === "function" ); } export type Child = - | CustomChildElement + | RenderableObject | string | boolean | number diff --git a/packages/core/test/rendering/basic.test.tsx b/packages/core/test/rendering/basic.test.tsx index 66a9affe8..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 { AY_CUSTOM_ELEMENT, 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", () => { @@ -52,7 +52,7 @@ describe("component nodes", () => { it("renders custom elements", () => { const customElement = { - [AY_CUSTOM_ELEMENT]() { + [RENDERABLE]() { return ( <> @@ -63,6 +63,26 @@ describe("component nodes", () => { 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", () => { From 67b617d6050fea59296cbe9cd11a7449f6869e1f Mon Sep 17 00:00:00 2001 From: Will Temple Date: Mon, 28 Jul 2025 12:52:29 -0400 Subject: [PATCH 4/4] Some docs and comments --- packages/core/src/render.ts | 1 + packages/core/src/runtime/component.ts | 15 +++++++++++++++ 2 files changed, 16 insertions(+) diff --git a/packages/core/src/render.ts b/packages/core/src/render.ts index 8c3a1a369..4de456ad8 100644 --- a/packages/core/src/render.ts +++ b/packages/core/src/render.ts @@ -520,6 +520,7 @@ 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; diff --git a/packages/core/src/runtime/component.ts b/packages/core/src/runtime/component.ts index 69f5ac465..53c13321f 100644 --- a/packages/core/src/runtime/component.ts +++ b/packages/core/src/runtime/component.ts @@ -5,10 +5,25 @@ 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" &&