185 lines
5.9 KiB
TypeScript
185 lines
5.9 KiB
TypeScript
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();
|
|
});
|
|
}); |