Implement ReactiveRoot.dispose() and real dispose on ReactiveNode

Adds ReactiveRoot.dispose() which calls render effect disposer, iterates
all tracked subscriber disposers, and clears internal state. subscribe()
now tracks effect disposers in a Set and returns idempotent unsubscribe.
render() now disposes previous render effect before overwriting. Both
reactiveComponent and reactiveElement return real dispose functions that
sever the computed signal reference on disposal.
This commit is contained in:
2026-05-18 17:23:51 +00:00
parent 95995f4602
commit f14916c1bb
2 changed files with 200 additions and 5 deletions

View File

@@ -0,0 +1,153 @@
import { describe, it, expect } from "vitest";
import { signal, computed, ReactiveRoot, reactiveComponent, reactiveElement } from "../src/core/reactive.js";
import { h, createComponent } from "../src/core/h.js";
import { isUElement } from "../src/core/schema.js";
import type { UNode } from "../src/core/schema.js";
describe("ReactiveRoot.dispose()", () => {
it("prevents future effect fires from subscribe()", () => {
const root = new ReactiveRoot(h("div", null, "initial"));
const received: UNode[] = [];
root.subscribe((n) => received.push(n));
expect(received.length).toBe(1);
root.update(() => h("div", null, "second"));
expect(received.length).toBe(2);
root.dispose();
root.update(() => h("div", null, "third"));
expect(received.length).toBe(2);
});
it("prevents future effect fires from render()", () => {
const events: { type: string; id: string; payload: unknown }[] = [];
const root = new ReactiveRoot(h("div", null, "hello"));
root.render((e) => events.push(e));
expect(events.length).toBe(1);
root.dispose();
root.update(() => h("div", null, "world"));
expect(events.length).toBe(1);
});
it("is idempotent — safe to call twice", () => {
const root = new ReactiveRoot(h("div", null, "initial"));
const received: UNode[] = [];
root.subscribe((n) => received.push(n));
root.dispose();
root.dispose();
root.update(() => h("div", null, "after"));
expect(received.length).toBe(1);
});
it("clears renderDisposer", () => {
const root = new ReactiveRoot(h("div", null, "x"));
const stop = root.render(() => {});
root.dispose();
stop();
});
it("iterates all subscriber disposers and calls each", () => {
const root = new ReactiveRoot(h("div", null, "initial"));
const received1: UNode[] = [];
const received2: UNode[] = [];
root.subscribe((n) => received1.push(n));
root.subscribe((n) => received2.push(n));
root.dispose();
root.update(() => h("div", null, "after"));
expect(received1.length).toBe(1);
expect(received2.length).toBe(1);
});
});
describe("subscribe() unsubscribe removes from tracking", () => {
it("unsubscribe removes disposer from tracking", () => {
const root = new ReactiveRoot(h("div", null, "initial"));
const received: UNode[] = [];
const unsub = root.subscribe((n) => received.push(n));
expect(received.length).toBe(1);
unsub();
root.update(() => h("div", null, "after-disconnect"));
expect(received.length).toBe(1);
});
it("unsubscribe is idempotent — safe to call twice", () => {
const root = new ReactiveRoot(h("div", null, "initial"));
const received: UNode[] = [];
const unsub = root.subscribe((n) => received.push(n));
unsub();
unsub();
root.update(() => h("div", null, "after"));
expect(received.length).toBe(1);
});
it("other subscribers still fire after one unsubscribes", () => {
const root = new ReactiveRoot(h("div", null, "initial"));
const received1: UNode[] = [];
const received2: UNode[] = [];
const unsub1 = root.subscribe((n) => received1.push(n));
root.subscribe((n) => received2.push(n));
unsub1();
root.update(() => h("div", null, "updated"));
expect(received1.length).toBe(1);
expect(received2.length).toBe(2);
});
});
describe("reactiveComponent.dispose()", () => {
it("prevents future computed evaluations", () => {
const MyComp = createComponent("MyComp", (props) => h("div", null, props.text as string));
const propsSignal = signal({ text: "hello" });
let evalCount = 0;
const nodeSignal = computed(() => {
evalCount++;
return MyComp(propsSignal.value);
});
const node = nodeSignal.value;
expect(evalCount).toBe(1);
expect(isUElement(node)).toBe(true);
const reactive = reactiveComponent(MyComp, propsSignal);
reactive.dispose();
propsSignal.value = { text: "world" };
expect(reactive.signal.value).toBeUndefined();
});
it("is idempotent — safe to call twice", () => {
const MyComp = createComponent("MyComp", (props) => h("div", null, props.text as string));
const propsSignal = signal({ text: "hello" });
const reactive = reactiveComponent(MyComp, propsSignal);
reactive.dispose();
reactive.dispose();
});
});
describe("reactiveElement.dispose()", () => {
it("cleans up the underlying computed subscription", () => {
const propsSignal = signal({ class: "initial" });
const childSignal = signal<UNode>("child-text");
const reactive = reactiveElement("div", propsSignal, [childSignal]);
expect(reactive.signal.value).toBeDefined();
reactive.dispose();
expect(reactive.signal.value).toBeUndefined();
});
it("is idempotent — safe to call twice", () => {
const propsSignal = signal({});
const reactive = reactiveElement("div", propsSignal, []);
reactive.dispose();
reactive.dispose();
});
});
describe("render() disposes previous render effect on re-call", () => {
it("calling render() twice disposes the first effect", () => {
const root = new ReactiveRoot(h("div", null, "initial"));
const events1: unknown[] = [];
const events2: unknown[] = [];
root.render(() => events1.push(1));
expect(events1.length).toBe(1);
root.render(() => events2.push(2));
expect(events2.length).toBe(1);
root.update(() => h("div", null, "after"));
expect(events1.length).toBe(1);
expect(events2.length).toBe(2);
});
});