import { beforeEach, describe, it } from "@std/testing/bdd";
import { UI, VNode } from "@commontools/runner";
import { render, renderImpl } from "../src/render.ts";
import * as assert from "./assert.ts";
import { serializableEvent } from "../src/render.ts";
import { MockDoc } from "../src/mock-doc.ts";
import { h } from "../src/h.ts";
let mock: MockDoc;
class SynthesizedEvent extends Event {
constructor(name: string, props: object) {
super(name);
Object.assign(this, props);
}
}
class KeyboardEvent extends SynthesizedEvent {}
class InputEvent extends SynthesizedEvent {}
class MouseEvent extends SynthesizedEvent {}
beforeEach(() => {
mock = new MockDoc(
`
`,
);
});
describe("render", () => {
it("renders", () => {
const { renderOptions, document } = mock;
// dom and globals are set up by beforeEach
const renderable = h(
"div",
{ id: "hello" },
h("p", null, "Hello world!"),
);
const parent = document.getElementById("root")!;
render(parent, renderable, renderOptions);
assert.equal(
parent.getElementsByTagName("div")[0]!.getAttribute("id"),
"hello",
);
assert.equal(
parent.getElementsByTagName("p")[0]!.innerHTML,
"Hello world!",
);
});
});
describe("renderImpl", () => {
it("creates DOM for a simple VNode", () => {
const { renderOptions, document } = mock;
const vnode = {
type: "vnode" as const,
name: "span",
props: { id: "test-span" },
children: ["hi!"],
};
const parent = document.getElementById("root")!;
const cancel = renderImpl(parent, vnode, renderOptions);
const span = parent.getElementsByTagName("span")[0]!;
assert.equal(span.getAttribute("id"), "test-span");
assert.equal(span.innerHTML, "hi!");
cancel();
assert.equal(parent.getElementsByTagName("span").length, 0);
});
it("returns a cancel function that removes the node", () => {
const { renderOptions, document } = mock;
const vnode = {
type: "vnode" as const,
name: "span",
props: {},
children: [],
};
const parent = document.getElementById("root")!;
const cancel = renderImpl(parent, vnode, renderOptions);
assert.equal(parent.getElementsByTagName("span").length, 1);
cancel();
assert.equal(parent.getElementsByTagName("span").length, 0);
});
it("handles null/invalid VNode by not appending anything", () => {
const { renderOptions, document } = mock;
const invalidVNode = {
type: "not-vnode",
name: "div",
props: {},
children: [],
};
const parent = document.getElementById("root")!;
const cancel = renderImpl(parent, invalidVNode as VNode, renderOptions);
assert.equal(parent.children.length, 0);
cancel();
});
it("renders only the [UI] nested vdom when both [UI] and top-level vdom are present", () => {
const { renderOptions, document } = mock;
// The [UI] property should take precedence over the top-level vdom
const nestedVNode = {
type: "vnode" as const,
name: "span",
props: { id: "nested" },
children: ["nested!"],
};
const topLevelVNode = {
type: "vnode" as const,
name: "div",
props: { id: "top" },
children: ["top!"],
};
// Compose an object with both vdom and [UI]
const vdomWithUI = {
...topLevelVNode,
[UI]: nestedVNode,
};
const parent = document.getElementById("root")!;
const cancel = renderImpl(parent, vdomWithUI, renderOptions);
// Only the nestedVNode should be rendered
const span = parent.getElementsByTagName("span")[0]!;
const div = document.getElementById("top");
assert.equal(span.getAttribute("id"), "nested");
assert.equal(span.innerHTML, "nested!");
assert.equal(div, null);
cancel();
assert.equal(parent.children.length, 0);
});
it("does not render false as text content", () => {
const { renderOptions, document } = mock;
const vnode = {
type: "vnode" as const,
name: "div",
props: {},
children: [false, "visible", null, undefined, true],
} as VNode;
const parent = document.getElementById("root")!;
const cancel = renderImpl(parent, vnode, renderOptions);
const div = parent.getElementsByTagName("div")[0]!;
// false, null, and undefined should render as empty text nodes
// true should render as "true"
// So innerHTML should be "visibletrue" (no "false")
assert.equal(div.innerHTML, "visibletrue");
cancel();
});
});
describe("serializableEvent", () => {
function isPlainSerializableObject(obj: unknown): boolean {
if (typeof obj !== "object" || obj === null) return true; // primitives are serializable
if (Array.isArray(obj)) {
return obj.every(isPlainSerializableObject);
}
if (Object.getPrototypeOf(obj) !== Object.prototype) return false;
for (const key in obj) {
const value = (obj as Record)[key];
if (typeof value === "function") return false;
if (!isPlainSerializableObject(value)) return false;
}
return true;
}
it("serializes a basic Event", () => {
const event = new Event("test");
const result = serializableEvent(event);
assert.matchObject(result, { type: "test" });
assert.equal(
isPlainSerializableObject(result),
true,
"Result should be a plain serializable object",
);
// Should not include non-allow-listed fields
assert.equal(
"timeStamp" in (result as object),
false,
"Should not include timeStamp",
);
});
it("serializes a KeyboardEvent", () => {
const event = new KeyboardEvent("keydown", {
key: "a",
code: "KeyA",
repeat: true,
altKey: true,
ctrlKey: false,
metaKey: true,
shiftKey: false,
});
const result = serializableEvent(event);
assert.matchObject(result, {
type: "keydown",
key: "a",
code: "KeyA",
repeat: true,
altKey: true,
ctrlKey: false,
metaKey: true,
shiftKey: false,
});
assert.equal(
isPlainSerializableObject(result),
true,
"Result should be a plain serializable object",
);
assert.equal(
"timeStamp" in (result as object),
false,
"Should not include timeStamp",
);
});
it("serializes a MouseEvent", () => {
const event = new MouseEvent("click", {
button: 0,
buttons: 1,
altKey: false,
ctrlKey: true,
metaKey: false,
shiftKey: true,
});
const result = serializableEvent(event);
assert.matchObject(result, {
type: "click",
button: 0,
buttons: 1,
altKey: false,
ctrlKey: true,
metaKey: false,
shiftKey: true,
});
assert.equal(
isPlainSerializableObject(result),
true,
"Result should be a plain serializable object",
);
assert.equal(
"timeStamp" in (result as object),
false,
"Should not include timeStamp",
);
});
it("serializes an InputEvent with target value", () => {
const { document } = mock;
const input = document.createElement("input");
input.value = "hello";
input.id = "should-not-appear";
const event = new InputEvent("input", {
data: "h",
inputType: "insertText",
});
Object.defineProperty(event, "target", { value: input });
const result = serializableEvent(event) as object;
assert.matchObject(result, {
type: "input",
data: "h",
inputType: "insertText",
target: { value: "hello" },
});
assert.equal(
isPlainSerializableObject(result),
true,
"Result should be a plain serializable object",
);
assert.equal(
"timeStamp" in result,
false,
"Should not include timeStamp",
);
assert.equal(
!!("target" in result && typeof result.target === "object" &&
result.target && "id" in result.target),
false,
"Should not include id on target",
);
});
it("serializes a CustomEvent with detail", () => {
const event = new CustomEvent("custom", { detail: { foo: [42, 43] } });
const result = serializableEvent(event) as object;
assert.matchObject(result, {
type: "custom",
detail: { foo: [42, 43] },
});
assert.equal(
isPlainSerializableObject(result),
true,
"Result should be a plain serializable object",
);
assert.equal(
"timeStamp" in result,
false,
"Should not include timeStamp",
);
});
it("serializes an event with HTMLSelectElement target and selectedOptions", () => {
const { document } = mock;
const select = document.createElement("select");
select.multiple = true;
select.id = "should-not-appear";
// Create option elements
const option1 = document.createElement("option");
option1.value = "option1";
option1.text = "Option 1";
const option2 = document.createElement("option");
option2.value = "option2";
option2.text = "Option 2";
const option3 = document.createElement("option");
option3.value = "option3";
option3.text = "Option 3";
select.appendChild(option1);
select.appendChild(option2);
select.appendChild(option3);
// Select multiple options
option1.selected = true;
option3.selected = true;
// @ts-ignore: These aren't real HTMLSelectElements,
// synthesize selectedOptions
(select as HTMLSelectElement).selectedOptions = [option1, option3];
const event = new Event("change");
Object.defineProperty(event, "target", { value: select });
const result = serializableEvent(event) as object;
assert.matchObject(result, {
type: "change",
target: {
selectedOptions: [
{ value: "option1" },
{ value: "option3" },
],
},
});
assert.equal(
isPlainSerializableObject(result),
true,
"Result should be a plain serializable object",
);
assert.equal(
"timeStamp" in result,
false,
"Should not include timeStamp",
);
assert.equal(
!!("target" in result && typeof result.target === "object" &&
result.target && "id" in result.target),
false,
"Should not include id on target",
);
});
it("serializes an event with single-select HTMLSelectElement target", () => {
const { document } = mock;
const select = document.createElement("select");
select.multiple = false; // single select
select.id = "should-not-appear";
// Create option elements
const option1 = document.createElement("option");
option1.value = "option1";
option1.text = "Option 1";
const option2 = document.createElement("option");
option2.value = "option2";
option2.text = "Option 2";
select.appendChild(option1);
select.appendChild(option2);
// Select single option
option2.selected = true;
// @ts-ignore: These aren't real HTMLSelectElements,
// synthesize selectedOptions
(select as HTMLSelectElement).selectedOptions = [option2];
const event = new Event("change");
Object.defineProperty(event, "target", { value: select });
const result = serializableEvent(event) as object;
assert.matchObject(result, {
type: "change",
target: {
selectedOptions: [
{ value: "option2" },
],
},
});
assert.equal(
isPlainSerializableObject(result),
true,
"Result should be a plain serializable object",
);
assert.equal(
"timeStamp" in result,
false,
"Should not include timeStamp",
);
assert.equal(
!!("target" in result && typeof result.target === "object" &&
result.target && "id" in result.target),
false,
"Should not include id on target",
);
});
it("serializes an event with target.dataset", () => {
const { document } = mock;
const div = document.createElement("div");
div.setAttribute("data-id", "123");
div.setAttribute("data-name", "test");
const event = new Event("click");
Object.defineProperty(event, "target", { value: div });
const result = serializableEvent(event) as object;
assert.matchObject(result, {
type: "click",
target: {
dataset: {
id: "123",
name: "test",
},
},
});
assert.equal(
isPlainSerializableObject(result),
true,
"Result should be a plain serializable object",
);
});
});
describe("style object support", () => {
it("converts React-style object to CSS string", () => {
const { renderOptions, document } = mock;
const vnode = {
type: "vnode" as const,
name: "div",
props: {
style: {
backgroundColor: "red",
fontSize: 16,
padding: "10px",
},
},
children: [],
};
const parent = document.getElementById("root")!;
const cancel = renderImpl(parent, vnode, renderOptions);
const div = parent.getElementsByTagName("div")[0]!;
const style = div.getAttribute("style");
assert.equal(
style?.includes("background-color: red"),
true,
"Should convert backgroundColor to background-color",
);
assert.equal(
style?.includes("font-size: 16px"),
true,
"Should add px to numeric fontSize",
);
assert.equal(
style?.includes("padding: 10px"),
true,
"Should preserve string values",
);
cancel();
});
it("handles unitless numeric properties", () => {
const { renderOptions, document } = mock;
const vnode = {
type: "vnode" as const,
name: "div",
props: {
style: {
opacity: 0.5,
zIndex: 10,
flex: 1,
flexGrow: 1,
lineHeight: 1.5,
},
},
children: [],
};
const parent = document.getElementById("root")!;
const cancel = renderImpl(parent, vnode, renderOptions);
const div = parent.getElementsByTagName("div")[0]!;
const style = div.getAttribute("style");
assert.equal(
style?.includes("opacity: 0.5"),
true,
"opacity should be unitless",
);
assert.equal(
style?.includes("z-index: 10"),
true,
"z-index should be unitless",
);
assert.equal(style?.includes("flex: 1"), true, "flex should be unitless");
assert.equal(
style?.includes("flex-grow: 1"),
true,
"flex-grow should be unitless",
);
assert.equal(
style?.includes("line-height: 1.5"),
true,
"line-height should be unitless",
);
cancel();
});
it("handles vendor prefixes", () => {
const { renderOptions, document } = mock;
const vnode = {
type: "vnode" as const,
name: "div",
props: {
style: {
WebkitTransform: "rotate(45deg)",
MozAppearance: "none",
msUserSelect: "none",
},
},
children: [],
};
const parent = document.getElementById("root")!;
const cancel = renderImpl(parent, vnode, renderOptions);
const div = parent.getElementsByTagName("div")[0]!;
const style = div.getAttribute("style");
assert.equal(
style?.includes("-webkit-transform: rotate(45deg)"),
true,
"Should handle WebkitTransform",
);
assert.equal(
style?.includes("-moz-appearance: none"),
true,
"Should handle MozAppearance",
);
assert.equal(
style?.includes("-ms-user-select: none"),
true,
"Should handle msUserSelect",
);
cancel();
});
it("handles zero values without units", () => {
const { renderOptions, document } = mock;
const vnode = {
type: "vnode" as const,
name: "div",
props: {
style: {
margin: 0,
padding: 0,
},
},
children: [],
};
const parent = document.getElementById("root")!;
const cancel = renderImpl(parent, vnode, renderOptions);
const div = parent.getElementsByTagName("div")[0]!;
const style = div.getAttribute("style");
assert.equal(
style?.includes("margin: 0"),
true,
"Should handle zero margin",
);
assert.equal(
style?.includes("padding: 0"),
true,
"Should handle zero padding",
);
cancel();
});
it("handles null and undefined values", () => {
const { renderOptions, document } = mock;
const vnode = {
type: "vnode" as const,
name: "div",
props: {
style: {
color: "blue",
backgroundColor: null,
fontSize: undefined,
},
},
children: [],
};
const parent = document.getElementById("root")!;
const cancel = renderImpl(parent, vnode, renderOptions);
const div = parent.getElementsByTagName("div")[0]!;
const style = div.getAttribute("style");
assert.equal(style?.includes("color: blue"), true, "Should include color");
assert.equal(
style?.includes("background-color"),
false,
"Should skip null values",
);
assert.equal(
style?.includes("font-size"),
false,
"Should skip undefined values",
);
cancel();
});
it("handles complex CSS values", () => {
const { renderOptions, document } = mock;
const vnode = {
type: "vnode" as const,
name: "div",
props: {
style: {
boxShadow: "0 4px 6px rgba(0, 0, 0, 0.1)",
backgroundImage: "linear-gradient(to right, red, blue)",
transform: "translate3d(10px, 20px, 0)",
},
},
children: [],
};
const parent = document.getElementById("root")!;
const cancel = renderImpl(parent, vnode, renderOptions);
const div = parent.getElementsByTagName("div")[0]!;
const style = div.getAttribute("style");
assert.equal(
style?.includes("box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1)"),
true,
"Should handle box-shadow",
);
assert.equal(
style?.includes("background-image: linear-gradient(to right, red, blue)"),
true,
"Should handle gradients",
);
assert.equal(
style?.includes("transform: translate3d(10px, 20px, 0)"),
true,
"Should handle transforms",
);
cancel();
});
it("handles style object alongside other props", () => {
const { renderOptions, document } = mock;
const vnode = {
type: "vnode" as const,
name: "div",
props: {
id: "styled-div",
className: "test-class",
style: {
color: "red",
fontSize: 14,
},
"data-test": "value",
},
children: [],
};
const parent = document.getElementById("root")!;
const cancel = renderImpl(parent, vnode, renderOptions);
const div = parent.getElementsByTagName("div")[0]!;
assert.equal(div.getAttribute("id"), "styled-div");
// Use getAttribute for className in mock DOM
// @ts-ignore: attribs exists on mock element
assert.equal(div.attribs.className, "test-class");
assert.equal(div.getAttribute("data-test"), "value");
const style = div.getAttribute("style");
assert.equal(style?.includes("color: red"), true);
assert.equal(style?.includes("font-size: 14px"), true);
cancel();
});
it("handles empty style object", () => {
const { renderOptions, document } = mock;
const vnode = {
type: "vnode" as const,
name: "div",
props: {
style: {},
},
children: [],
};
const parent = document.getElementById("root")!;
const cancel = renderImpl(parent, vnode, renderOptions);
const div = parent.getElementsByTagName("div")[0]!;
const style = div.getAttribute("style");
assert.equal(style, "", "Empty style object should result in empty string");
cancel();
});
it("handles CSS custom properties (variables) without adding px", () => {
const { renderOptions, document } = mock;
const vnode = {
type: "vnode" as const,
name: "div",
props: {
style: {
"--scale": 2,
"--opacity": 0.5,
"--columns": 3,
"--primary-color": "#ff0000",
"--MyAccent": "blue",
"--THEME-Color": "green",
},
},
children: [],
};
const parent = document.getElementById("root")!;
const cancel = renderImpl(parent, vnode, renderOptions);
const div = parent.getElementsByTagName("div")[0]!;
const style = div.getAttribute("style");
assert.equal(
style?.includes("--scale: 2"),
true,
"CSS variables should not get px suffix",
);
assert.equal(
style?.includes("--opacity: 0.5"),
true,
"CSS variables should preserve decimal values",
);
assert.equal(
style?.includes("--columns: 3"),
true,
"CSS variables should preserve numeric values",
);
assert.equal(
style?.includes("--primary-color: #ff0000"),
true,
"CSS variables should preserve string values",
);
assert.equal(
style?.includes("--MyAccent: blue"),
true,
"CSS variables should preserve case sensitivity",
);
assert.equal(
style?.includes("--THEME-Color: green"),
true,
"CSS variables should preserve mixed case",
);
cancel();
});
});
describe("dataset attributes", () => {
it("sets data-* attributes using setAttribute", () => {
const { renderOptions, document } = mock;
const vnode = {
type: "vnode" as const,
name: "div",
props: {
"data-id": "123",
"data-name": "test",
},
children: [],
};
const parent = document.getElementById("root")!;
const cancel = renderImpl(parent, vnode, renderOptions);
const div = parent.getElementsByTagName("div")[0]!;
assert.equal(div.getAttribute("data-id"), "123");
assert.equal(div.getAttribute("data-name"), "test");
// @ts-ignore: dataset exists on Element in real DOM
assert.equal(div.dataset.id, "123");
// @ts-ignore: dataset exists on Element in real DOM
assert.equal(div.dataset.name, "test");
cancel();
});
it("removes data-* attributes when value is null", () => {
const { renderOptions, document } = mock;
const parent = document.getElementById("root")!;
// First render with data attribute
const vnode1 = {
type: "vnode" as const,
name: "div",
props: {
"data-id": "123",
},
children: [],
};
const cancel1 = renderImpl(parent, vnode1, renderOptions);
const div = parent.getElementsByTagName("div")[0]!;
assert.equal(div.getAttribute("data-id"), "123");
cancel1();
// Re-render with null value
const vnode2 = {
type: "vnode" as const,
name: "div",
props: {
"data-id": null,
},
children: [],
};
const cancel2 = renderImpl(parent, vnode2, renderOptions);
const div2 = parent.getElementsByTagName("div")[0]!;
assert.equal(div2.hasAttribute("data-id"), false);
cancel2();
});
it("removes data-* attributes when value is undefined", () => {
const { renderOptions, document } = mock;
const parent = document.getElementById("root")!;
// First render with data attribute
const vnode1 = {
type: "vnode" as const,
name: "div",
props: {
"data-id": "123",
},
children: [],
};
const cancel1 = renderImpl(parent, vnode1, renderOptions);
const div = parent.getElementsByTagName("div")[0]!;
assert.equal(div.getAttribute("data-id"), "123");
cancel1();
// Re-render with undefined value
// @ts-ignore: Testing undefined handling even though it's not in Props type
const vnode2 = {
type: "vnode" as const,
name: "div",
props: {
"data-id": undefined,
},
children: [],
} as VNode;
const cancel2 = renderImpl(parent, vnode2, renderOptions);
const div2 = parent.getElementsByTagName("div")[0]!;
assert.equal(div2.hasAttribute("data-id"), false);
cancel2();
});
it("converts non-string values to strings for data-* attributes", () => {
const { renderOptions, document } = mock;
const vnode = {
type: "vnode" as const,
name: "div",
props: {
"data-count": 42,
"data-enabled": true,
"data-items": ["a", "b", "c"],
},
children: [],
};
const parent = document.getElementById("root")!;
const cancel = renderImpl(parent, vnode, renderOptions);
const div = parent.getElementsByTagName("div")[0]!;
assert.equal(div.getAttribute("data-count"), "42");
assert.equal(div.getAttribute("data-enabled"), "true");
assert.equal(div.getAttribute("data-items"), "a,b,c");
cancel();
});
it("updates data-* attributes when value changes", () => {
const { renderOptions, document } = mock;
const parent = document.getElementById("root")!;
// First render
const vnode1 = {
type: "vnode" as const,
name: "div",
props: {
"data-id": "123",
},
children: [],
};
const cancel1 = renderImpl(parent, vnode1, renderOptions);
const div = parent.getElementsByTagName("div")[0]!;
assert.equal(div.getAttribute("data-id"), "123");
cancel1();
// Update with new value
const vnode2 = {
type: "vnode" as const,
name: "div",
props: {
"data-id": "456",
},
children: [],
};
const cancel2 = renderImpl(parent, vnode2, renderOptions);
const div2 = parent.getElementsByTagName("div")[0]!;
assert.equal(div2.getAttribute("data-id"), "456");
cancel2();
});
});
describe("cycle detection", () => {
it("detects direct [UI] self-reference cycle and skips rendering", () => {
const { renderOptions, document } = mock;
// Create a VNode that references itself via [UI]
const selfRefNode: VNode = {
type: "vnode" as const,
name: "div",
props: { id: "self-ref" },
children: [],
};
// Create cycle: node[UI] points to itself
(selfRefNode as any)[UI] = selfRefNode;
const parent = document.getElementById("root")!;
const cancel = renderImpl(parent, selfRefNode, renderOptions);
// Cyclic [UI] references are detected by isVNode and nothing is rendered
assert.equal(parent.children.length, 0);
cancel();
});
it("detects indirect [UI] chain cycle (A -> B -> A) and skips rendering", () => {
const { renderOptions, document } = mock;
// Create two VNodes that reference each other via [UI]
const nodeA: VNode = {
type: "vnode" as const,
name: "div",
props: { id: "node-a" },
children: [],
};
const nodeB: VNode = {
type: "vnode" as const,
name: "span",
props: { id: "node-b" },
children: [],
};
// Create cycle: A[UI] -> B[UI] -> A
(nodeA as any)[UI] = nodeB;
(nodeB as any)[UI] = nodeA;
const parent = document.getElementById("root")!;
const cancel = renderImpl(parent, nodeA, renderOptions);
// Cyclic [UI] references are detected by isVNode and nothing is rendered
assert.equal(parent.children.length, 0);
cancel();
});
it("detects cycle when child VNode references parent", () => {
const { renderOptions, document } = mock;
// Create parent that has itself as a child
const parentNode: VNode = {
type: "vnode" as const,
name: "div",
props: { id: "parent" },
children: [], // will be set below
};
// Child references parent, creating a cycle
parentNode.children = [parentNode];
const parent = document.getElementById("root")!;
const cancel = renderImpl(parent, parentNode, renderOptions);
// Should render the parent div with cycle placeholder as child
const div = parent.getElementsByTagName("div")[0];
assert.equal(div?.getAttribute("id"), "parent");
const span = div?.getElementsByTagName("span")[0];
assert.equal(span?.textContent, "🔄");
cancel();
});
it("allows the same VNode object as siblings (not a cycle)", () => {
const { renderOptions, document } = mock;
// Create a VNode that appears twice as siblings
const sharedChild: VNode = {
type: "vnode" as const,
name: "span",
props: { className: "shared" },
children: ["shared content"],
};
const parentNode: VNode = {
type: "vnode" as const,
name: "div",
props: {},
children: [sharedChild, sharedChild], // same object twice
};
const parent = document.getElementById("root")!;
const cancel = renderImpl(parent, parentNode, renderOptions);
// Should render both siblings without cycle detection
const spans = parent.getElementsByTagName("span");
assert.equal(spans.length, 2);
// Check via innerHTML that neither is a cycle placeholder
assert.equal(
parent.innerHTML.includes("🔄"),
false,
"Should not contain cycle placeholder",
);
cancel();
});
it("allows valid [UI] chain without cycle", () => {
const { renderOptions, document } = mock;
// Create a valid [UI] chain: A[UI] -> B[UI] -> C (no cycle)
const nodeC: VNode = {
type: "vnode" as const,
name: "span",
props: { id: "final" },
children: ["final content"],
};
const nodeB: VNode = {
type: "vnode" as const,
name: "div",
props: { id: "node-b" },
children: [],
[UI]: nodeC,
} as VNode;
const nodeA: VNode = {
type: "vnode" as const,
name: "div",
props: { id: "node-a" },
children: [],
[UI]: nodeB,
} as VNode;
const parent = document.getElementById("root")!;
const cancel = renderImpl(parent, nodeA, renderOptions);
// Should render nodeC (the final node in the [UI] chain)
const span = parent.getElementsByTagName("span")[0];
assert.equal(span?.getAttribute("id"), "final");
// Check via innerHTML since textContent doesn't work in MockDoc
assert.equal(parent.innerHTML.includes("final content"), true);
cancel();
});
it("detects deep nested cycle (grandchild references grandparent)", () => {
const { renderOptions, document } = mock;
// Create: grandparent -> parent -> child -> grandparent (cycle)
const grandparent: VNode = {
type: "vnode" as const,
name: "div",
props: { id: "grandparent" },
children: [],
};
const parentNode: VNode = {
type: "vnode" as const,
name: "div",
props: { id: "parent" },
children: [],
};
// Create cycle: grandparent -> parent -> grandparent
grandparent.children = [parentNode];
parentNode.children = [grandparent];
const parent = document.getElementById("root")!;
const cancel = renderImpl(parent, grandparent, renderOptions);
// Should render grandparent and parent, with cycle detected at the child level
const divs = parent.getElementsByTagName("div");
assert.equal(divs.length, 2);
// The innermost should have the cycle placeholder
const innerDiv = divs[1];
const span = innerDiv?.getElementsByTagName("span")[0];
assert.equal(span?.textContent, "🔄");
cancel();
});
});