diff --git a/src/host/reconcile.ts b/src/host/reconcile.ts index 2266441..57f781d 100644 --- a/src/host/reconcile.ts +++ b/src/host/reconcile.ts @@ -156,6 +156,46 @@ export function resetUpdateQueue(): void { flushScheduled = false; } +export function longestIncreasingSubsequence(arr: number[]): number[] { + if (arr.length === 0) return []; + + const tails: number[] = []; + const tailsIdx: number[] = []; + const prev: number[] = new Array(arr.length).fill(-1); + + for (let i = 0; i < arr.length; i++) { + const val = arr[i]!; + let lo = 0; + let hi = tails.length; + while (lo < hi) { + const mid = (lo + hi) >>> 1; + if (tails[mid]! < val) { + lo = mid + 1; + } else { + hi = mid; + } + } + if (lo > 0) { + prev[i] = tailsIdx[lo - 1]!; + } + if (lo === tails.length) { + tails.push(val); + tailsIdx.push(i); + } else { + tails[lo] = val; + tailsIdx[lo] = i; + } + } + + const result: number[] = new Array(tails.length); + let k = tailsIdx[tails.length - 1]!; + for (let j = tails.length - 1; j >= 0; j--) { + result[j] = k; + k = prev[k]!; + } + return result; +} + export interface MatchedChild { oldFiber: Fiber; newChild: UElement; @@ -166,6 +206,7 @@ export interface ChildClassification { matched: MatchedChild[]; added: { newChild: UElement; index: number }[]; removed: Fiber[]; + moves: Map>; } export function reconcileChildren( @@ -267,5 +308,16 @@ export function reconcileChildren( } } - return { matched, added, removed }; + const oldIndices = matched.map((m) => oldFibers.indexOf(m.oldFiber)); + const lisPositions = longestIncreasingSubsequence(oldIndices); + const lisSet = new Set(lisPositions); + + const moves = new Map>(); + for (let i = 0; i < matched.length; i++) { + if (!lisSet.has(i)) { + moves.set(i, matched[i]!.oldFiber); + } + } + + return { matched, added, removed, moves }; } \ No newline at end of file diff --git a/src/mod.ts b/src/mod.ts index 24334cc..e2c3f30 100644 --- a/src/mod.ts +++ b/src/mod.ts @@ -21,7 +21,7 @@ export type { HostConfig, Root } from "./host/config.js"; export type { Fiber, Effect } from "./host/fiber.js"; -export { scheduleUpdate, flushUpdates, reconcileProps, commitEffects, wireSignalToFiber, resetUpdateQueue, reconcileChildren } from "./host/reconcile.js"; +export { scheduleUpdate, flushUpdates, reconcileProps, commitEffects, wireSignalToFiber, resetUpdateQueue, reconcileChildren, longestIncreasingSubsequence } from "./host/reconcile.js"; export type { MatchedChild, ChildClassification } from "./host/reconcile.js"; export { TransformRegistry, childCtx, matchesSchema, ctx as transformCtx } from "./transform/registry.js"; diff --git a/test/lis-move-detection.test.ts b/test/lis-move-detection.test.ts new file mode 100644 index 0000000..b343201 --- /dev/null +++ b/test/lis-move-detection.test.ts @@ -0,0 +1,178 @@ +import { describe, it, expect } from "vitest"; +import type { Fiber } from "../src/host/fiber.js"; +import type { UNode } from "../src/core/schema.js"; +import { longestIncreasingSubsequence, 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("longestIncreasingSubsequence", () => { + it("empty array → empty result", () => { + expect(longestIncreasingSubsequence([])).toEqual([]); + }); + + it("single element → [0]", () => { + expect(longestIncreasingSubsequence([5])).toEqual([0]); + }); + + it("already ordered [0, 1, 2] → all stay", () => { + const result = longestIncreasingSubsequence([0, 1, 2]); + expect(result).toEqual([0, 1, 2]); + }); + + it("[2, 0, 1] → LIS positions: [1, 2] (values 0, 1 stay)", () => { + const result = longestIncreasingSubsequence([2, 0, 1]); + expect(result).toEqual([1, 2]); + const moved = [0, 1, 2].filter((i) => !result.includes(i)); + expect(moved).toEqual([0]); + }); + + it("reversed [3, 2, 1, 0] → LIS length 1, three moves", () => { + const result = longestIncreasingSubsequence([3, 2, 1, 0]); + expect(result).toHaveLength(1); + const moved = [0, 1, 2, 3].filter((i) => !result.includes(i)); + expect(moved).toHaveLength(3); + }); + + it("[0, 4, 2, 3, 1] → LIS covers 0, 2, 3", () => { + const result = longestIncreasingSubsequence([0, 4, 2, 3, 1]); + expect(result.length).toBeGreaterThanOrEqual(3); + const vals = result.map((i) => [0, 4, 2, 3, 1][i]!); + for (let i = 1; i < vals.length; i++) { + expect(vals[i]).toBeGreaterThan(vals[i - 1]!); + } + }); +}); + +describe("reconcileChildren LIS move detection", () => { + it("already ordered children → no moves", () => { + const oldFibers = [ + makeFiber("a", "div"), + makeFiber("b", "span"), + makeFiber("c", "p"), + ]; + const newChildren = [ + makeElement("div", "a"), + makeElement("span", "b"), + makeElement("p", "c"), + ]; + const result = reconcileChildren(oldFibers, newChildren); + expect(result.moves.size).toBe(0); + }); + + it("reordered children → moves for out-of-order", () => { + 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.moves.size).toBeGreaterThan(0); + expect(result.matched).toHaveLength(3); + }); + + it("adding child at start doesn't move existing ones (LIS covers all old)", () => { + 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.moves.size).toBe(0); + }); + + it("reversed list → LIS of length 1, three moves", () => { + const oldFibers = [ + makeFiber("a", "div"), + makeFiber("b", "span"), + makeFiber("c", "p"), + makeFiber("d", "section"), + ]; + const newChildren = [ + makeElement("section", "d"), + makeElement("p", "c"), + makeElement("span", "b"), + makeElement("div", "a"), + ]; + const result = reconcileChildren(oldFibers, newChildren); + expect(result.matched).toHaveLength(4); + expect(result.moves.size).toBe(3); + }); + + it("[2, 0, 1] pattern → index 0 stays, indices 2 and 1 move", () => { + 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); + + const oldIndices = result.matched.map((m) => oldFibers.indexOf(m.oldFiber)); + const lisPositions = longestIncreasingSubsequence(oldIndices); + const lisSet = new Set(lisPositions); + + const movedIndices = result.matched + .map((_, i) => i) + .filter((i) => !lisSet.has(i)); + expect(movedIndices.length).toBeGreaterThan(0); + + const stayedIndices = result.matched + .map((_, i) => i) + .filter((i) => lisSet.has(i)); + expect(stayedIndices.length).toBeGreaterThan(0); + }); + + it("moves map contains old fibers that need moving", () => { + const oldFibers = [ + makeFiber("a", "div"), + makeFiber("b", "span"), + ]; + const newChildren = [ + makeElement("span", "b"), + makeElement("div", "a"), + ]; + const result = reconcileChildren(oldFibers, newChildren); + + expect(result.moves.size).toBe(1); + for (const fiber of result.moves.values()) { + expect(oldFibers).toContain(fiber); + } + }); + + it("no matched children → empty moves", () => { + const result = reconcileChildren([], [makeElement("div", "a")]); + expect(result.moves.size).toBe(0); + }); +}); \ No newline at end of file