diff --git a/packages/functorial/src/reactive.test.ts b/packages/functorial/src/reactive.test.ts index 6e5f2e1..212dd96 100644 --- a/packages/functorial/src/reactive.test.ts +++ b/packages/functorial/src/reactive.test.ts @@ -56,7 +56,9 @@ Deno.test("value listeners", () => { const r: Record = reactive({ a: 1 }); let event: ReactiveEvent | undefined; - listen(r, (e) => (event = e)); + listen(r, (e) => { + event = e; + }); // create r.new = true; @@ -84,7 +86,11 @@ Deno.test("function listeners", () => { const r: Record any> = reactive({ a: f1 }); const events: ReactiveEvent[] = []; - listen(r, (e) => events.push(e)); + listen(r, (e) => { + { + events.push(e); + } + }); assertEquals(logs.length, 0); @@ -130,7 +136,11 @@ Deno.test("events collapse", () => { const r: Record = reactive({ a: 1, b }); const events: ReactiveEvent[] = []; - listen(r, (e) => events.push(e)); + listen(r, (e) => { + { + events.push(e); + } + }); // no update to the initial state r.a = 1; @@ -178,7 +188,9 @@ Deno.test("only dependencies trigger events", () => { }); const events: ReactiveEvent[] = []; - listen(s, (e) => events.push(e)); + listen(s, (e) => { + events.push(e); + }); // no event is triggered on s when updating something it doesn't depend on r.b = 3; @@ -218,16 +230,24 @@ Deno.test("glitch free (diamond)", () => { }); let aEvent: ReactiveEvent | undefined; - listen(a, (e) => (aEvent = e)); + listen(a, (e) => { + aEvent = e; + }); let bEvent: ReactiveEvent | undefined; - listen(b, (e) => (bEvent = e)); + listen(b, (e) => { + bEvent = e; + }); let cEvent: ReactiveEvent | undefined; - listen(c, (e) => (cEvent = e)); + listen(c, (e) => { + cEvent = e; + }); let dEvent: ReactiveEvent | undefined; - listen(d, (e) => (dEvent = e)); + listen(d, (e) => { + dEvent = e; + }); assertEquals(d.d, 2 + 2); assertEquals(seenGlitch, false); @@ -295,7 +315,9 @@ Deno.test("preserve `this` binding in reactive functions", () => { }; const events: ReactiveEvent[] = []; - listen(r, (e) => events.push(e)); + listen(r, (e) => { + events.push(e); + }); // already bound const getX = r.getX; @@ -359,10 +381,14 @@ Deno.test("destructuring object maintains reactivity", () => { const { first } = r; const firstEvents: ReactiveEvent[] = []; - listen(first, (e) => firstEvents.push(e)); + listen(first, (e) => { + firstEvents.push(e); + }); const rEvents: ReactiveEvent[] = []; - listen(r, (e) => rEvents.push(e)); + listen(r, (e) => { + rEvents.push(e); + }); // updating `r` triggers the update event on `first` r.first.a = false; @@ -394,7 +420,9 @@ Deno.test("reactive iterables can be mutated during iteration", () => { const arr = reactive([{ a: 1 }]); const events: ReactiveEvent[] = []; - listen(arr, (e) => events.push(e)); + listen(arr, (e) => { + events.push(e); + }); // returns a proxied iterator for (const element of arr) { @@ -413,7 +441,9 @@ Deno.test("reactive iterables can be mutated during iteration", () => { // Map const map = reactive(new Map([["a", { value: true }]])); - listen(map, (e) => events.push(e)); + listen(map, (e) => { + events.push(e); + }); // we can destructure iterated items for (const [_k, item] of map) { @@ -435,7 +465,9 @@ Deno.test("auto prunes relations", () => { const r = reactive([{ value: "a" }]); const events: ReactiveEvent[] = []; - listen(r, (e) => events.push(e)); + listen(r, (e) => { + events.push(e); + }); const a = r[0]!; assertEquals(a?.value, "a"); @@ -459,28 +491,20 @@ Deno.test("nested listeners", () => { const MSG2 = "even"; const MSG3 = "odd"; - let disposer: DisposableStack | undefined; - listen(r, (e) => { - disposer?.dispose(); events.push({ ...e, info: "root listener" }); if (e.type === "update") { - disposer = new DisposableStack(); if (e.newValue % 2 === 0) { - disposer.use( - listen(r, (e1) => { - events.push({ ...e1, info: "even listener" }); - (e.newValue % 6 === 0) ? logs.push(MSG1) : logs.push(MSG2); - }), - ); + return listen(r, (e1) => { + events.push({ ...e1, info: "even listener" }); + (e.newValue % 6 === 0) ? logs.push(MSG1) : logs.push(MSG2); + }); } else { - disposer.use( - listen(r, (e2) => { - events.push({ ...e2, info: "odd listener" }); - logs.push(MSG3); - }), - ); + return listen(r, (e2) => { + events.push({ ...e2, info: "odd listener" }); + logs.push(MSG3); + }); } } }); @@ -508,7 +532,9 @@ Deno.test("derived getters", () => { }); const events: ReactiveEvent[] = []; - listen(second, (e) => events.push(e)); + listen(second, (e) => { + events.push(e); + }); assertEquals(first.a, true); assertEquals(second.b, false); @@ -535,7 +561,9 @@ Deno.test("self-derived values", () => { }); const events: ReactiveEvent[] = []; - listen(state, (e) => events.push(e)); + listen(state, (e) => { + events.push(e); + }); assertEquals(state.count, 0); assertEquals(state.even, true); @@ -568,7 +596,9 @@ Deno.test("upgrading derived events", () => { let event: ReactiveEvent | undefined; - listen(second, (e) => (event = e)); + listen(second, (e) => { + event = e; + }); assertEquals(first.a, 1); assertEquals(second.value, 3); @@ -602,7 +632,9 @@ Deno.test("nested derivations", () => { }); const events: ReactiveEvent[] = []; - listen(fullname, (e) => (events.push(e))); + listen(fullname, (e) => { + events.push(e); + }); assertEquals(lower.first, "john"); assertEquals(lower.last, "doe"); @@ -693,16 +725,24 @@ Deno.test("bubbling derived events", () => { }); let sOuterEvent: ReactiveEvent | undefined; - listen(second.outer, (e) => (sOuterEvent = e)); + listen(second.outer, (e) => { + sOuterEvent = e; + }); let sInnerEvent: ReactiveEvent | undefined; - listen(second.outer.inner, (e) => (sInnerEvent = e)); + listen(second.outer.inner, (e) => { + sInnerEvent = e; + }); let tOuterEvent: ReactiveEvent | undefined; - listen(third.outer, (e) => (tOuterEvent = e)); + listen(third.outer, (e) => { + tOuterEvent = e; + }); let tInnerEvent: ReactiveEvent | undefined; - listen(third.outer.inner, (e) => (tInnerEvent = e)); + listen(third.outer.inner, (e) => { + tInnerEvent = e; + }); // track assertEquals(second.outer.inner.b, false); @@ -746,7 +786,9 @@ Deno.test("relabelling", () => { const r = reactive([{ value: "a" }, { value: "b" }]); const events: ReactiveEvent[] = []; - listen(r, (e) => events.push(e)); + listen(r, (e) => { + events.push(e); + }); assertEquals(events.length, 0); @@ -780,13 +822,19 @@ Deno.test("nested relabelling", () => { }); const rEvents: ReactiveEvent[] = []; - listen(r, (e) => rEvents.push(e)); + listen(r, (e) => { + rEvents.push(e); + }); const firstEvents: ReactiveEvent[] = []; - listen(r.first, (e) => firstEvents.push(e)); + listen(r.first, (e) => { + firstEvents.push(e); + }); const secondEvents: ReactiveEvent[] = []; - listen(r.first.second, (e) => secondEvents.push(e)); + listen(r.first.second, (e) => { + secondEvents.push(e); + }); r.first.second.values.shift(); flushSync(); @@ -825,7 +873,9 @@ Deno.test("derived relabelling", () => { }); const dEvents: ReactiveEvent[] = []; - listen(d, (e) => dEvents.push(e)); + listen(d, (e) => { + dEvents.push(e); + }); assertEquals(d.value, 3); @@ -849,7 +899,9 @@ Deno.test("can compose reactive", () => { const r = reactive({ a: bool }); const events: ReactiveEvent[] = []; - listen(r, (e) => events.push(e)); + listen(r, (e) => { + events.push(e); + }); // the dependency tracking between r and bool begins when the path is taken assertEquals(r.a.value, false); @@ -877,9 +929,15 @@ Deno.test("multi-parent adoption", () => { let event1: ReactiveEvent | undefined; let event2: ReactiveEvent | undefined; - listen(r, (e) => (event = e)); - listen(r1, (e) => (event1 = e)); - listen(r2, (e) => (event2 = e)); + listen(r, (e) => { + event = e; + }); + listen(r1, (e) => { + event1 = e; + }); + listen(r2, (e) => { + event2 = e; + }); // the dependency tracking between r and bool begins when the path is taken assertEquals(r.a.value, false); @@ -918,19 +976,27 @@ Deno.test("multi-parent adoption", () => { Deno.test("bubbling", () => { const r = reactive({ a: { b: { c: { d: { e: { f: true } } } } } }); let event: ReactiveEvent | undefined; - listen(r, (e) => (event = e)); + listen(r, (e) => { + event = e; + }); const refC = r.a.b.c; let refCEvent: ReactiveEvent | undefined; - listen(refC, (e) => (refCEvent = e)); + listen(refC, (e) => { + refCEvent = e; + }); const refD = r.a.b.c.d; let refDEvent: ReactiveEvent | undefined; - listen(refD, (e) => (refDEvent = e)); + listen(refD, (e) => { + refDEvent = e; + }); const refE = r.a.b.c.d.e; let refEEvent: ReactiveEvent | undefined; - listen(refE, (e) => (refEEvent = e)); + listen(refE, (e) => { + refEEvent = e; + }); // root, c and d see the create event but not e // @ts-ignore @@ -1151,7 +1217,9 @@ Deno.test("array functoriality", () => { }); const events: ReactiveEvent[] = []; - listen(r, (e) => events.push(e)); + listen(r, (e) => { + events.push(e); + }); // we can transport operations @@ -1198,7 +1266,9 @@ Deno.test("array length property", () => { const r = reactive([1, 2, 3]); const events: ReactiveEvent[] = []; - listen(r, (e) => events.push(e)); + listen(r, (e) => { + events.push(e); + }); assertEquals(events.length, 0); assertEquals(r.length, 3); @@ -1255,7 +1325,9 @@ Deno.test("array-derived values", () => { }); const events: ReactiveEvent[] = []; - listen(derived, (e) => events.push(e)); + listen(derived, (e) => { + events.push(e); + }); assertEquals(derived.sum, 6); assertEquals(derived.len, 3); @@ -1307,7 +1379,9 @@ Deno.test("array relabeling", () => { const r = reactive([3, 1, 2]); const events: ReactiveEvent[] = []; - listen(r, (e) => events.push(e)); + listen(r, (e) => { + events.push(e); + }); assertEquals(events.length, 0); @@ -1420,7 +1494,9 @@ Deno.test("map-derived values", () => { }); const events: ReactiveEvent[] = []; - listen(derived, (e) => events.push(e)); + listen(derived, (e) => { + events.push(e); + }); assertEquals(derived.sum, 6); diff --git a/packages/functorial/src/reactive.ts b/packages/functorial/src/reactive.ts index 40b5625..f22d08b 100644 --- a/packages/functorial/src/reactive.ts +++ b/packages/functorial/src/reactive.ts @@ -58,9 +58,17 @@ export type ReactiveEvent = | { type: "relabel"; labels: [string, string][] }; /** - * Represents the callbacks of the {@linkcode listen} function + * A cleanup function */ -export type ReactiveEventCallback = (event: ReactiveEvent) => void; +export type Cleanup = () => void; + +/** + * The callbacks of the {@linkcode listen} function + * + * @param event The {@linkcode ReactiveEvent} + * @returns Optionally returns a {@linkcode Cleanup} function that runs on the next call + */ +export type ReactiveEventCallback = (event: ReactiveEvent) => void | Cleanup; /** * All data structures are faithfully representable as labelled directed multigraphs. @@ -210,6 +218,7 @@ export function reactive(object: T): T { const derivedValues = new Map(); const derivedLabels = new Map(); const callbacks = new Set(); + const cleanups = new Map(); function emit(e: ReactiveEvent) { // recompute to ensure the correct newValue in the case of batched updates @@ -378,7 +387,11 @@ export function reactive(object: T): T { function notify(e: ReactiveEvent) { for (const callback of callbacks) { - callback(e); + let cleanup = cleanups.get(callback); + cleanup?.(); + cleanup = callback(e) ?? undefined; + // if the cleanup is `undefined` this just removes it + cleanups.set(callback, cleanup); } } @@ -429,12 +442,12 @@ export function reactive(object: T): T { return labels; } - function addListener(callback: ReactiveEventCallback): Disposable { + function addListener(callback: ReactiveEventCallback): Cleanup { callbacks.add(callback); - return { - [Symbol.dispose]() { - callbacks.delete(callback); - }, + return () => { + cleanups.get(callback)?.(); + callbacks.delete(callback); + cleanups.delete(callback); }; } @@ -873,8 +886,10 @@ export function isPrimitive(value: unknown): value is Primitive { ["string", "number", "boolean", "undefined"].includes(typeof value); } +function noop() {} + /** - * Listens to a {@linkcode reactive} graph and runs the provided callback whenever a change or call is detected + * Listens to a {@linkcode reactive} and runs the provided callback whenever a change or method call is detected * * Does nothing if the argument is not reactive * @@ -896,16 +911,16 @@ export function isPrimitive(value: unknown): value is Primitive { * // old: 0, new: 1 * ``` * - * @param {T} node The structure to listen to - * @param {ReactiveEventCallback} callback The callback to run on changes - * @return {Disposable} A cleanup function to remove the listener + * @param node The structure to listen to + * @param callback The callback to run on changes + * @return A cleanup function to that removes the listener */ export function listen( node: T, callback: ReactiveEventCallback, -): Disposable { +): Cleanup { // doing the sanity check here to avoid having these checks all over the codebase - if (!isReactive(node)) return { [Symbol.dispose]() {} }; + if (!isReactive(node)) return noop; return getOwn(node, ns.ADD_LISTENER)(callback); } diff --git a/packages/reflow/src/boundary.ts b/packages/reflow/src/boundary.ts index 116f144..068a9c8 100644 --- a/packages/reflow/src/boundary.ts +++ b/packages/reflow/src/boundary.ts @@ -484,7 +484,7 @@ export class Boundary { spliceBoundaries(0, 0, ...values); // Creates a functorial relation with the original reactive array - this.disposer.use( + this.disposer.defer( listen(values, (e) => { switch (e.type) { case "relabel": { @@ -610,7 +610,7 @@ export class Boundary { const textNode = new Text(String(content[key] ?? "")); this.#end.before(textNode); - this.disposer.use( + this.disposer.defer( listen(content, (e) => { if (e.type !== "update") return; if (e.path !== `.${key}`) return; @@ -628,7 +628,7 @@ export class Boundary { let cleanup = setup(data.cond ? data.ifCase : data.elseCase); - this.disposer.use( + this.disposer.defer( listen(data, (e) => { // ensure we're in the right case before cleaning up if (e.type !== "update" || e.path !== ".cond") return; @@ -643,7 +643,7 @@ export class Boundary { template.innerHTML = data.value; this.replaceChildren(template.content); - this.disposer.use( + this.disposer.defer( listen(data, (e) => { switch (e.type) { case "update": @@ -673,7 +673,7 @@ export class Boundary { this.#end.before(content.fragment); } - disposable.use( + disposable.defer( listen(data, (e) => { if (e.type !== "update" && e.type !== "delete") return; if (e.path !== ".value") return; diff --git a/packages/reflow/src/html.ts b/packages/reflow/src/html.ts index b5e5199..c66567f 100644 --- a/packages/reflow/src/html.ts +++ b/packages/reflow/src/html.ts @@ -214,7 +214,7 @@ class Template { } } - disposer.use( + disposer.defer( listen(attr, (e) => { if (e.type === "relabel" || !(typeof e.path === "string")) return; const key = e.path.split(".")[1]; @@ -274,7 +274,7 @@ class Template { } } - disposer.use( + disposer.defer( listen(classList, (/** @type {ReactiveEvent} */ e) => { if (e.type === "relabel" || !(typeof e.path === "string")) return; const key = e.path.split(".")[1]; @@ -338,7 +338,7 @@ class Template { addListener(key, val as ListenerParams); } - disposer.use( + disposer.defer( listen(listeners, (e) => { if (e.type === "relabel" || !(typeof e.path === "string")) return; const key = e.path.split(".")[1]; @@ -385,7 +385,7 @@ class Template { element[key] = value; } - disposer.use( + disposer.defer( listen(props, (e) => { if (e.type === "relabel" || !(typeof e.path === "string")) return; const key = e.path.split(".")[1]; @@ -430,7 +430,7 @@ class Template { currentElement.style.setProperty(key, String(value)); } - disposer.use( + disposer.defer( listen(style, (e) => { if (e.type === "relabel" || (typeof e.path !== "string")) return; const key = e.path.split(".")[1]; diff --git a/packages/reflow/src/sinks.ts b/packages/reflow/src/sinks.ts index 72db8e7..ca7230d 100644 --- a/packages/reflow/src/sinks.ts +++ b/packages/reflow/src/sinks.ts @@ -611,6 +611,8 @@ export function isUnsafeHTML(value: unknown): value is UnsafeSink { */ export type DerivedSink = Primitive | ReactiveLeaf | TemplateSink; +function noop() {} + /** * Creates a component * @@ -624,7 +626,10 @@ export function component( const scope: EffectScope = { disposer, - listen: (node, callback) => disposer.use(listen(node, callback)), + listen: (node, callback) => { + disposer.defer(listen(node, callback)); + return noop; + }, [Symbol.dispose]() { disposer.dispose(); }, diff --git a/playground/pages/reactivity/Cleanup.ts b/playground/pages/reactivity/Cleanup.ts index e7c361b..256f3b2 100644 --- a/playground/pages/reactivity/Cleanup.ts +++ b/playground/pages/reactivity/Cleanup.ts @@ -1,31 +1,28 @@ -import { derived, reactive } from "@f-stack/functorial"; +import { reactive } from "@f-stack/functorial"; import { component, html, on } from "@f-stack/reflow"; export const Cleanup = component(function () { - const state = reactive({ - interval: 1000, - elapsed: 0, - }); + const elapsed = reactive({ value: 0 }); + const timeout = reactive({ value: 1000 }); const setup = () => setInterval(() => { - state.elapsed += 1; - console.log(state.elapsed); - }, state.interval); + elapsed.value += 1; + console.log(elapsed.value); + }, timeout.value); let id = setup(); this.disposer.defer(() => clearInterval(id)); - this.listen(state, (e) => { - if (e.type === "update" && e.path === ".interval") { - clearInterval(id); - id = setup(); - } + this.listen(timeout, (e) => { + console.log(e); + clearInterval(id); + id = setup(); }); return html` - - -

elapsed: ${derived(() => state.elapsed)}

+ + +

elapsed: ${elapsed}

`; }); diff --git a/playground/pages/templating/html.ts b/playground/pages/templating/html.ts index 3a8ac4f..c0ae0e8 100644 --- a/playground/pages/templating/html.ts +++ b/playground/pages/templating/html.ts @@ -1,6 +1,6 @@ import { attr, html } from "@f-stack/reflow"; // @ts-ignore works in Chrome -import styles from "./hello.css" with { type: "css" }; +// import styles from "./hello.css" with { type: "css" }; document.adoptedStyleSheets.push(styles);