Add key-based child matching algorithm (reconcileChildren) for fiber reconciliation
This commit is contained in:
226
test/key-matching-algorithm.test.ts
Normal file
226
test/key-matching-algorithm.test.ts
Normal file
@@ -0,0 +1,226 @@
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user