From 87ec672641df69c3640d1a48ee97299c51ae85a2 Mon Sep 17 00:00:00 2001 From: "glm-5.1" Date: Mon, 18 May 2026 16:46:15 +0000 Subject: [PATCH] Refactor mountNode to build fiber tree alongside host instances --- src/host/config.ts | 61 +++++++++++--- test/mount-fibers.test.ts | 166 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 216 insertions(+), 11 deletions(-) create mode 100644 test/mount-fibers.test.ts diff --git a/src/host/config.ts b/src/host/config.ts index bc3501b..af58b1f 100644 --- a/src/host/config.ts +++ b/src/host/config.ts @@ -1,6 +1,7 @@ import type { UNode, UElement, URoot, ComponentFn, UComponent } from "../core/schema.js"; import { isURoot, isUPrimitive } from "../core/schema.js"; import { Context } from "../core/context.js"; +import type { Fiber } from "./fiber.js"; export interface HostConfig { name: string; @@ -34,6 +35,7 @@ export interface Root { ctx: RootCtx; container: unknown; context: Context; + rootFiber: Fiber | null; render(node: UNode): void; unmount(): void; } @@ -47,46 +49,70 @@ export function createRoot( const ctx = host.createRootContext(container, options, context); const rootContext = context ?? new Context(); - function mountNode(node: UNode, parentInst?: Instance): Instance | undefined { + function mountNode(node: UNode, parentFiber: Fiber | null): Fiber | undefined { if (node == null || node === false) return undefined; if (isUPrimitive(node)) { const text = node === null ? "" : String(node); + const parentInst = parentFiber?.instance; const t = host.createTextInstance(text, ctx, parentInst); if (parentInst) host.appendChild(parentInst, t, ctx); host.emit?.("instance.create", `text_${Date.now()}`, { kind: "text", value: text }); - return t; + const fiber: Fiber = { + instance: t, + tag: "#text", + props: { text }, + key: undefined, + children: [], + parent: parentFiber, + effect: null, + signalDisposers: [], + prevProps: null, + }; + if (parentFiber) parentFiber.children.push(fiber); + return fiber; } if (isURoot(node)) { for (const child of node.children) { - mountNode(child, parentInst); + mountNode(child, parentFiber); } - return parentInst; + return parentFiber ?? undefined; } - // node must be a UElement here const el = node as UElement; - // Function component — type is a function (runtime-only, before resolution) if (typeof el.type === "function") { const component = el.type as ComponentFn; const out = component({ ...el.props, children: el.children }); host.emit?.("component.invoke", `comp_${Date.now()}`, { type: (component as unknown as UComponent).displayName ?? "anonymous" }); - return mountNode(out, parentInst); + return mountNode(out, parentFiber); } - // Intrinsic element const tag = el.type as TTag; + const parentInst = parentFiber?.instance; const inst = host.createInstance(tag, el.props as Record, ctx, parentInst); host.emit?.("instance.create", `${tag}_${Date.now()}`, { kind: "element", tag, props: el.props }); + const fiber: Fiber = { + instance: inst, + tag, + props: el.props as Record, + key: el.key, + children: [], + parent: parentFiber, + effect: null, + signalDisposers: [], + prevProps: null, + }; + for (const child of el.children) { - mountNode(child, inst); + mountNode(child, fiber); } if (parentInst) host.appendChild(parentInst, inst, ctx); - return inst; + if (parentFiber) parentFiber.children.push(fiber); + return fiber; } return { @@ -94,11 +120,24 @@ export function createRoot( ctx, container, context: rootContext, + rootFiber: null, render(node: UNode) { + const root: Fiber = { + instance: undefined as unknown as Instance, + tag: "#root", + props: {}, + key: undefined, + children: [], + parent: null, + effect: null, + signalDisposers: [], + prevProps: null, + }; const payloadChildren = isURoot(node) ? (node as URoot).children : [node]; for (const child of payloadChildren) { - mountNode(child, undefined); + mountNode(child, root); } + this.rootFiber = root; host.finalizeRoot?.(ctx); host.emit?.("root.render", `root_${Date.now()}`, { childCount: payloadChildren.length }); }, diff --git a/test/mount-fibers.test.ts b/test/mount-fibers.test.ts new file mode 100644 index 0000000..77d5e51 --- /dev/null +++ b/test/mount-fibers.test.ts @@ -0,0 +1,166 @@ +import { describe, it, expect } from "vitest"; +import { h, createComponent, createRoot } from "../src/core/h.js"; +import { createRoot as createHostRoot } from "../src/host/config.js"; +import type { HostConfig } from "../src/host/config.js"; +import type { Fiber } from "../src/host/fiber.js"; + +function makeHost(): { + host: HostConfig>; + instances: { tag: string; props: Record }[]; + texts: string[]; + appends: { parent: string; child: string }[]; +} { + const instances: { tag: string; props: Record }[] = []; + const texts: string[] = []; + const appends: { parent: string; child: string }[] = []; + const host: HostConfig> = { + name: "test", + createRootContext: () => ({}), + createInstance: (tag, props) => { + instances.push({ tag, props }); + return tag; + }, + createTextInstance: (text) => { + texts.push(text); + return text; + }, + appendChild: (parent, child) => { + appends.push({ parent, child }); + }, + }; + return { host, instances, texts, appends }; +} + +describe("mountWithFibers: rootFiber after render()", () => { + it("rootFiber is null before render", () => { + const { host } = makeHost(); + const root = createHostRoot(host, {}); + expect(root.rootFiber).toBeNull(); + }); + + it("rootFiber is set after render", () => { + const { host } = makeHost(); + const root = createHostRoot(host, {}); + root.render(h("div", null, "hello")); + expect(root.rootFiber).not.toBeNull(); + }); + + it("rootFiber has correct children for simple element with text", () => { + const { host } = makeHost(); + const root = createHostRoot(host, {}); + root.render(h("div", { class: "outer" }, "hello")); + + const rf = root.rootFiber!; + expect(rf.tag).toBe("#root"); + expect(rf.parent).toBeNull(); + expect(rf.children.length).toBe(1); + + const divFiber = rf.children[0]!; + expect(divFiber.tag).toBe("div"); + expect(divFiber.props.class).toBe("outer"); + expect(divFiber.parent).toBe(rf); + expect(divFiber.children.length).toBe(1); + + const textFiber = divFiber.children[0]!; + expect(textFiber.tag).toBe("#text"); + expect(textFiber.props.text).toBe("hello"); + expect(textFiber.parent).toBe(divFiber); + expect(textFiber.children.length).toBe(0); + }); + + it("primitives get text fibers with tag #text", () => { + const { host } = makeHost(); + const root = createHostRoot(host, {}); + root.render(h("p", null, "world")); + + const textFiber = root.rootFiber!.children[0]!.children[0]!; + expect(textFiber.tag).toBe("#text"); + expect(textFiber.instance).toBe("world"); + }); + + it("fiber tree linked with parent and children", () => { + const { host } = makeHost(); + const root = createHostRoot(host, {}); + root.render(h("div", null, h("span", null, "a"), h("em", null, "b"))); + + const divFiber = root.rootFiber!.children[0]!; + expect(divFiber.parent).toBe(root.rootFiber); + expect(divFiber.children.length).toBe(2); + + const spanFiber = divFiber.children[0]!; + const emFiber = divFiber.children[1]!; + expect(spanFiber.parent).toBe(divFiber); + expect(emFiber.parent).toBe(divFiber); + expect(spanFiber.tag).toBe("span"); + expect(emFiber.tag).toBe("em"); + }); + + it("function components are transparent — no fiber for component", () => { + const { host } = makeHost(); + const MyComp = createComponent("MyComp", (props) => h("section", null, props.text as string)); + const root = createHostRoot(host, {}); + root.render(h(MyComp, { text: "hi" })); + + const rf = root.rootFiber!; + expect(rf.children.length).toBe(1); + expect(rf.children[0]!.tag).toBe("section"); + expect(rf.children[0]!.parent).toBe(rf); + }); + + it("URoot children are mounted into parent fiber (root is transparent)", () => { + const { host } = makeHost(); + const root = createHostRoot(host, {}); + const uRoot = createRoot("r", h("div", null, "x"), h("span", null, "y")); + root.render(uRoot); + + const rf = root.rootFiber!; + expect(rf.children.length).toBe(2); + expect(rf.children[0]!.tag).toBe("div"); + expect(rf.children[1]!.tag).toBe("span"); + expect(rf.children[0]!.parent).toBe(rf); + expect(rf.children[1]!.parent).toBe(rf); + }); + + it("key is propagated to fiber", () => { + const { host } = makeHost(); + const root = createHostRoot(host, {}); + root.render(h("li", { key: "a" }, "item")); + + const liFiber = root.rootFiber!.children[0]!; + expect(liFiber.key).toBe("a"); + }); + + it("signalDisposers initialized as empty array on each fiber", () => { + const { host } = makeHost(); + const root = createHostRoot(host, {}); + root.render(h("div", null, "x")); + + const rf = root.rootFiber!; + expect(rf.signalDisposers).toEqual([]); + expect(rf.children[0]!.signalDisposers).toEqual([]); + expect(rf.children[0]!.children[0]!.signalDisposers).toEqual([]); + }); + + it("effect initialized as null on each fiber", () => { + const { host } = makeHost(); + const root = createHostRoot(host, {}); + root.render(h("div", null, "x")); + + const rf = root.rootFiber!; + expect(rf.effect).toBeNull(); + expect(rf.children[0]!.effect).toBeNull(); + expect(rf.children[0]!.children[0]!.effect).toBeNull(); + }); + + it("existing mount behavior preserved — hosts receive same createInstance/appendChild calls", () => { + const { host, instances, texts, appends } = makeHost(); + const root = createHostRoot(host, {}); + root.render(h("div", { class: "outer" }, "hello", h("span", null, "world"))); + + expect(instances.length).toBe(2); + expect(instances[0]!.tag).toBe("div"); + expect(instances[1]!.tag).toBe("span"); + expect(texts.sort()).toEqual(["hello", "world"]); + expect(appends.length).toBe(3); + }); +}); \ No newline at end of file