feat: add LIS-based move detection for keyed children reconciliation
This commit is contained in:
@@ -156,6 +156,46 @@ export function resetUpdateQueue(): void {
|
|||||||
flushScheduled = false;
|
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<I> {
|
export interface MatchedChild<I> {
|
||||||
oldFiber: Fiber<I>;
|
oldFiber: Fiber<I>;
|
||||||
newChild: UElement;
|
newChild: UElement;
|
||||||
@@ -166,6 +206,7 @@ export interface ChildClassification<I> {
|
|||||||
matched: MatchedChild<I>[];
|
matched: MatchedChild<I>[];
|
||||||
added: { newChild: UElement; index: number }[];
|
added: { newChild: UElement; index: number }[];
|
||||||
removed: Fiber<I>[];
|
removed: Fiber<I>[];
|
||||||
|
moves: Map<number, Fiber<I>>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function reconcileChildren<I>(
|
export function reconcileChildren<I>(
|
||||||
@@ -267,5 +308,16 @@ export function reconcileChildren<I>(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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<number, Fiber<I>>();
|
||||||
|
for (let i = 0; i < matched.length; i++) {
|
||||||
|
if (!lisSet.has(i)) {
|
||||||
|
moves.set(i, matched[i]!.oldFiber);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { matched, added, removed, moves };
|
||||||
}
|
}
|
||||||
@@ -21,7 +21,7 @@ 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, 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 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";
|
||||||
|
|||||||
178
test/lis-move-detection.test.ts
Normal file
178
test/lis-move-detection.test.ts
Normal file
@@ -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<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("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);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user