feat: implement full Root.unmount() with disposeFiber and instance cleanup
This commit is contained in:
@@ -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;
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
185
test/unmount-implementation.test.ts
Normal file
185
test/unmount-implementation.test.ts
Normal 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user