Refactor mountNode to build fiber tree alongside host instances

This commit is contained in:
2026-05-18 16:46:15 +00:00
parent d472b9f107
commit 87ec672641
2 changed files with 216 additions and 11 deletions

View File

@@ -1,6 +1,7 @@
import type { UNode, UElement, URoot, ComponentFn, UComponent } from "../core/schema.js"; import type { UNode, UElement, URoot, ComponentFn, UComponent } from "../core/schema.js";
import { isURoot, isUPrimitive } from "../core/schema.js"; import { isURoot, isUPrimitive } from "../core/schema.js";
import { Context } from "../core/context.js"; import { Context } from "../core/context.js";
import type { Fiber } from "./fiber.js";
export interface HostConfig<TTag extends string, Instance, RootCtx> { export interface HostConfig<TTag extends string, Instance, RootCtx> {
name: string; name: string;
@@ -34,6 +35,7 @@ export interface Root<TTag extends string, Instance, RootCtx> {
ctx: RootCtx; ctx: RootCtx;
container: unknown; container: unknown;
context: Context; context: Context;
rootFiber: Fiber<Instance> | null;
render(node: UNode): void; render(node: UNode): void;
unmount(): void; unmount(): void;
} }
@@ -47,46 +49,70 @@ export function createRoot<TTag extends string, Instance, RootCtx>(
const ctx = host.createRootContext(container, options, context); const ctx = host.createRootContext(container, options, context);
const rootContext = context ?? new Context(); const rootContext = context ?? new Context();
function mountNode(node: UNode, parentInst?: Instance): Instance | undefined { function mountNode(node: UNode, parentFiber: Fiber<Instance> | null): Fiber<Instance> | undefined {
if (node == null || node === false) return undefined; if (node == null || node === false) return undefined;
if (isUPrimitive(node)) { if (isUPrimitive(node)) {
const text = node === null ? "" : String(node); const text = node === null ? "" : String(node);
const parentInst = parentFiber?.instance;
const t = host.createTextInstance(text, ctx, parentInst); const t = host.createTextInstance(text, ctx, parentInst);
if (parentInst) host.appendChild(parentInst, t, ctx); if (parentInst) host.appendChild(parentInst, t, ctx);
host.emit?.("instance.create", `text_${Date.now()}`, { kind: "text", value: text }); host.emit?.("instance.create", `text_${Date.now()}`, { kind: "text", value: text });
return t; const fiber: Fiber<Instance> = {
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)) { if (isURoot(node)) {
for (const child of node.children) { 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; const el = node as UElement;
// Function component — type is a function (runtime-only, before resolution)
if (typeof el.type === "function") { if (typeof el.type === "function") {
const component = el.type as ComponentFn; const component = el.type as ComponentFn;
const out = component({ ...el.props, children: el.children }); const out = component({ ...el.props, children: el.children });
host.emit?.("component.invoke", `comp_${Date.now()}`, { type: (component as unknown as UComponent).displayName ?? "anonymous" }); 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 tag = el.type as TTag;
const parentInst = parentFiber?.instance;
const inst = host.createInstance(tag, el.props as Record<string, unknown>, ctx, parentInst); const inst = host.createInstance(tag, el.props as Record<string, unknown>, ctx, parentInst);
host.emit?.("instance.create", `${tag}_${Date.now()}`, { kind: "element", tag, props: el.props }); host.emit?.("instance.create", `${tag}_${Date.now()}`, { kind: "element", tag, props: el.props });
const fiber: Fiber<Instance> = {
instance: inst,
tag,
props: el.props as Record<string, unknown>,
key: el.key,
children: [],
parent: parentFiber,
effect: null,
signalDisposers: [],
prevProps: null,
};
for (const child of el.children) { for (const child of el.children) {
mountNode(child, inst); mountNode(child, fiber);
} }
if (parentInst) host.appendChild(parentInst, inst, ctx); if (parentInst) host.appendChild(parentInst, inst, ctx);
return inst; if (parentFiber) parentFiber.children.push(fiber);
return fiber;
} }
return { return {
@@ -94,11 +120,24 @@ export function createRoot<TTag extends string, Instance, RootCtx>(
ctx, ctx,
container, container,
context: rootContext, context: rootContext,
rootFiber: null,
render(node: UNode) { render(node: UNode) {
const root: Fiber<Instance> = {
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]; const payloadChildren = isURoot(node) ? (node as URoot).children : [node];
for (const child of payloadChildren) { for (const child of payloadChildren) {
mountNode(child, undefined); mountNode(child, root);
} }
this.rootFiber = root;
host.finalizeRoot?.(ctx); host.finalizeRoot?.(ctx);
host.emit?.("root.render", `root_${Date.now()}`, { childCount: payloadChildren.length }); host.emit?.("root.render", `root_${Date.now()}`, { childCount: payloadChildren.length });
}, },

166
test/mount-fibers.test.ts Normal file
View File

@@ -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<string, string, Record<string, unknown>>;
instances: { tag: string; props: Record<string, unknown> }[];
texts: string[];
appends: { parent: string; child: string }[];
} {
const instances: { tag: string; props: Record<string, unknown> }[] = [];
const texts: string[] = [];
const appends: { parent: string; child: string }[] = [];
const host: HostConfig<string, string, Record<string, unknown>> = {
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);
});
});