import { describe, it, expect, vi } from "vitest"; import { h } from "../src/core/h.js"; import { createRoot as createHostRoot } from "../src/host/config.js"; import type { HostConfig } from "../src/host/config.js"; import { commitMutations, reconcileChildren } from "../src/host/reconcile.js"; import type { CommitContext, ChildClassification } from "../src/host/reconcile.js"; import type { Fiber } from "../src/host/fiber.js"; import { disposeFiber } from "../src/host/fiber.js"; function makeHost(): { host: HostConfig>; operations: string[]; instances: { tag: string; props: Record }[]; } { const operations: string[] = []; const instances: { tag: string; props: Record }[] = []; const host: HostConfig> = { name: "test", createRootContext: () => ({}), createInstance: (tag, props) => { const id = `${tag}_${instances.length}`; instances.push({ tag, props }); return id; }, createTextInstance: (text) => { return `text:${text}`; }, appendChild: (parent, child) => { operations.push(`appendChild(${parent}, ${child})`); }, insertBefore: (parent, child, before) => { operations.push(`insertBefore(${parent}, ${child}, ${before})`); }, removeChild: (parent, child) => { operations.push(`removeChild(${parent}, ${child})`); }, prepareUpdate: (_instance, _tag, prevProps, nextProps) => { const changed = Object.keys(nextProps).some( (k) => prevProps[k] !== nextProps[k], ) || Object.keys(prevProps).some( (k) => !(k in nextProps), ); return changed ? { prevProps, nextProps } : null; }, commitUpdate: (instance, _payload, _tag, _prevProps, nextProps) => { operations.push(`commitUpdate(${instance})`); }, }; return { host, operations, instances }; } describe("commitMutations", () => { it("adding a child calls host.appendChild", () => { const { host, operations } = makeHost(); const root = createHostRoot(host, {}); root.render(h("ul", null, h("li", { key: "a" }, "A"))); operations.length = 0; root.render(h("ul", null, h("li", { key: "a" }, "A"), h("li", { key: "b" }, "B"))); expect(operations.some(op => op.includes("appendChild") || op.includes("insertBefore"))).toBe(true); }); it("removing a child calls host.removeChild", () => { const { host, operations } = makeHost(); const root = createHostRoot(host, {}); root.render(h("ul", null, h("li", { key: "a" }, "A"), h("li", { key: "b" }, "B"))); operations.length = 0; root.render(h("ul", null, h("li", { key: "a" }, "A"))); expect(operations.some(op => op.includes("removeChild"))).toBe(true); }); it("reordering children calls host.insertBefore for moved children", () => { const { host, operations } = makeHost(); const root = createHostRoot(host, {}); root.render(h("ul", null, h("li", { key: "a" }, "A"), h("li", { key: "b" }, "B"), h("li", { key: "c" }, "C"))); operations.length = 0; root.render(h("ul", null, h("li", { key: "c" }, "C"), h("li", { key: "a" }, "A"), h("li", { key: "b" }, "B"))); expect(operations.some(op => op.includes("insertBefore"))).toBe(true); }); it("mixed add+remove+update operations apply in correct order", () => { const { host, operations } = makeHost(); const root = createHostRoot(host, {}); root.render(h("ul", null, h("li", { key: "a", color: "red" }, "A"), h("li", { key: "b" }, "B"))); operations.length = 0; root.render(h("ul", null, h("li", { key: "a", color: "blue" }, "A"), h("li", { key: "c" }, "C"))); const removeIdx = operations.findIndex(op => op.includes("removeChild")); const insertIdx = operations.findIndex(op => op.includes("appendChild") || op.includes("insertBefore")); const updateIdx = operations.findIndex(op => op.includes("commitUpdate")); if (removeIdx !== -1 && insertIdx !== -1) { expect(removeIdx).toBeLessThan(insertIdx); } if (insertIdx !== -1 && updateIdx !== -1) { expect(insertIdx).toBeLessThan(updateIdx); } }); it("insertBefore falls back to appendChild if before is null", () => { const hostWithoutInsertBefore: HostConfig> = { name: "test", createRootContext: () => ({}), createInstance: (tag) => `${tag}_0`, createTextInstance: (text) => `text:${text}`, appendChild: (_parent, _child) => {}, removeChild: (_parent, _child) => {}, prepareUpdate: () => null, }; const root = createHostRoot(hostWithoutInsertBefore, {}); root.render(h("ul", null, h("li", { key: "a" }, "A"))); expect(() => { root.render(h("ul", null, h("li", { key: "a" }, "A"), h("li", { key: "b" }, "B"))); }).not.toThrow(); }); it("fiber tree updated: new children added, removed children pruned", () => { const { host } = makeHost(); const root = createHostRoot(host, {}); root.render(h("ul", null, h("li", { key: "a" }, "A"), h("li", { key: "b" }, "B"))); const ulFiber = root.rootFiber!.children[0]!; expect(ulFiber.children.length).toBe(2); expect(ulFiber.children[0]!.key).toBe("a"); expect(ulFiber.children[1]!.key).toBe("b"); root.render(h("ul", null, h("li", { key: "a" }, "A"), h("li", { key: "c" }, "C"))); expect(ulFiber.children.length).toBe(2); const keys = ulFiber.children.map(c => c.key); expect(keys).toContain("a"); expect(keys).toContain("c"); expect(keys).not.toContain("b"); }); it("fiber tree updated: moved children reordered", () => { const { host } = makeHost(); const root = createHostRoot(host, {}); root.render(h("ul", null, h("li", { key: "a" }, "A"), h("li", { key: "b" }, "B"), h("li", { key: "c" }, "C"))); root.render(h("ul", null, h("li", { key: "c" }, "C"), h("li", { key: "a" }, "A"), h("li", { key: "b" }, "B"))); const ulFiber = root.rootFiber!.children[0]!; const keys = ulFiber.children.map(c => c.key); expect(keys).toEqual(["c", "a", "b"]); }); it("removes are committed before inserts and moves", () => { const { host, operations } = makeHost(); const root = createHostRoot(host, {}); root.render(h("ul", null, h("li", { key: "a" }, "A"), h("li", { key: "b" }, "B"))); operations.length = 0; root.render(h("ul", null, h("li", { key: "c" }, "C"), h("li", { key: "a" }, "A"))); const removeIdx = operations.findIndex(op => op.includes("removeChild")); const insertIdx = operations.findIndex(op => op.includes("appendChild") || op.includes("insertBefore")); if (removeIdx !== -1 && insertIdx !== -1) { expect(removeIdx).toBeLessThan(insertIdx); } }); it("updates are committed after inserts and moves", () => { const { host, operations } = makeHost(); const root = createHostRoot(host, {}); root.render(h("ul", null, h("li", { key: "a", color: "red" }, "A"))); operations.length = 0; root.render(h("ul", null, h("li", { key: "a", color: "blue" }, "A"), h("li", { key: "b" }, "B"))); const insertIdx = operations.findIndex(op => op.includes("appendChild") || op.includes("insertBefore")); const updateIdx = operations.findIndex(op => op.includes("commitUpdate")); if (insertIdx !== -1 && updateIdx !== -1) { expect(insertIdx).toBeLessThan(updateIdx); } }); it("removed fibers have parent set to null", () => { const { host } = makeHost(); const root = createHostRoot(host, {}); root.render(h("ul", null, h("li", { key: "a" }, "A"), h("li", { key: "b" }, "B"))); const ulFiber = root.rootFiber!.children[0]!; const bFiber = ulFiber.children[1]!; root.render(h("ul", null, h("li", { key: "a" }, "A"))); expect(bFiber.parent).toBeNull(); }); }); describe("commitMutations direct", () => { function makeFiber(key: string | undefined, tag: string, instance?: string): Fiber { return { instance: instance ?? `inst-${tag}-${key ?? "nokey"}`, tag, props: {}, key, children: [], parent: null, effect: null, signalDisposers: [], prevProps: null, cachedNode: null, }; } function makeClassification( overrides: Partial> = {}, ): ChildClassification { return { matched: [], added: [], removed: [], moves: new Map(), ...overrides, }; } it("appendChild for added child at end position", () => { const ops: string[] = []; const parentFiber = makeFiber(undefined, "div", "parent-inst"); const addedFiber = makeFiber("b", "span", "span-inst"); const host: HostConfig = { name: "test", createRootContext: () => ({}), createInstance: () => "inst", createTextInstance: () => "text", appendChild: (p, c) => ops.push(`appendChild(${p},${c})`), removeChild: (p, c) => ops.push(`removeChild(${p},${c})`), }; const existingFiber = makeFiber("a", "div", "a-inst"); existingFiber.parent = parentFiber; parentFiber.children = [existingFiber]; const classification = makeClassification({ matched: [{ oldFiber: existingFiber, newChild: { type: "div", props: {}, children: [], key: "a" }, index: 0 }], added: [{ newChild: { type: "span", props: {}, children: [], key: "b" }, index: 1 }], }); const commitCtx: CommitContext = { host: host as HostConfig, ctx: {}, createFiber: () => addedFiber, }; commitMutations(parentFiber, classification, commitCtx); expect(ops).toContain("appendChild(parent-inst,span-inst)"); }); it("removeChild called for removed child", () => { const ops: string[] = []; const parentFiber = makeFiber(undefined, "div", "parent-inst"); const removedFiber = makeFiber("b", "span", "b-inst"); removedFiber.parent = parentFiber; const host: HostConfig = { name: "test", createRootContext: () => ({}), createInstance: () => "inst", createTextInstance: () => "text", appendChild: () => {}, removeChild: (p, c) => ops.push(`removeChild(${p},${c})`), }; const classification = makeClassification({ removed: [removedFiber], }); const commitCtx: CommitContext = { host: host as HostConfig, ctx: {}, createFiber: () => makeFiber("x", "x", "x"), }; commitMutations(parentFiber, classification, commitCtx); expect(ops).toContain("removeChild(parent-inst,b-inst)"); }); it("insertBefore called for moved child with staying sibling after it", () => { const ops: string[] = []; const parentFiber = makeFiber(undefined, "div", "parent-inst"); const aFiber = makeFiber("a", "div", "a-inst"); aFiber.parent = parentFiber; const bFiber = makeFiber("b", "span", "b-inst"); bFiber.parent = parentFiber; const cFiber = makeFiber("c", "p", "c-inst"); cFiber.parent = parentFiber; parentFiber.children = [aFiber, bFiber, cFiber]; const host: HostConfig = { name: "test", createRootContext: () => ({}), createInstance: () => "inst", createTextInstance: () => "text", appendChild: (p, c) => ops.push(`appendChild(${p},${c})`), insertBefore: (p, c, b) => ops.push(`insertBefore(${p},${c},${b})`), removeChild: (p, c) => ops.push(`removeChild(${p},${c})`), }; // Reorder: [b, a, c] → moves b before a (a is staying) const classification: ChildClassification = { matched: [ { oldFiber: bFiber, newChild: { type: "span", props: {}, children: [], key: "b" }, index: 0 }, { oldFiber: aFiber, newChild: { type: "div", props: {}, children: [], key: "a" }, index: 1 }, { oldFiber: cFiber, newChild: { type: "p", props: {}, children: [], key: "c" }, index: 2 }, ], added: [], removed: [], moves: new Map([[0, bFiber]]), }; const commitCtx: CommitContext = { host: host as HostConfig, ctx: {}, createFiber: () => makeFiber("x", "x", "x"), }; commitMutations(parentFiber, classification, commitCtx); expect(ops.some(op => op.includes("insertBefore"))).toBe(true); }); it("updates committed top-down (parent before child)", () => { const ops: string[] = []; const parentFiber = makeFiber(undefined, "div", "parent-inst"); parentFiber.effect = { type: "update", payload: {} }; parentFiber.prevProps = {}; parentFiber.props = { color: "blue" }; const childFiber = makeFiber("a", "span", "child-inst"); childFiber.parent = parentFiber; childFiber.effect = { type: "update", payload: {} }; childFiber.prevProps = {}; childFiber.props = { size: "big" }; parentFiber.children = [childFiber]; const host: HostConfig = { name: "test", createRootContext: () => ({}), createInstance: () => "inst", createTextInstance: () => "text", appendChild: () => {}, commitUpdate: (inst) => ops.push(`commitUpdate(${inst})`), }; const classification = makeClassification({ matched: [{ oldFiber: childFiber, newChild: { type: "span", props: { size: "big" }, children: [], key: "a" }, index: 0, }], }); const commitCtx: CommitContext = { host: host as HostConfig, ctx: {}, createFiber: () => makeFiber("x", "x", "x"), }; commitMutations(parentFiber, classification, commitCtx); const parentIdx = ops.indexOf("commitUpdate(parent-inst)"); const childIdx = ops.indexOf("commitUpdate(child-inst)"); expect(parentIdx).toBeLessThan(childIdx); }); }); describe("finalizeInstance during commit/remove pipeline", () => { function makeFiber(key: string | undefined, tag: string, instance?: string): Fiber { return { instance: instance ?? `inst-${tag}-${key ?? "nokey"}`, tag, props: {}, key, children: [], parent: null, effect: null, signalDisposers: [], prevProps: null, cachedNode: null, disposed: false, }; } it("host implementing finalizeInstance receives calls during disposal via commitMutations", () => { const finalized: string[] = []; const parentFiber = makeFiber(undefined, "div", "parent-inst"); const removedFiber = makeFiber("b", "span", "b-inst"); removedFiber.parent = parentFiber; const host: HostConfig = { name: "test", createRootContext: () => ({}), createInstance: () => "inst", createTextInstance: () => "text", appendChild: () => {}, removeChild: () => {}, finalizeInstance: (instance) => { finalized.push(instance); }, }; const classification: ChildClassification = { matched: [], added: [], removed: [removedFiber], moves: new Map(), }; const commitCtx: CommitContext = { host: host as HostConfig, ctx: {}, createFiber: () => makeFiber("x", "x", "x"), }; commitMutations(parentFiber, classification, commitCtx); expect(finalized).toContain("b-inst"); expect(removedFiber.disposed).toBe(true); }); it("finalizeInstance called bottom-up: children before parents", () => { const finalized: string[] = []; const parentFiber = makeFiber(undefined, "div", "parent-inst"); const childFiber = makeFiber(undefined, "span", "child-inst"); childFiber.parent = parentFiber; parentFiber.children = [childFiber]; disposeFiber(parentFiber, { finalizeInstance: (instance) => { finalized.push(instance); }, }, {}); expect(finalized).toEqual(["child-inst", "parent-inst"]); }); it("finalizeInstance is optional — hosts without it still work", () => { const parentFiber = makeFiber(undefined, "div", "parent-inst"); const removedFiber = makeFiber("b", "span", "b-inst"); removedFiber.parent = parentFiber; const host: HostConfig = { name: "test", createRootContext: () => ({}), createInstance: () => "inst", createTextInstance: () => "text", appendChild: () => {}, removeChild: () => {}, }; const classification: ChildClassification = { matched: [], added: [], removed: [removedFiber], moves: new Map(), }; const commitCtx: CommitContext = { host: host as HostConfig, ctx: {}, createFiber: () => makeFiber("x", "x", "x"), }; expect(() => commitMutations(parentFiber, classification, commitCtx)).not.toThrow(); expect(removedFiber.disposed).toBe(true); }); it("disposeFiber calls finalizeInstance and clears signal disposer", () => { const disposed: string[] = []; const fiber: Fiber = { instance: "inst-1", tag: "div", props: {}, key: undefined, children: [], parent: null, effect: null, signalDisposers: [() => disposed.push("signal-a"), () => disposed.push("signal-b")], prevProps: null, cachedNode: null, disposed: false, }; const finalized: string[] = []; disposeFiber(fiber, { finalizeInstance: (instance) => { finalized.push(instance); }, }, {}); expect(finalized).toEqual(["inst-1"]); expect(disposed).toEqual(["signal-a", "signal-b"]); expect(fiber.disposed).toBe(true); }); });