Adds commitMutations function to reconcile.ts that processes fiber effects in correct order: removes (reverse), inserts+moves (left-to-right with insertBefore), updates (top-down). Integrates key-based reconciliation pipeline into render() via reconcileNode, resolveUNode, and createFiberForInsert.
184 lines
7.3 KiB
TypeScript
184 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 are removed when new children are fewer", () => {
|
|
const { host, instances } = makeHost();
|
|
const root = createHostRoot(host, {});
|
|
root.render(h("div", null, h("span", null), h("em", null)));
|
|
|
|
root.render(h("div", null, h("span", null)));
|
|
expect(root.rootFiber!.children[0]!.children.length).toBe(1);
|
|
expect(root.rootFiber!.children[0]!.children[0]!.tag).toBe("span");
|
|
});
|
|
|
|
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();
|
|
});
|
|
}); |