Files
ujsx/test/reactiveroot-dispose.test.ts
glm-5.1 f14916c1bb 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.
2026-05-18 17:23:51 +00:00

153 lines
5.4 KiB
TypeScript

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