Add TypeBox Value.Equal deep-comparison as first optimization layer in reconcileProps. When a fiber's cached node is deep-equal to the next node, skip prepareUpdate, commitUpdate, and children reconciliation entirely. New cachedNode field on Fiber stores the last reconciled node for comparison.
383 lines
14 KiB
TypeScript
383 lines
14 KiB
TypeScript
import { describe, it, expect, vi } from "vitest";
|
|
import { h } from "../src/core/h.js";
|
|
import { createRoot as createHostRoot } from "../src/host/config.js";
|
|
import type { HostConfig } from "../src/host/config.js";
|
|
import { commitMutations, reconcileChildren } from "../src/host/reconcile.js";
|
|
import type { CommitContext, ChildClassification } from "../src/host/reconcile.js";
|
|
import type { Fiber } from "../src/host/fiber.js";
|
|
|
|
function makeHost(): {
|
|
host: HostConfig<string, string, Record<string, unknown>>;
|
|
operations: string[];
|
|
instances: { tag: string; props: Record<string, unknown> }[];
|
|
} {
|
|
const operations: string[] = [];
|
|
const instances: { tag: string; props: 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) => {
|
|
return `text:${text}`;
|
|
},
|
|
appendChild: (parent, child) => {
|
|
operations.push(`appendChild(${parent}, ${child})`);
|
|
},
|
|
insertBefore: (parent, child, before) => {
|
|
operations.push(`insertBefore(${parent}, ${child}, ${before})`);
|
|
},
|
|
removeChild: (parent, child) => {
|
|
operations.push(`removeChild(${parent}, ${child})`);
|
|
},
|
|
prepareUpdate: (_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) => {
|
|
operations.push(`commitUpdate(${instance})`);
|
|
},
|
|
};
|
|
return { host, operations, instances };
|
|
}
|
|
|
|
describe("commitMutations", () => {
|
|
it("adding a child calls host.appendChild", () => {
|
|
const { host, operations } = makeHost();
|
|
const root = createHostRoot(host, {});
|
|
root.render(h("ul", null, h("li", { key: "a" }, "A")));
|
|
|
|
operations.length = 0;
|
|
root.render(h("ul", null, h("li", { key: "a" }, "A"), h("li", { key: "b" }, "B")));
|
|
|
|
expect(operations.some(op => op.includes("appendChild") || op.includes("insertBefore"))).toBe(true);
|
|
});
|
|
|
|
it("removing a child calls host.removeChild", () => {
|
|
const { host, operations } = makeHost();
|
|
const root = createHostRoot(host, {});
|
|
root.render(h("ul", null, h("li", { key: "a" }, "A"), h("li", { key: "b" }, "B")));
|
|
|
|
operations.length = 0;
|
|
root.render(h("ul", null, h("li", { key: "a" }, "A")));
|
|
|
|
expect(operations.some(op => op.includes("removeChild"))).toBe(true);
|
|
});
|
|
|
|
it("reordering children calls host.insertBefore for moved children", () => {
|
|
const { host, operations } = makeHost();
|
|
const root = createHostRoot(host, {});
|
|
root.render(h("ul", null, h("li", { key: "a" }, "A"), h("li", { key: "b" }, "B"), h("li", { key: "c" }, "C")));
|
|
|
|
operations.length = 0;
|
|
root.render(h("ul", null, h("li", { key: "c" }, "C"), h("li", { key: "a" }, "A"), h("li", { key: "b" }, "B")));
|
|
|
|
expect(operations.some(op => op.includes("insertBefore"))).toBe(true);
|
|
});
|
|
|
|
it("mixed add+remove+update operations apply in correct order", () => {
|
|
const { host, operations } = makeHost();
|
|
const root = createHostRoot(host, {});
|
|
root.render(h("ul", null, h("li", { key: "a", color: "red" }, "A"), h("li", { key: "b" }, "B")));
|
|
|
|
operations.length = 0;
|
|
root.render(h("ul", null, h("li", { key: "a", color: "blue" }, "A"), h("li", { key: "c" }, "C")));
|
|
|
|
const removeIdx = operations.findIndex(op => op.includes("removeChild"));
|
|
const insertIdx = operations.findIndex(op => op.includes("appendChild") || op.includes("insertBefore"));
|
|
const updateIdx = operations.findIndex(op => op.includes("commitUpdate"));
|
|
|
|
if (removeIdx !== -1 && insertIdx !== -1) {
|
|
expect(removeIdx).toBeLessThan(insertIdx);
|
|
}
|
|
if (insertIdx !== -1 && updateIdx !== -1) {
|
|
expect(insertIdx).toBeLessThan(updateIdx);
|
|
}
|
|
});
|
|
|
|
it("insertBefore falls back to appendChild if before is null", () => {
|
|
const hostWithoutInsertBefore: HostConfig<string, string, Record<string, unknown>> = {
|
|
name: "test",
|
|
createRootContext: () => ({}),
|
|
createInstance: (tag) => `${tag}_0`,
|
|
createTextInstance: (text) => `text:${text}`,
|
|
appendChild: (_parent, _child) => {},
|
|
removeChild: (_parent, _child) => {},
|
|
prepareUpdate: () => null,
|
|
};
|
|
const root = createHostRoot(hostWithoutInsertBefore, {});
|
|
root.render(h("ul", null, h("li", { key: "a" }, "A")));
|
|
|
|
expect(() => {
|
|
root.render(h("ul", null, h("li", { key: "a" }, "A"), h("li", { key: "b" }, "B")));
|
|
}).not.toThrow();
|
|
});
|
|
|
|
it("fiber tree updated: new children added, removed children pruned", () => {
|
|
const { host } = makeHost();
|
|
const root = createHostRoot(host, {});
|
|
root.render(h("ul", null, h("li", { key: "a" }, "A"), h("li", { key: "b" }, "B")));
|
|
|
|
const ulFiber = root.rootFiber!.children[0]!;
|
|
expect(ulFiber.children.length).toBe(2);
|
|
expect(ulFiber.children[0]!.key).toBe("a");
|
|
expect(ulFiber.children[1]!.key).toBe("b");
|
|
|
|
root.render(h("ul", null, h("li", { key: "a" }, "A"), h("li", { key: "c" }, "C")));
|
|
|
|
expect(ulFiber.children.length).toBe(2);
|
|
const keys = ulFiber.children.map(c => c.key);
|
|
expect(keys).toContain("a");
|
|
expect(keys).toContain("c");
|
|
expect(keys).not.toContain("b");
|
|
});
|
|
|
|
it("fiber tree updated: moved children reordered", () => {
|
|
const { host } = makeHost();
|
|
const root = createHostRoot(host, {});
|
|
root.render(h("ul", null, h("li", { key: "a" }, "A"), h("li", { key: "b" }, "B"), h("li", { key: "c" }, "C")));
|
|
|
|
root.render(h("ul", null, h("li", { key: "c" }, "C"), h("li", { key: "a" }, "A"), h("li", { key: "b" }, "B")));
|
|
|
|
const ulFiber = root.rootFiber!.children[0]!;
|
|
const keys = ulFiber.children.map(c => c.key);
|
|
expect(keys).toEqual(["c", "a", "b"]);
|
|
});
|
|
|
|
it("removes are committed before inserts and moves", () => {
|
|
const { host, operations } = makeHost();
|
|
const root = createHostRoot(host, {});
|
|
root.render(h("ul", null, h("li", { key: "a" }, "A"), h("li", { key: "b" }, "B")));
|
|
|
|
operations.length = 0;
|
|
root.render(h("ul", null, h("li", { key: "c" }, "C"), h("li", { key: "a" }, "A")));
|
|
|
|
const removeIdx = operations.findIndex(op => op.includes("removeChild"));
|
|
const insertIdx = operations.findIndex(op => op.includes("appendChild") || op.includes("insertBefore"));
|
|
|
|
if (removeIdx !== -1 && insertIdx !== -1) {
|
|
expect(removeIdx).toBeLessThan(insertIdx);
|
|
}
|
|
});
|
|
|
|
it("updates are committed after inserts and moves", () => {
|
|
const { host, operations } = makeHost();
|
|
const root = createHostRoot(host, {});
|
|
root.render(h("ul", null, h("li", { key: "a", color: "red" }, "A")));
|
|
|
|
operations.length = 0;
|
|
root.render(h("ul", null, h("li", { key: "a", color: "blue" }, "A"), h("li", { key: "b" }, "B")));
|
|
|
|
const insertIdx = operations.findIndex(op => op.includes("appendChild") || op.includes("insertBefore"));
|
|
const updateIdx = operations.findIndex(op => op.includes("commitUpdate"));
|
|
|
|
if (insertIdx !== -1 && updateIdx !== -1) {
|
|
expect(insertIdx).toBeLessThan(updateIdx);
|
|
}
|
|
});
|
|
|
|
it("removed fibers have parent set to null", () => {
|
|
const { host } = makeHost();
|
|
const root = createHostRoot(host, {});
|
|
root.render(h("ul", null, h("li", { key: "a" }, "A"), h("li", { key: "b" }, "B")));
|
|
|
|
const ulFiber = root.rootFiber!.children[0]!;
|
|
const bFiber = ulFiber.children[1]!;
|
|
|
|
root.render(h("ul", null, h("li", { key: "a" }, "A")));
|
|
|
|
expect(bFiber.parent).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe("commitMutations direct", () => {
|
|
function makeFiber(key: string | undefined, tag: string, instance?: string): Fiber<string> {
|
|
return {
|
|
instance: instance ?? `inst-${tag}-${key ?? "nokey"}`,
|
|
tag,
|
|
props: {},
|
|
key,
|
|
children: [],
|
|
parent: null,
|
|
effect: null,
|
|
signalDisposers: [],
|
|
prevProps: null,
|
|
cachedNode: null,
|
|
};
|
|
}
|
|
|
|
function makeClassification<I>(
|
|
overrides: Partial<ChildClassification<I>> = {},
|
|
): ChildClassification<I> {
|
|
return {
|
|
matched: [],
|
|
added: [],
|
|
removed: [],
|
|
moves: new Map(),
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
it("appendChild for added child at end position", () => {
|
|
const ops: string[] = [];
|
|
const parentFiber = makeFiber(undefined, "div", "parent-inst");
|
|
|
|
const addedFiber = makeFiber("b", "span", "span-inst");
|
|
|
|
const host: HostConfig<string, string, unknown> = {
|
|
name: "test",
|
|
createRootContext: () => ({}),
|
|
createInstance: () => "inst",
|
|
createTextInstance: () => "text",
|
|
appendChild: (p, c) => ops.push(`appendChild(${p},${c})`),
|
|
removeChild: (p, c) => ops.push(`removeChild(${p},${c})`),
|
|
};
|
|
|
|
const existingFiber = makeFiber("a", "div", "a-inst");
|
|
existingFiber.parent = parentFiber;
|
|
parentFiber.children = [existingFiber];
|
|
|
|
const classification = makeClassification({
|
|
matched: [{ oldFiber: existingFiber, newChild: { type: "div", props: {}, children: [], key: "a" }, index: 0 }],
|
|
added: [{ newChild: { type: "span", props: {}, children: [], key: "b" }, index: 1 }],
|
|
});
|
|
|
|
const commitCtx: CommitContext<string> = {
|
|
host: host as HostConfig<string, string, unknown>,
|
|
ctx: {},
|
|
createFiber: () => addedFiber,
|
|
};
|
|
|
|
commitMutations(parentFiber, classification, commitCtx);
|
|
|
|
expect(ops).toContain("appendChild(parent-inst,span-inst)");
|
|
});
|
|
|
|
it("removeChild called for removed child", () => {
|
|
const ops: string[] = [];
|
|
const parentFiber = makeFiber(undefined, "div", "parent-inst");
|
|
|
|
const removedFiber = makeFiber("b", "span", "b-inst");
|
|
removedFiber.parent = parentFiber;
|
|
|
|
const host: HostConfig<string, string, unknown> = {
|
|
name: "test",
|
|
createRootContext: () => ({}),
|
|
createInstance: () => "inst",
|
|
createTextInstance: () => "text",
|
|
appendChild: () => {},
|
|
removeChild: (p, c) => ops.push(`removeChild(${p},${c})`),
|
|
};
|
|
|
|
const classification = makeClassification({
|
|
removed: [removedFiber],
|
|
});
|
|
|
|
const commitCtx: CommitContext<string> = {
|
|
host: host as HostConfig<string, string, unknown>,
|
|
ctx: {},
|
|
createFiber: () => makeFiber("x", "x", "x"),
|
|
};
|
|
|
|
commitMutations(parentFiber, classification, commitCtx);
|
|
|
|
expect(ops).toContain("removeChild(parent-inst,b-inst)");
|
|
});
|
|
|
|
it("insertBefore called for moved child with staying sibling after it", () => {
|
|
const ops: string[] = [];
|
|
const parentFiber = makeFiber(undefined, "div", "parent-inst");
|
|
|
|
const aFiber = makeFiber("a", "div", "a-inst");
|
|
aFiber.parent = parentFiber;
|
|
const bFiber = makeFiber("b", "span", "b-inst");
|
|
bFiber.parent = parentFiber;
|
|
const cFiber = makeFiber("c", "p", "c-inst");
|
|
cFiber.parent = parentFiber;
|
|
parentFiber.children = [aFiber, bFiber, cFiber];
|
|
|
|
const host: HostConfig<string, string, unknown> = {
|
|
name: "test",
|
|
createRootContext: () => ({}),
|
|
createInstance: () => "inst",
|
|
createTextInstance: () => "text",
|
|
appendChild: (p, c) => ops.push(`appendChild(${p},${c})`),
|
|
insertBefore: (p, c, b) => ops.push(`insertBefore(${p},${c},${b})`),
|
|
removeChild: (p, c) => ops.push(`removeChild(${p},${c})`),
|
|
};
|
|
|
|
// Reorder: [b, a, c] → moves b before a (a is staying)
|
|
const classification: ChildClassification<string> = {
|
|
matched: [
|
|
{ oldFiber: bFiber, newChild: { type: "span", props: {}, children: [], key: "b" }, index: 0 },
|
|
{ oldFiber: aFiber, newChild: { type: "div", props: {}, children: [], key: "a" }, index: 1 },
|
|
{ oldFiber: cFiber, newChild: { type: "p", props: {}, children: [], key: "c" }, index: 2 },
|
|
],
|
|
added: [],
|
|
removed: [],
|
|
moves: new Map([[0, bFiber]]),
|
|
};
|
|
|
|
const commitCtx: CommitContext<string> = {
|
|
host: host as HostConfig<string, string, unknown>,
|
|
ctx: {},
|
|
createFiber: () => makeFiber("x", "x", "x"),
|
|
};
|
|
|
|
commitMutations(parentFiber, classification, commitCtx);
|
|
|
|
expect(ops.some(op => op.includes("insertBefore"))).toBe(true);
|
|
});
|
|
|
|
it("updates committed top-down (parent before child)", () => {
|
|
const ops: string[] = [];
|
|
const parentFiber = makeFiber(undefined, "div", "parent-inst");
|
|
parentFiber.effect = { type: "update", payload: {} };
|
|
parentFiber.prevProps = {};
|
|
parentFiber.props = { color: "blue" };
|
|
|
|
const childFiber = makeFiber("a", "span", "child-inst");
|
|
childFiber.parent = parentFiber;
|
|
childFiber.effect = { type: "update", payload: {} };
|
|
childFiber.prevProps = {};
|
|
childFiber.props = { size: "big" };
|
|
parentFiber.children = [childFiber];
|
|
|
|
const host: HostConfig<string, string, unknown> = {
|
|
name: "test",
|
|
createRootContext: () => ({}),
|
|
createInstance: () => "inst",
|
|
createTextInstance: () => "text",
|
|
appendChild: () => {},
|
|
commitUpdate: (inst) => ops.push(`commitUpdate(${inst})`),
|
|
};
|
|
|
|
const classification = makeClassification<string>({
|
|
matched: [{
|
|
oldFiber: childFiber,
|
|
newChild: { type: "span", props: { size: "big" }, children: [], key: "a" },
|
|
index: 0,
|
|
}],
|
|
});
|
|
|
|
const commitCtx: CommitContext<string> = {
|
|
host: host as HostConfig<string, string, unknown>,
|
|
ctx: {},
|
|
createFiber: () => makeFiber("x", "x", "x"),
|
|
};
|
|
|
|
commitMutations(parentFiber, classification, commitCtx);
|
|
|
|
const parentIdx = ops.indexOf("commitUpdate(parent-inst)");
|
|
const childIdx = ops.indexOf("commitUpdate(child-inst)");
|
|
expect(parentIdx).toBeLessThan(childIdx);
|
|
});
|
|
}); |