diff --git a/src/host/config.ts b/src/host/config.ts index 10a8657..606af1c 100644 --- a/src/host/config.ts +++ b/src/host/config.ts @@ -2,6 +2,7 @@ import type { UNode, UElement, URoot, ComponentFn, UComponent } from "../core/sc import { isURoot, isUPrimitive } from "../core/schema.js"; import { Context } from "../core/context.js"; import type { Fiber } from "./fiber.js"; +import { disposeFiber } from "./fiber.js"; import { reconcileProps, reconcileChildren, commitMutations } from "./reconcile.js"; import type { CommitContext } from "./reconcile.js"; @@ -302,8 +303,18 @@ export function createRoot( host.emit?.("root.render", `root_${Date.now()}`, { childCount: payloadChildren.length }); }, unmount() { + const rootFiber = this.rootFiber; + if (!rootFiber) return; + + disposeFiber(rootFiber, host as import("./fiber.js").HostLike, ctx); + + for (const child of rootFiber.children) { + host.removeChild?.(rootFiber.instance as never, child.instance as never, ctx as never); + } + host.finalizeRoot?.(ctx); host.emit?.("root.unmount", `root_${Date.now()}`, {}); + this.rootFiber = null; }, }; } \ No newline at end of file diff --git a/test/unmount-implementation.test.ts b/test/unmount-implementation.test.ts new file mode 100644 index 0000000..5efa822 --- /dev/null +++ b/test/unmount-implementation.test.ts @@ -0,0 +1,185 @@ +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(); + }); +}); \ No newline at end of file