Add key-based child matching algorithm (reconcileChildren) for fiber reconciliation
This commit is contained in:
@@ -2,7 +2,7 @@ import { effect } from "@preact/signals-core";
|
|||||||
import type { Fiber, Effect } from "./fiber.js";
|
import type { Fiber, Effect } from "./fiber.js";
|
||||||
import type { HostConfig } from "./config.js";
|
import type { HostConfig } from "./config.js";
|
||||||
import type { UNode, UElement } from "../core/schema.js";
|
import type { UNode, UElement } from "../core/schema.js";
|
||||||
import { isUPrimitive, isURoot } from "../core/schema.js";
|
import { isUPrimitive, isURoot, isUElement } from "../core/schema.js";
|
||||||
|
|
||||||
interface PendingUpdate {
|
interface PendingUpdate {
|
||||||
fiber: Fiber<unknown>;
|
fiber: Fiber<unknown>;
|
||||||
@@ -154,4 +154,118 @@ export function wireSignalToFiber<I>(
|
|||||||
export function resetUpdateQueue(): void {
|
export function resetUpdateQueue(): void {
|
||||||
pendingUpdates = [];
|
pendingUpdates = [];
|
||||||
flushScheduled = false;
|
flushScheduled = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MatchedChild<I> {
|
||||||
|
oldFiber: Fiber<I>;
|
||||||
|
newChild: UElement;
|
||||||
|
index: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ChildClassification<I> {
|
||||||
|
matched: MatchedChild<I>[];
|
||||||
|
added: { newChild: UElement; index: number }[];
|
||||||
|
removed: Fiber<I>[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function reconcileChildren<I>(
|
||||||
|
oldFibers: Fiber<I>[],
|
||||||
|
newChildren: UNode[],
|
||||||
|
): ChildClassification<I> {
|
||||||
|
const matched: MatchedChild<I>[] = [];
|
||||||
|
const added: { newChild: UElement; index: number }[] = [];
|
||||||
|
const removed: Fiber<I>[] = [];
|
||||||
|
|
||||||
|
const oldKeyMap = new Map<string, Fiber<I>>();
|
||||||
|
const displacedOldFibers: Fiber<I>[] = [];
|
||||||
|
for (const fiber of oldFibers) {
|
||||||
|
if (fiber.key !== undefined) {
|
||||||
|
if (oldKeyMap.has(fiber.key)) {
|
||||||
|
console.warn(`Duplicate key "${fiber.key}" among old children; last-wins`);
|
||||||
|
displacedOldFibers.push(oldKeyMap.get(fiber.key)!);
|
||||||
|
}
|
||||||
|
oldKeyMap.set(fiber.key, fiber);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const matchedOldKeys = new Set<string>();
|
||||||
|
const unkeyedOldUsed = new Set<number>();
|
||||||
|
let unkeyedOldCursor = 0;
|
||||||
|
|
||||||
|
const seenNewKeys = new Map<string, number>();
|
||||||
|
|
||||||
|
for (let i = 0; i < newChildren.length; i++) {
|
||||||
|
const child = newChildren[i]!;
|
||||||
|
if (!isUElement(child)) continue;
|
||||||
|
|
||||||
|
if (child.key !== undefined) {
|
||||||
|
const prevIdx = seenNewKeys.get(child.key);
|
||||||
|
if (prevIdx !== undefined) {
|
||||||
|
console.warn(`Duplicate key "${child.key}" among new children; last-wins`);
|
||||||
|
const prevMatch = matched.findIndex((m) => m.newChild.key === child.key && m.index === prevIdx);
|
||||||
|
if (prevMatch !== -1) {
|
||||||
|
const oldFiber = matched[prevMatch]!.oldFiber;
|
||||||
|
removed.push(oldFiber);
|
||||||
|
matched.splice(prevMatch, 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
seenNewKeys.set(child.key, i);
|
||||||
|
|
||||||
|
const oldFiber = oldKeyMap.get(child.key);
|
||||||
|
if (oldFiber !== undefined) {
|
||||||
|
matchedOldKeys.add(child.key);
|
||||||
|
if (oldFiber.tag === child.type) {
|
||||||
|
matched.push({ oldFiber, newChild: child, index: i });
|
||||||
|
} else {
|
||||||
|
removed.push(oldFiber);
|
||||||
|
added.push({ newChild: child, index: i });
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
added.push({ newChild: child, index: i });
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
let matchedOld: Fiber<I> | null = null;
|
||||||
|
let matchedOldIdx = -1;
|
||||||
|
for (let j = unkeyedOldCursor; j < oldFibers.length; j++) {
|
||||||
|
if (!unkeyedOldUsed.has(j) && oldFibers[j]!.key === undefined) {
|
||||||
|
matchedOld = oldFibers[j]!;
|
||||||
|
matchedOldIdx = j;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (matchedOld !== null) {
|
||||||
|
unkeyedOldUsed.add(matchedOldIdx);
|
||||||
|
unkeyedOldCursor = matchedOldIdx + 1;
|
||||||
|
if (matchedOld.tag === child.type) {
|
||||||
|
matched.push({ oldFiber: matchedOld, newChild: child, index: i });
|
||||||
|
} else {
|
||||||
|
removed.push(matchedOld);
|
||||||
|
added.push({ newChild: child, index: i });
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
added.push({ newChild: child, index: i });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let i = 0; i < oldFibers.length; i++) {
|
||||||
|
const fiber = oldFibers[i]!;
|
||||||
|
if (fiber.key !== undefined) {
|
||||||
|
if (!matchedOldKeys.has(fiber.key)) {
|
||||||
|
removed.push(fiber);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (!unkeyedOldUsed.has(i)) {
|
||||||
|
removed.push(fiber);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const displaced of displacedOldFibers) {
|
||||||
|
if (matchedOldKeys.has(displaced.key!)) {
|
||||||
|
removed.push(displaced);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { matched, added, removed };
|
||||||
}
|
}
|
||||||
@@ -21,7 +21,8 @@ export type { HostConfig, Root } from "./host/config.js";
|
|||||||
|
|
||||||
export type { Fiber, Effect } from "./host/fiber.js";
|
export type { Fiber, Effect } from "./host/fiber.js";
|
||||||
|
|
||||||
export { scheduleUpdate, flushUpdates, reconcileProps, commitEffects, wireSignalToFiber, resetUpdateQueue } from "./host/reconcile.js";
|
export { scheduleUpdate, flushUpdates, reconcileProps, commitEffects, wireSignalToFiber, resetUpdateQueue, reconcileChildren } from "./host/reconcile.js";
|
||||||
|
export type { MatchedChild, ChildClassification } from "./host/reconcile.js";
|
||||||
|
|
||||||
export { TransformRegistry, childCtx, matchesSchema, ctx as transformCtx } from "./transform/registry.js";
|
export { TransformRegistry, childCtx, matchesSchema, ctx as transformCtx } from "./transform/registry.js";
|
||||||
export type { TransformContext, TransformFn, TransformRule } from "./transform/registry.js";
|
export type { TransformContext, TransformFn, TransformRule } from "./transform/registry.js";
|
||||||
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