diff --git a/src/host/reconcile.ts b/src/host/reconcile.ts index a740448..2266441 100644 --- a/src/host/reconcile.ts +++ b/src/host/reconcile.ts @@ -2,7 +2,7 @@ import { effect } from "@preact/signals-core"; import type { Fiber, Effect } from "./fiber.js"; import type { HostConfig } from "./config.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 { fiber: Fiber; @@ -154,4 +154,118 @@ export function wireSignalToFiber( export function resetUpdateQueue(): void { pendingUpdates = []; flushScheduled = false; +} + +export interface MatchedChild { + oldFiber: Fiber; + newChild: UElement; + index: number; +} + +export interface ChildClassification { + matched: MatchedChild[]; + added: { newChild: UElement; index: number }[]; + removed: Fiber[]; +} + +export function reconcileChildren( + oldFibers: Fiber[], + newChildren: UNode[], +): ChildClassification { + const matched: MatchedChild[] = []; + const added: { newChild: UElement; index: number }[] = []; + const removed: Fiber[] = []; + + const oldKeyMap = new Map>(); + const displacedOldFibers: Fiber[] = []; + 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(); + const unkeyedOldUsed = new Set(); + let unkeyedOldCursor = 0; + + const seenNewKeys = new Map(); + + 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 | 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 }; } \ No newline at end of file diff --git a/src/mod.ts b/src/mod.ts index 748ba33..24334cc 100644 --- a/src/mod.ts +++ b/src/mod.ts @@ -21,7 +21,8 @@ export type { HostConfig, Root } from "./host/config.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 type { TransformContext, TransformFn, TransformRule } from "./transform/registry.js"; \ No newline at end of file diff --git a/test/key-matching-algorithm.test.ts b/test/key-matching-algorithm.test.ts new file mode 100644 index 0000000..b5c110c --- /dev/null +++ b/test/key-matching-algorithm.test.ts @@ -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 { + 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); + } + }); +}); \ No newline at end of file