import { describe, it, expect } 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 { ReactiveRoot } from "../src/core/reactive.js"; function makeHost(): { host: HostConfig>; removes: { parent: string; child: string }[]; finalizeInstanceCalls: string[]; finalizeRootCalls: { count: number }; emittedEvents: { type: string; id: string; payload: unknown }[]; instances: { tag: string; props: Record }[]; appends: { parent: string; child: string }[]; } { const instances: { tag: string; props: Record }[] = []; const appends: { parent: string; child: string }[] = []; const removes: { parent: string; child: string }[] = []; const finalizeInstanceCalls: string[] = []; const finalizeRootCalls: { count: number } = { count: 0 }; const emittedEvents: { type: string; id: string; payload: unknown }[] = []; const host: HostConfig> = { name: "test", createRootContext: () => ({}), createInstance: (tag, props) => { const id = `${tag}_${instances.length}`; instances.push({ tag, props }); return id; }, createTextInstance: (text) => { instances.push({ tag: "#text", props: { text } }); return `text_${instances.length}`; }, appendChild: (parent, child) => { appends.push({ parent, child }); }, removeChild: (parent, child) => { removes.push({ parent, child }); }, finalizeInstance: (instance) => { finalizeInstanceCalls.push(instance as string); }, finalizeRoot: () => { finalizeRootCalls.count++; }, emit: (type, id, payload) => { emittedEvents.push({ type, id, payload }); }, }; return { host, removes, finalizeInstanceCalls, finalizeRootCalls, emittedEvents, instances, appends }; } describe("Root.unmount()", () => { it("cleans up all host instances via removeChild", () => { const { host, removes } = makeHost(); const root = createHostRoot(host, {}); root.render(h("div", null, h("span", null, "a"), h("em", null))); root.unmount(); expect(removes.length).toBe(1); }); it("clears rootFiber after unmount", () => { const { host } = makeHost(); const root = createHostRoot(host, {}); root.render(h("div", null, "hello")); expect(root.rootFiber).not.toBeNull(); root.unmount(); expect(root.rootFiber).toBeNull(); }); it("calls host.finalizeRoot after disposal and removal", () => { const { host, finalizeRootCalls } = makeHost(); const root = createHostRoot(host, {}); root.render(h("div", null, "hello")); expect(finalizeRootCalls.count).toBe(1); root.unmount(); expect(finalizeRootCalls.count).toBe(2); }); it("emits root.unmount event", () => { const { host, emittedEvents } = makeHost(); const root = createHostRoot(host, {}); root.render(h("div", null, "hello")); root.unmount(); const unmountEvent = emittedEvents.find((e) => e.type === "root.unmount"); expect(unmountEvent).toBeDefined(); }); it("unmount then re-render works (fresh mount)", () => { const { host, instances, appends } = makeHost(); const root = createHostRoot(host, {}); root.render(h("div", null, "hello")); const firstInstanceCount = instances.length; const firstAppendCount = appends.length; root.unmount(); expect(root.rootFiber).toBeNull(); root.render(h("div", null, "world")); expect(root.rootFiber).not.toBeNull(); expect(root.rootFiber!.tag).toBe("#root"); expect(root.rootFiber!.children.length).toBe(1); expect(root.rootFiber!.children[0]!.tag).toBe("div"); expect(root.rootFiber!.disposed).toBe(false); expect(instances.length).toBeGreaterThan(firstInstanceCount); }); it("double unmount is safe (idempotent)", () => { const { host, removes } = makeHost(); const root = createHostRoot(host, {}); root.render(h("div", null, "hello")); root.unmount(); const removesAfterFirst = removes.length; root.unmount(); expect(removes.length).toBe(removesAfterFirst); expect(root.rootFiber).toBeNull(); }); it("unmount with no prior render is safe", () => { const { host, removes } = makeHost(); const root = createHostRoot(host, {}); root.unmount(); expect(root.rootFiber).toBeNull(); expect(removes.length).toBe(0); }); it("calls finalizeInstance for each fiber", () => { const { host, finalizeInstanceCalls } = makeHost(); const root = createHostRoot(host, {}); root.render(h("div", null, h("span", null, "a"), h("em", null))); root.unmount(); expect(finalizeInstanceCalls.length).toBe(5); }); it("signal subscriptions are cleaned up via disposeFiber", () => { const disposed: string[] = []; const { host } = makeHost(); const root = createHostRoot(host, {}); root.render(h("div", null, "hello")); const rootFiber = root.rootFiber!; const divFiber = rootFiber.children[0]!; divFiber.signalDisposers.push(() => disposed.push("div-disposed")); const textFiber = divFiber.children[0]!; textFiber.signalDisposers.push(() => disposed.push("text-disposed")); root.unmount(); expect(disposed).toContain("div-disposed"); expect(disposed).toContain("text-disposed"); }); it("does not auto-dispose ReactiveRoot — consumer responsibility", () => { const { host } = makeHost(); const root = createHostRoot(host, {}); root.render(h("div", null, "hello")); const reactiveRoot = new ReactiveRoot(h("span", null, "test")); const disposeSpy: string[] = []; const origDispose = reactiveRoot.dispose.bind(reactiveRoot); reactiveRoot.dispose = () => { disposeSpy.push("called"); origDispose(); }; root.unmount(); expect(disposeSpy.length).toBe(0); expect(reactiveRoot.value).toBeDefined(); }); });