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.
227 lines
7.3 KiB
TypeScript
227 lines
7.3 KiB
TypeScript
import { describe, it, expect, vi } from "vitest";
|
|
import type { Fiber } from "../src/host/fiber.js";
|
|
import type { UNode } from "../src/core/schema.js";
|
|
import { reconcileChildren } from "../src/host/reconcile.js";
|
|
|
|
function makeFiber(key: string | undefined, tag: string): Fiber<string> {
|
|
return {
|
|
instance: `inst-${tag}-${key ?? "nokey"}`,
|
|
tag,
|
|
props: {},
|
|
key,
|
|
children: [],
|
|
parent: null,
|
|
effect: null,
|
|
signalDisposers: [],
|
|
prevProps: null,
|
|
cachedNode: null,
|
|
};
|
|
}
|
|
|
|
function makeElement(type: string, key?: string): UNode {
|
|
return { type, props: {}, children: [], ...(key !== undefined ? { key } : {}) };
|
|
}
|
|
|
|
describe("reconcileChildren", () => {
|
|
it("keyed children reordered → matched correctly by key", () => {
|
|
const oldFibers = [
|
|
makeFiber("a", "div"),
|
|
makeFiber("b", "span"),
|
|
makeFiber("c", "p"),
|
|
];
|
|
const newChildren = [
|
|
makeElement("p", "c"),
|
|
makeElement("div", "a"),
|
|
makeElement("span", "b"),
|
|
];
|
|
const result = reconcileChildren(oldFibers, newChildren);
|
|
|
|
expect(result.matched).toHaveLength(3);
|
|
expect(result.added).toHaveLength(0);
|
|
expect(result.removed).toHaveLength(0);
|
|
|
|
expect(result.matched[0]!.oldFiber.key).toBe("c");
|
|
expect(result.matched[1]!.oldFiber.key).toBe("a");
|
|
expect(result.matched[2]!.oldFiber.key).toBe("b");
|
|
});
|
|
|
|
it("keyed child added at start → old children matched, new child added", () => {
|
|
const oldFibers = [
|
|
makeFiber("b", "span"),
|
|
makeFiber("c", "p"),
|
|
];
|
|
const newChildren = [
|
|
makeElement("div", "a"),
|
|
makeElement("span", "b"),
|
|
makeElement("p", "c"),
|
|
];
|
|
const result = reconcileChildren(oldFibers, newChildren);
|
|
|
|
expect(result.matched).toHaveLength(2);
|
|
expect(result.added).toHaveLength(1);
|
|
expect(result.removed).toHaveLength(0);
|
|
|
|
expect(result.added[0]!.newChild.key).toBe("a");
|
|
expect(result.matched[0]!.oldFiber.key).toBe("b");
|
|
expect(result.matched[1]!.oldFiber.key).toBe("c");
|
|
});
|
|
|
|
it("keyed child removed → classified as removed", () => {
|
|
const oldFibers = [
|
|
makeFiber("a", "div"),
|
|
makeFiber("b", "span"),
|
|
makeFiber("c", "p"),
|
|
];
|
|
const newChildren = [
|
|
makeElement("div", "a"),
|
|
makeElement("p", "c"),
|
|
];
|
|
const result = reconcileChildren(oldFibers, newChildren);
|
|
|
|
expect(result.matched).toHaveLength(2);
|
|
expect(result.added).toHaveLength(0);
|
|
expect(result.removed).toHaveLength(1);
|
|
|
|
expect(result.removed[0]!.key).toBe("b");
|
|
});
|
|
|
|
it("mixed keyed and unkeyed children → key-based for keyed, positional for unkeyed", () => {
|
|
const oldFibers = [
|
|
makeFiber("a", "div"),
|
|
makeFiber(undefined, "span"),
|
|
makeFiber(undefined, "p"),
|
|
makeFiber("b", "section"),
|
|
];
|
|
const newChildren = [
|
|
makeElement("span"),
|
|
makeElement("section", "b"),
|
|
makeElement("p"),
|
|
makeElement("div", "a"),
|
|
];
|
|
const result = reconcileChildren(oldFibers, newChildren);
|
|
|
|
expect(result.matched).toHaveLength(4);
|
|
expect(result.added).toHaveLength(0);
|
|
expect(result.removed).toHaveLength(0);
|
|
|
|
const matchedKeys = result.matched.map((m) => m.oldFiber.key);
|
|
expect(matchedKeys).toContain("a");
|
|
expect(matchedKeys).toContain("b");
|
|
expect(matchedKeys).toContain(undefined);
|
|
expect(result.matched.filter((m) => m.oldFiber.key === undefined)).toHaveLength(2);
|
|
});
|
|
|
|
it("duplicate key → last-wins, no crash", () => {
|
|
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
|
|
const oldFibers = [
|
|
makeFiber("a", "div"),
|
|
makeFiber("a", "span"),
|
|
];
|
|
const newChildren = [
|
|
makeElement("span", "a"),
|
|
];
|
|
const result = reconcileChildren(oldFibers, newChildren);
|
|
|
|
expect(result.matched).toHaveLength(1);
|
|
expect(result.added).toHaveLength(0);
|
|
expect(result.removed).toHaveLength(1);
|
|
|
|
expect(result.matched[0]!.oldFiber.tag).toBe("span");
|
|
expect(result.removed[0]!.tag).toBe("div");
|
|
expect(warnSpy).toHaveBeenCalled();
|
|
warnSpy.mockRestore();
|
|
});
|
|
|
|
it("duplicate key among new children → last-wins", () => {
|
|
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
|
|
const oldFibers = [
|
|
makeFiber("a", "div"),
|
|
];
|
|
const newChildren = [
|
|
makeElement("div", "a"),
|
|
makeElement("div", "a"),
|
|
];
|
|
const result = reconcileChildren(oldFibers, newChildren);
|
|
|
|
expect(result.matched).toHaveLength(1);
|
|
expect(result.matched[0]!.newChild.key).toBe("a");
|
|
expect(result.added).toHaveLength(0);
|
|
expect(result.removed).toHaveLength(1);
|
|
expect(warnSpy).toHaveBeenCalled();
|
|
warnSpy.mockRestore();
|
|
});
|
|
|
|
it("matched with different type → old removed, new added", () => {
|
|
const oldFibers = [
|
|
makeFiber("a", "div"),
|
|
];
|
|
const newChildren = [
|
|
makeElement("span", "a"),
|
|
];
|
|
const result = reconcileChildren(oldFibers, newChildren);
|
|
|
|
expect(result.matched).toHaveLength(0);
|
|
expect(result.added).toHaveLength(1);
|
|
expect(result.removed).toHaveLength(1);
|
|
|
|
expect(result.added[0]!.newChild.type).toBe("span");
|
|
expect(result.removed[0]!.tag).toBe("div");
|
|
});
|
|
|
|
it("unkeyed children with type change → old removed, new added", () => {
|
|
const oldFibers = [
|
|
makeFiber(undefined, "div"),
|
|
];
|
|
const newChildren = [
|
|
makeElement("span"),
|
|
];
|
|
const result = reconcileChildren(oldFibers, newChildren);
|
|
|
|
expect(result.matched).toHaveLength(0);
|
|
expect(result.added).toHaveLength(1);
|
|
expect(result.removed).toHaveLength(1);
|
|
});
|
|
|
|
it("returns empty classification for empty inputs", () => {
|
|
const result = reconcileChildren([], []);
|
|
expect(result.matched).toHaveLength(0);
|
|
expect(result.added).toHaveLength(0);
|
|
expect(result.removed).toHaveLength(0);
|
|
});
|
|
|
|
it("all old children removed when new children is empty", () => {
|
|
const oldFibers = [makeFiber("a", "div"), makeFiber("b", "span")];
|
|
const result = reconcileChildren(oldFibers, []);
|
|
expect(result.matched).toHaveLength(0);
|
|
expect(result.added).toHaveLength(0);
|
|
expect(result.removed).toHaveLength(2);
|
|
});
|
|
|
|
it("all new children added when old children is empty", () => {
|
|
const newChildren = [makeElement("div", "a"), makeElement("span", "b")];
|
|
const result = reconcileChildren([], newChildren);
|
|
expect(result.matched).toHaveLength(0);
|
|
expect(result.added).toHaveLength(2);
|
|
expect(result.removed).toHaveLength(0);
|
|
});
|
|
|
|
it("skips non-UElement children (primitives/roots) in matching", () => {
|
|
const oldFibers = [makeFiber("a", "div")];
|
|
const newChildren: UNode[] = ["text", makeElement("div", "a")];
|
|
const result = reconcileChildren(oldFibers, newChildren);
|
|
expect(result.matched).toHaveLength(1);
|
|
expect(result.matched[0]!.oldFiber.key).toBe("a");
|
|
});
|
|
|
|
it("pure function — does not mutate old fibers", () => {
|
|
const oldFibers = [makeFiber("a", "div"), makeFiber("b", "span")];
|
|
const snapshots = oldFibers.map((f) => ({ ...f }));
|
|
const newChildren = [makeElement("span", "b"), makeElement("div", "a")];
|
|
reconcileChildren(oldFibers, newChildren);
|
|
for (let i = 0; i < oldFibers.length; i++) {
|
|
expect(oldFibers[i]!.instance).toBe(snapshots[i]!.instance);
|
|
expect(oldFibers[i]!.tag).toBe(snapshots[i]!.tag);
|
|
expect(oldFibers[i]!.key).toBe(snapshots[i]!.key);
|
|
}
|
|
});
|
|
}); |