Add disposeFiber() function that performs bottom-up teardown of fiber subtrees: recursively disposes children before parents, calls host.finalizeInstance for per-instance cleanup, invokes signal disposers, and clears fiber state. Idempotent via disposed flag. Does NOT call host.removeChild (that's the commit phase's job). - Add disposeFiber + HostLike to src/host/fiber.ts - Add finalizeInstance to HostConfig interface - Add disposed boolean to Fiber interface - Export disposeFiber and HostLike from barrel - Add 7 tests for disposeFiber (3-level tree, idempotency, signal cleanup, etc.)
383 lines
14 KiB
TypeScript
383 lines
14 KiB
TypeScript
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";
|
|
|
|
function makeHost(): {
|
|
host: HostConfig<string, string, Record<string, unknown>>;
|
|
operations: string[];
|
|
instances: { tag: string; props: Record<string, unknown> }[];
|
|
} {
|
|
const operations: string[] = [];
|
|
const instances: { tag: string; props: Record<string, unknown> }[] = [];
|
|
|
|
const host: HostConfig<string, string, Record<string, unknown>> = {
|
|
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<string, string, Record<string, unknown>> = {
|
|
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<string> {
|
|
return {
|
|
instance: instance ?? `inst-${tag}-${key ?? "nokey"}`,
|
|
tag,
|
|
props: {},
|
|
key,
|
|
children: [],
|
|
parent: null,
|
|
effect: null,
|
|
signalDisposers: [],
|
|
prevProps: null,
|
|
disposed: false,
|
|
};
|
|
}
|
|
|
|
function makeClassification<I>(
|
|
overrides: Partial<ChildClassification<I>> = {},
|
|
): ChildClassification<I> {
|
|
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<string, string, unknown> = {
|
|
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<string> = {
|
|
host: host as HostConfig<string, string, unknown>,
|
|
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<string, string, unknown> = {
|
|
name: "test",
|
|
createRootContext: () => ({}),
|
|
createInstance: () => "inst",
|
|
createTextInstance: () => "text",
|
|
appendChild: () => {},
|
|
removeChild: (p, c) => ops.push(`removeChild(${p},${c})`),
|
|
};
|
|
|
|
const classification = makeClassification({
|
|
removed: [removedFiber],
|
|
});
|
|
|
|
const commitCtx: CommitContext<string> = {
|
|
host: host as HostConfig<string, string, unknown>,
|
|
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<string, string, unknown> = {
|
|
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<string> = {
|
|
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<string> = {
|
|
host: host as HostConfig<string, string, unknown>,
|
|
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<string, string, unknown> = {
|
|
name: "test",
|
|
createRootContext: () => ({}),
|
|
createInstance: () => "inst",
|
|
createTextInstance: () => "text",
|
|
appendChild: () => {},
|
|
commitUpdate: (inst) => ops.push(`commitUpdate(${inst})`),
|
|
};
|
|
|
|
const classification = makeClassification<string>({
|
|
matched: [{
|
|
oldFiber: childFiber,
|
|
newChild: { type: "span", props: { size: "big" }, children: [], key: "a" },
|
|
index: 0,
|
|
}],
|
|
});
|
|
|
|
const commitCtx: CommitContext<string> = {
|
|
host: host as HostConfig<string, string, unknown>,
|
|
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);
|
|
});
|
|
}); |