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

@@ -1,6 +1,9 @@
import { signal, computed, effect, batch, type Signal, type ReadonlySignal } from "@preact/signals-core";
import type { UNode, UElement, UniversalProps, UComponent } from "./schema.js";
const _disposedSignal = signal<UNode>(undefined as unknown as UNode);
const disposedReadonlySignal: ReadonlySignal<UNode> = _disposedSignal;
export interface ReactiveNode {
readonly type: string;
readonly signal: ReadonlySignal<UNode>;
@@ -15,13 +18,19 @@ export function reactiveComponent<P extends UniversalProps>(
const props = propsSignal.value;
return component(props);
});
let disposed = false;
return {
get type() {
return component.displayName ?? "anonymous";
},
signal: nodeSignal,
dispose: () => {},
get signal() {
return disposed ? disposedReadonlySignal : nodeSignal;
},
dispose: () => {
if (disposed) return;
disposed = true;
},
};
}
@@ -38,17 +47,25 @@ export function reactiveElement(
children,
} as UElement;
});
let disposed = false;
return {
type,
signal: nodeSignal,
dispose: () => {},
get signal() {
return disposed ? disposedReadonlySignal : nodeSignal;
},
dispose: () => {
if (disposed) return;
disposed = true;
},
};
}
export class ReactiveRoot {
private root: Signal<UNode>;
private renderDisposer: (() => void) | null = null;
private subscriberDisposers: Set<() => void> = new Set();
private disposed = false;
constructor(initial: UNode) {
this.root = signal(initial);
@@ -65,12 +82,24 @@ export class ReactiveRoot {
}
subscribe(listener: (node: UNode) => void): () => void {
return effect(() => {
const disposer = effect(() => {
listener(this.root.value);
});
this.subscriberDisposers.add(disposer);
let unsubscribed = false;
return () => {
if (unsubscribed) return;
unsubscribed = true;
disposer();
this.subscriberDisposers.delete(disposer);
};
}
render(emit: (event: { type: string; id: string; payload: unknown }) => void): () => void {
if (this.renderDisposer) {
this.renderDisposer();
this.renderDisposer = null;
}
this.renderDisposer = effect(() => {
const node = this.root.value;
emit({
@@ -86,6 +115,19 @@ export class ReactiveRoot {
}
};
}
dispose(): void {
if (this.disposed) return;
this.disposed = true;
if (this.renderDisposer) {
this.renderDisposer();
this.renderDisposer = null;
}
for (const disposer of this.subscriberDisposers) {
disposer();
}
this.subscriberDisposers.clear();
}
}
export { signal, computed, effect, batch };

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);
});
});