feat: implement full Root.unmount() with disposeFiber and instance cleanup
This commit is contained in:
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