Refactor mountNode to build fiber tree alongside host instances
This commit is contained in:
@@ -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
166
test/mount-fibers.test.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user