First render still does a full mount and builds fiber tree. Subsequent renders reconcile the new UNode tree against existing fibers: compare props via prepareUpdate, apply changes via commitUpdate. Excess old children remain (structural changes deferred to Phase 2). Function components remain transparent during reconciliation.
185 lines
7.3 KiB
TypeScript
185 lines
7.3 KiB
TypeScript
import { describe, it, expect } from "vitest";
|
|
import { h, createComponent } from "../src/core/h.js";
|
|
import { createRoot as createHostRoot } from "../src/host/config.js";
|
|
import type { HostConfig } from "../src/host/config.js";
|
|
|
|
function makeHost(): {
|
|
host: HostConfig<string, string, Record<string, unknown>>;
|
|
instances: { tag: string; props: Record<string, unknown> }[];
|
|
texts: string[];
|
|
appends: { parent: string; child: string }[];
|
|
prepareUpdateCalls: { instance: string; tag: string; prevProps: Record<string, unknown>; nextProps: Record<string, unknown> }[];
|
|
commitUpdateCalls: { instance: string; payload: unknown; tag: string; prevProps: Record<string, unknown>; nextProps: Record<string, unknown> }[];
|
|
} {
|
|
const instances: { tag: string; props: Record<string, unknown> }[] = [];
|
|
const texts: string[] = [];
|
|
const appends: { parent: string; child: string }[] = [];
|
|
const prepareUpdateCalls: { instance: string; tag: string; prevProps: Record<string, unknown>; nextProps: Record<string, unknown> }[] = [];
|
|
const commitUpdateCalls: { instance: string; payload: unknown; tag: string; prevProps: Record<string, unknown>; nextProps: Record<string, unknown> }[] = [];
|
|
|
|
const host: HostConfig<string, string, Record<string, unknown>> = {
|
|
name: "test",
|
|
createRootContext: () => ({}),
|
|
createInstance: (tag, props) => {
|
|
const id = `${tag}_${instances.length}`;
|
|
instances.push({ tag, props });
|
|
return id;
|
|
},
|
|
createTextInstance: (text) => {
|
|
texts.push(text);
|
|
return text;
|
|
},
|
|
appendChild: (parent, child) => {
|
|
appends.push({ parent, child });
|
|
},
|
|
prepareUpdate: (instance, tag, prevProps, nextProps) => {
|
|
prepareUpdateCalls.push({ instance, tag, prevProps, nextProps });
|
|
const changed = Object.keys(nextProps).some(
|
|
(k) => prevProps[k] !== nextProps[k],
|
|
) || Object.keys(prevProps).some(
|
|
(k) => !(k in nextProps),
|
|
);
|
|
return changed ? { prevProps, nextProps } : null;
|
|
},
|
|
commitUpdate: (instance, payload, tag, prevProps, nextProps) => {
|
|
commitUpdateCalls.push({ instance, payload, tag, prevProps, nextProps });
|
|
},
|
|
};
|
|
return { host, instances, texts, appends, prepareUpdateCalls, commitUpdateCalls };
|
|
}
|
|
|
|
describe("Root.render() re-renderable", () => {
|
|
it("first render mounts and builds fiber tree", () => {
|
|
const { host, instances } = makeHost();
|
|
const root = createHostRoot(host, {});
|
|
root.render(h("div", { class: "a" }, "hello"));
|
|
|
|
expect(root.rootFiber).not.toBeNull();
|
|
expect(instances.length).toBe(1);
|
|
expect(instances[0]!.tag).toBe("div");
|
|
});
|
|
|
|
it("second render does not create duplicate instances", () => {
|
|
const { host, instances } = makeHost();
|
|
const root = createHostRoot(host, {});
|
|
root.render(h("div", { class: "a" }, "hello"));
|
|
const countAfterFirst = instances.length;
|
|
|
|
root.render(h("div", { class: "b" }, "hello"));
|
|
expect(instances.length).toBe(countAfterFirst);
|
|
});
|
|
|
|
it("second render produces one instance tree with updated props", () => {
|
|
const { host } = makeHost();
|
|
const root = createHostRoot(host, {});
|
|
root.render(h("div", { class: "a" }, "hello"));
|
|
|
|
const divFiber = root.rootFiber!.children[0]!;
|
|
expect(divFiber.props.class).toBe("a");
|
|
|
|
root.render(h("div", { class: "b" }, "hello"));
|
|
expect(divFiber.props.class).toBe("b");
|
|
});
|
|
|
|
it("prepareUpdate is called for changed props on re-render", () => {
|
|
const { host, prepareUpdateCalls } = makeHost();
|
|
const root = createHostRoot(host, {});
|
|
root.render(h("div", { class: "old" }, "hello"));
|
|
prepareUpdateCalls.length = 0;
|
|
|
|
root.render(h("div", { class: "new" }, "hello"));
|
|
expect(prepareUpdateCalls.length).toBeGreaterThanOrEqual(1);
|
|
const divCall = prepareUpdateCalls.find((c) => c.tag === "div");
|
|
expect(divCall).toBeDefined();
|
|
expect(divCall!.prevProps.class).toBe("old");
|
|
expect(divCall!.nextProps.class).toBe("new");
|
|
});
|
|
|
|
it("commitUpdate is called for changed props on re-render", () => {
|
|
const { host, commitUpdateCalls } = makeHost();
|
|
const root = createHostRoot(host, {});
|
|
root.render(h("div", { class: "old" }, "hello"));
|
|
commitUpdateCalls.length = 0;
|
|
|
|
root.render(h("div", { class: "new" }, "hello"));
|
|
expect(commitUpdateCalls.length).toBeGreaterThanOrEqual(1);
|
|
const divCall = commitUpdateCalls.find((c) => c.tag === "div");
|
|
expect(divCall).toBeDefined();
|
|
});
|
|
|
|
it("no commitUpdate when props are unchanged", () => {
|
|
const { host, commitUpdateCalls } = makeHost();
|
|
const root = createHostRoot(host, {});
|
|
root.render(h("div", { class: "same" }, "hello"));
|
|
commitUpdateCalls.length = 0;
|
|
|
|
root.render(h("div", { class: "same" }, "hello"));
|
|
const divCall = commitUpdateCalls.find((c) => c.tag === "div");
|
|
expect(divCall).toBeUndefined();
|
|
});
|
|
|
|
it("positional children matching: Nth old child matches Nth new child", () => {
|
|
const { host, commitUpdateCalls } = makeHost();
|
|
const root = createHostRoot(host, {});
|
|
root.render(h("div", null, h("span", { color: "red" }), h("em", { size: "big" })));
|
|
commitUpdateCalls.length = 0;
|
|
|
|
root.render(h("div", null, h("span", { color: "blue" }), h("em", { size: "small" })));
|
|
const spanCall = commitUpdateCalls.find((c) => c.tag === "span");
|
|
const emCall = commitUpdateCalls.find((c) => c.tag === "em");
|
|
expect(spanCall).toBeDefined();
|
|
expect(emCall).toBeDefined();
|
|
expect(spanCall!.nextProps.color).toBe("blue");
|
|
expect(emCall!.nextProps.size).toBe("small");
|
|
});
|
|
|
|
it("excess old children remain when new children are fewer", () => {
|
|
const { host, instances } = makeHost();
|
|
const root = createHostRoot(host, {});
|
|
root.render(h("div", null, h("span", null), h("em", null)));
|
|
const countAfterFirst = instances.length;
|
|
|
|
root.render(h("div", null, h("span", null)));
|
|
expect(instances.length).toBe(countAfterFirst);
|
|
expect(root.rootFiber!.children[0]!.children.length).toBe(2);
|
|
});
|
|
|
|
it("text node props are reconciled on re-render", () => {
|
|
const { host, prepareUpdateCalls } = makeHost();
|
|
const root = createHostRoot(host, {});
|
|
root.render(h("div", null, "hello"));
|
|
prepareUpdateCalls.length = 0;
|
|
|
|
root.render(h("div", null, "world"));
|
|
const textCall = prepareUpdateCalls.find((c) => c.tag === "#text");
|
|
expect(textCall).toBeDefined();
|
|
expect(textCall!.prevProps.text).toBe("hello");
|
|
expect(textCall!.nextProps.text).toBe("world");
|
|
});
|
|
|
|
it("function components are transparent during reconciliation", () => {
|
|
const { host, commitUpdateCalls } = makeHost();
|
|
const MyComp = createComponent("MyComp", (props) => h("section", { id: props.id as string }));
|
|
const root = createHostRoot(host, {});
|
|
root.render(h(MyComp, { id: "1" }));
|
|
commitUpdateCalls.length = 0;
|
|
|
|
root.render(h(MyComp, { id: "2" }));
|
|
const sectionCall = commitUpdateCalls.find((c) => c.tag === "section");
|
|
expect(sectionCall).toBeDefined();
|
|
expect(sectionCall!.nextProps.id).toBe("2");
|
|
});
|
|
|
|
it("existing tests still pass — rootFiber null before render", () => {
|
|
const { host } = makeHost();
|
|
const root = createHostRoot(host, {});
|
|
expect(root.rootFiber).toBeNull();
|
|
});
|
|
|
|
it("existing tests still pass — rootFiber set after render", () => {
|
|
const { host } = makeHost();
|
|
const root = createHostRoot(host, {});
|
|
root.render(h("div", null, "hello"));
|
|
expect(root.rootFiber).not.toBeNull();
|
|
});
|
|
}); |