Merge branch 'feat/unmount-implementation'

This commit is contained in:
2026-05-18 17:31:43 +00:00
2 changed files with 196 additions and 0 deletions

View File

@@ -2,6 +2,7 @@ import type { UNode, UElement, URoot, ComponentFn, UComponent } from "../core/sc
import { isURoot, isUPrimitive } from "../core/schema.js"; import { isURoot, isUPrimitive } from "../core/schema.js";
import { Context } from "../core/context.js"; import { Context } from "../core/context.js";
import type { Fiber } from "./fiber.js"; import type { Fiber } from "./fiber.js";
import { disposeFiber } from "./fiber.js";
import { reconcileProps, reconcileChildren, commitMutations } from "./reconcile.js"; import { reconcileProps, reconcileChildren, commitMutations } from "./reconcile.js";
import type { CommitContext } from "./reconcile.js"; import type { CommitContext } from "./reconcile.js";
@@ -302,8 +303,18 @@ export function createRoot<TTag extends string, Instance, RootCtx>(
host.emit?.("root.render", `root_${Date.now()}`, { childCount: payloadChildren.length }); host.emit?.("root.render", `root_${Date.now()}`, { childCount: payloadChildren.length });
}, },
unmount() { unmount() {
const rootFiber = this.rootFiber;
if (!rootFiber) return;
disposeFiber(rootFiber, host as import("./fiber.js").HostLike<Instance, RootCtx>, ctx);
for (const child of rootFiber.children) {
host.removeChild?.(rootFiber.instance as never, child.instance as never, ctx as never);
}
host.finalizeRoot?.(ctx); host.finalizeRoot?.(ctx);
host.emit?.("root.unmount", `root_${Date.now()}`, {}); host.emit?.("root.unmount", `root_${Date.now()}`, {});
this.rootFiber = null;
}, },
}; };
} }

View File

@@ -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<string, string, Record<string, unknown>>;
removes: { parent: string; child: string }[];
finalizeInstanceCalls: string[];
finalizeRootCalls: { count: number };
emittedEvents: { type: string; id: string; payload: unknown }[];
instances: { tag: string; props: Record<string, unknown> }[];
appends: { parent: string; child: string }[];
} {
const instances: { tag: string; props: Record<string, unknown> }[] = [];
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<string, string, Record<string, unknown>> = {
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();
});
});