diff --git a/src/core/reactive.ts b/src/core/reactive.ts index 59c37cb..d777295 100644 --- a/src/core/reactive.ts +++ b/src/core/reactive.ts @@ -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(undefined as unknown as UNode); +const disposedReadonlySignal: ReadonlySignal = _disposedSignal; + export interface ReactiveNode { readonly type: string; readonly signal: ReadonlySignal; @@ -15,13 +18,19 @@ export function reactiveComponent

( 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; 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 }; diff --git a/test/reactiveroot-dispose.test.ts b/test/reactiveroot-dispose.test.ts new file mode 100644 index 0000000..14b72b8 --- /dev/null +++ b/test/reactiveroot-dispose.test.ts @@ -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("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); + }); +}); \ No newline at end of file