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:
@@ -1,6 +1,9 @@
|
|||||||
import { signal, computed, effect, batch, type Signal, type ReadonlySignal } from "@preact/signals-core";
|
import { signal, computed, effect, batch, type Signal, type ReadonlySignal } from "@preact/signals-core";
|
||||||
import type { UNode, UElement, UniversalProps, UComponent } from "./schema.js";
|
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 {
|
export interface ReactiveNode {
|
||||||
readonly type: string;
|
readonly type: string;
|
||||||
readonly signal: ReadonlySignal<UNode>;
|
readonly signal: ReadonlySignal<UNode>;
|
||||||
@@ -15,13 +18,19 @@ export function reactiveComponent<P extends UniversalProps>(
|
|||||||
const props = propsSignal.value;
|
const props = propsSignal.value;
|
||||||
return component(props);
|
return component(props);
|
||||||
});
|
});
|
||||||
|
let disposed = false;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
get type() {
|
get type() {
|
||||||
return component.displayName ?? "anonymous";
|
return component.displayName ?? "anonymous";
|
||||||
},
|
},
|
||||||
signal: nodeSignal,
|
get signal() {
|
||||||
dispose: () => {},
|
return disposed ? disposedReadonlySignal : nodeSignal;
|
||||||
|
},
|
||||||
|
dispose: () => {
|
||||||
|
if (disposed) return;
|
||||||
|
disposed = true;
|
||||||
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -38,17 +47,25 @@ export function reactiveElement(
|
|||||||
children,
|
children,
|
||||||
} as UElement;
|
} as UElement;
|
||||||
});
|
});
|
||||||
|
let disposed = false;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
type,
|
type,
|
||||||
signal: nodeSignal,
|
get signal() {
|
||||||
dispose: () => {},
|
return disposed ? disposedReadonlySignal : nodeSignal;
|
||||||
|
},
|
||||||
|
dispose: () => {
|
||||||
|
if (disposed) return;
|
||||||
|
disposed = true;
|
||||||
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export class ReactiveRoot {
|
export class ReactiveRoot {
|
||||||
private root: Signal<UNode>;
|
private root: Signal<UNode>;
|
||||||
private renderDisposer: (() => void) | null = null;
|
private renderDisposer: (() => void) | null = null;
|
||||||
|
private subscriberDisposers: Set<() => void> = new Set();
|
||||||
|
private disposed = false;
|
||||||
|
|
||||||
constructor(initial: UNode) {
|
constructor(initial: UNode) {
|
||||||
this.root = signal(initial);
|
this.root = signal(initial);
|
||||||
@@ -65,12 +82,24 @@ export class ReactiveRoot {
|
|||||||
}
|
}
|
||||||
|
|
||||||
subscribe(listener: (node: UNode) => void): () => void {
|
subscribe(listener: (node: UNode) => void): () => void {
|
||||||
return effect(() => {
|
const disposer = effect(() => {
|
||||||
listener(this.root.value);
|
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 {
|
render(emit: (event: { type: string; id: string; payload: unknown }) => void): () => void {
|
||||||
|
if (this.renderDisposer) {
|
||||||
|
this.renderDisposer();
|
||||||
|
this.renderDisposer = null;
|
||||||
|
}
|
||||||
this.renderDisposer = effect(() => {
|
this.renderDisposer = effect(() => {
|
||||||
const node = this.root.value;
|
const node = this.root.value;
|
||||||
emit({
|
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 };
|
export { signal, computed, effect, batch };
|
||||||
|
|||||||
153
test/reactiveroot-dispose.test.ts
Normal file
153
test/reactiveroot-dispose.test.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user