From a991694d2e074fef9afd04ed37b205709dd6d520 Mon Sep 17 00:00:00 2001 From: Claudia Meadows Date: Wed, 30 Oct 2024 18:27:07 -0700 Subject: [PATCH] Add a single-value tracker, optimize tracked handle. --- src/entry/mithril.esm.js | 3 +- src/std/tracked.js | 116 +++++++++++++++++++--------- test-utils/global.js | 4 +- tests/std/tracked.js | 161 ++++++++++++++++++++++++--------------- 4 files changed, 184 insertions(+), 100 deletions(-) diff --git a/src/entry/mithril.esm.js b/src/entry/mithril.esm.js index cf0364fb9..a45d2ad6e 100644 --- a/src/entry/mithril.esm.js +++ b/src/entry/mithril.esm.js @@ -3,9 +3,9 @@ import m from "../core.js" import {debouncer, throttler} from "../std/rate-limit.js" import {link, route} from "../std/router.js" import {match, p, query} from "../std/path-query.js" +import {tracked, trackedList} from "../std/tracked.js" import init from "../std/init.js" import lazy from "../std/lazy.js" -import tracked from "../std/tracked.js" import withProgress from "../std/with-progress.js" m.route = route @@ -17,6 +17,7 @@ m.withProgress = withProgress m.lazy = lazy m.init = init m.tracked = tracked +m.trackedList = trackedList m.throttler = throttler m.debouncer = debouncer diff --git a/src/std/tracked.js b/src/std/tracked.js index bb2553481..2b4e7e2ef 100644 --- a/src/std/tracked.js +++ b/src/std/tracked.js @@ -43,9 +43,15 @@ why that was removed in favor of this: want to clear some state and not other state. You might want to preserve some elements of a sibling's state. Embedding it in the renderer would force an opinion on you, and in order to work around it, you'd have to do something like this anyways. + +As for the difference between `m.trackedList()` and `m.tracked()`, the first is for tracking lists +(and is explained above), and `m.tracked()` is for single values (but uses `m.trackedList()` +internally to avoid a ton of code duplication). */ -import {checkCallback} from "../util.js" +import m from "../core.js" + +import {checkCallback, noop} from "../util.js" /** * @template K, V @@ -55,6 +61,7 @@ import {checkCallback} from "../util.js" * @property {V} value * @property {AbortSignal} signal * @property {() => void} release + * @property {() => void} remove */ /** @@ -70,47 +77,64 @@ import {checkCallback} from "../util.js" * @property {(key: K) => boolean} delete */ -/** - * @template K, V - * @param {Iterable<[K, V]>} [initial] - * @param {() => void} redraw - * @returns {Tracked} - */ -var tracked = (redraw, initial) => { +var trackedState = (redraw) => { checkCallback(redraw, false, "redraw") - - /** @type {Map & {_: AbortController}>} */ var state = new Map() + /** @type {Map>} */ + var state = new Map() + var removed = new WeakSet() /** @type {Set>} */ var live = new Set() + /** @param {null | AbortController & TrackedHandle} prev */ var abort = (prev) => { try { if (prev) { - if (prev._) prev._.abort() - else live.delete(prev) + if (removed.has(prev)) { + live.delete(prev) + } else { + prev.abort() + } } } catch (e) { console.error(e) } } - // Bit 1 forcibly releases the old handle, and bit 2 causes an update notification to be sent - // (something that's unwanted during initialization). + /** @param {K} k */ + var remove = (k, r) => { + var prev = state.get(k) + var result = state.delete(k) + abort(prev) + if (r) redraw() + return result + } + + /** + * @param {K} k + * @param {V} v + * @param {number} bits + * Bit 1 forcibly releases the old handle, and bit 2 causes an update notification to be sent + * (something that's unwanted during initialization). + */ var setHandle = (k, v, bits) => { var prev = state.get(k) - var ctrl = new AbortController() - /** @type {TrackedHandle} */ - var handle = { - _: ctrl, - key: k, - value: v, - signal: ctrl.signal, - release() { - if (state.get(handle.key) === handle) { - handle._ = null - } else if (live.delete(handle)) { - redraw() - } - }, + // Note: it extending `AbortController` is an implementation detail. It exposing a `signal` + // property is *not*. + var handle = /** @type {AbortController & TrackedHandle} */ (new AbortController()) + handle.key = k + handle.value = v + handle.release = (ev) => { + if (ev) m.capture(ev) + if (!handle) return + if (state.get(handle.key) === handle) { + removed.add(handle) + handle = null + } else if (live.delete(handle)) { + redraw() + } + } + handle.remove = (ev) => { + if (ev) m.capture(ev) + remove(handle.key, 0) } state.set(k, handle) live.add(handle) @@ -121,6 +145,18 @@ var tracked = (redraw, initial) => { if (bits & 2) redraw() } + return {s: state, l: live, h: setHandle, r: remove} +} + +/** + * @template K, V + * @param {Iterable<[K, V]>} [initial] + * @param {() => void} redraw + * @returns {TrackedList} + */ +var trackedList = (redraw, initial) => { + var {s: state, l: live, h: setHandle, r: remove} = trackedState(redraw) + for (var [k, v] of initial || []) setHandle(k, v, 1) return { @@ -130,14 +166,22 @@ var tracked = (redraw, initial) => { get: (k) => (k = state.get(k)) && k.value, set: (k, v) => setHandle(k, v, 3), replace: (k, v) => setHandle(k, v, 2), - delete(k) { - var prev = state.get(k) - var result = state.delete(k) - abort(prev) - redraw() - return result - }, + delete: (k) => remove(k, 1), + forget: (k) => (k = state.get(k)) && k.release(), + } +} + +var tracked = (redraw) => { + var {l: live, h: setHandle, r: remove} = trackedState(redraw) + var initial = noop + var id = -1 + return (state) => { + if (!Object.is(initial, initial = state)) { + remove(id++, 0) + setHandle(id, state, 1) + } + return [...live] } } -export {tracked as default} +export {tracked, trackedList} diff --git a/test-utils/global.js b/test-utils/global.js index e92b25c12..4dbedafe1 100644 --- a/test-utils/global.js +++ b/test-utils/global.js @@ -64,7 +64,7 @@ export function setupGlobals(env = {}) { o.beforeEach(() => { initialize({...env}) - return env.initialize && env.initialize() + return env.initialize && env.initialize(G) }) o.afterEach(() => { @@ -87,7 +87,7 @@ export function setupGlobals(env = {}) { o(errors).deepEquals([]) errors.length = 0 o(mock.queueLength()).equals(0) - return env.cleanup && env.cleanup() + return env.cleanup && env.cleanup(G) }) return { diff --git a/tests/std/tracked.js b/tests/std/tracked.js index c0fb57195..d30e61408 100644 --- a/tests/std/tracked.js +++ b/tests/std/tracked.js @@ -2,15 +2,14 @@ import o from "ospec" import m from "../../src/entry/mithril.esm.js" -o.spec("tracked", () => { - /** @param {import("../tracked.js").Tracked} t */ - var live = (t) => t.live().map((h) => [h.key, h.value, h.signal.aborted]) +const readState = (list) => list.map((h) => [h.key, h.value, h.signal.aborted]) +o.spec("trackedList", () => { o("initializes values correctly", () => { - var calls = 0 - var t = m.tracked(() => calls++, [[1, "one"], [2, "two"]]) + let calls = 0 + const t = m.trackedList(() => calls++, [[1, "one"], [2, "two"]]) - o(live(t)).deepEquals([[1, "one", false], [2, "two", false]]) + o(readState(t.live())).deepEquals([[1, "one", false], [2, "two", false]]) o(t.list()).deepEquals([[1, "one"], [2, "two"]]) o(t.has(1)).equals(true) @@ -22,31 +21,31 @@ o.spec("tracked", () => { }) o("tracks values correctly", () => { - var calls = 0 - var t = m.tracked(() => calls++) + let calls = 0 + const t = m.trackedList(() => calls++) t.set(1, "one") o(calls).equals(1) - o(live(t)).deepEquals([[1, "one", false]]) + o(readState(t.live())).deepEquals([[1, "one", false]]) o(t.list()).deepEquals([[1, "one"]]) o(t.has(1)).equals(true) o(t.get(1)).equals("one") - var live1 = t.live()[0] + const live1 = t.live()[0] t.set(2, "two") o(calls).equals(2) - o(live(t)).deepEquals([[1, "one", false], [2, "two", false]]) + o(readState(t.live())).deepEquals([[1, "one", false], [2, "two", false]]) o(t.live()[0]).equals(live1) o(t.list()).deepEquals([[1, "one"], [2, "two"]]) o(t.has(1)).equals(true) o(t.get(1)).equals("one") o(t.has(2)).equals(true) o(t.get(2)).equals("two") - var live2 = t.live()[1] + const live2 = t.live()[1] t.delete(1) o(calls).equals(3) - o(live(t)).deepEquals([[1, "one", true], [2, "two", false]]) + o(readState(t.live())).deepEquals([[1, "one", true], [2, "two", false]]) o(t.live()[0]).equals(live1) o(t.live()[1]).equals(live2) o(t.list()).deepEquals([[2, "two"]]) @@ -57,7 +56,7 @@ o.spec("tracked", () => { live1.release() o(calls).equals(4) - o(live(t)).deepEquals([[2, "two", false]]) + o(readState(t.live())).deepEquals([[2, "two", false]]) o(t.live()[0]).equals(live2) o(t.list()).deepEquals([[2, "two"]]) o(t.has(1)).equals(false) @@ -67,18 +66,18 @@ o.spec("tracked", () => { t.replace(2, "dos") o(calls).equals(5) - o(live(t)).deepEquals([[2, "two", true], [2, "dos", false]]) + o(readState(t.live())).deepEquals([[2, "two", true], [2, "dos", false]]) o(t.live()[0]).equals(live2) o(t.list()).deepEquals([[2, "dos"]]) o(t.has(1)).equals(false) o(t.get(1)).equals(undefined) o(t.has(2)).equals(true) o(t.get(2)).equals("dos") - var live3 = t.live()[1] + const live3 = t.live()[1] live2.release() o(calls).equals(6) - o(live(t)).deepEquals([[2, "dos", false]]) + o(readState(t.live())).deepEquals([[2, "dos", false]]) o(t.live()[0]).equals(live3) o(t.list()).deepEquals([[2, "dos"]]) o(t.has(1)).equals(false) @@ -87,15 +86,15 @@ o.spec("tracked", () => { o(t.get(2)).equals("dos") }) - o("invokes `onUpdate()` after the update is fully completed, including any and all signal aborts", () => { - var live1, live2, live3 - var live1Aborted = false - var live2Aborted = false - var call = 0 - var t = m.tracked(() => { + o("invokes `redraw()` after the update is fully completed, including any and all signal aborts", () => { + let live1, live2, live3 + let live1Aborted = false + let live2Aborted = false + let call = 0 + const t = m.trackedList(() => { switch (++call) { case 1: - o(live(t)).deepEquals([[1, "one", false]]) + o(readState(t.live())).deepEquals([[1, "one", false]]) o(t.list()).deepEquals([[1, "one"]]) o(t.has(1)).equals(true) o(t.get(1)).equals("one") @@ -103,7 +102,7 @@ o.spec("tracked", () => { break case 2: - o(live(t)).deepEquals([[1, "one", false], [2, "two", false]]) + o(readState(t.live())).deepEquals([[1, "one", false], [2, "two", false]]) o(t.live()[0]).equals(live1) o(t.list()).deepEquals([[1, "one"], [2, "two"]]) o(t.has(1)).equals(true) @@ -114,7 +113,7 @@ o.spec("tracked", () => { break case 3: - o(live(t)).deepEquals([[1, "one", true], [2, "two", false]]) + o(readState(t.live())).deepEquals([[1, "one", true], [2, "two", false]]) o(t.live()[0]).equals(live1) o(t.live()[1]).equals(live2) o(t.list()).deepEquals([[2, "two"]]) @@ -125,7 +124,7 @@ o.spec("tracked", () => { break case 4: - o(live(t)).deepEquals([[2, "two", false]]) + o(readState(t.live())).deepEquals([[2, "two", false]]) o(t.live()[0]).equals(live2) o(t.list()).deepEquals([[2, "two"]]) o(t.has(1)).equals(false) @@ -135,7 +134,7 @@ o.spec("tracked", () => { break case 5: - o(live(t)).deepEquals([[2, "two", true], [2, "dos", false]]) + o(readState(t.live())).deepEquals([[2, "two", true], [2, "dos", false]]) o(t.live()[0]).equals(live2) o(t.list()).deepEquals([[2, "dos"]]) o(t.has(1)).equals(false) @@ -146,7 +145,7 @@ o.spec("tracked", () => { break case 6: - o(live(t)).deepEquals([[2, "dos", false]]) + o(readState(t.live())).deepEquals([[2, "dos", false]]) o(t.live()[0]).equals(live3) o(t.list()).deepEquals([[2, "dos"]]) o(t.has(1)).equals(false) @@ -164,7 +163,7 @@ o.spec("tracked", () => { o(call).equals(1) o(live1Aborted).equals(false) o(live2Aborted).equals(false) - var deleteOneStarted = false + let deleteOneStarted = false live1.signal.onabort = () => { live1Aborted = true o(call).equals(2) @@ -175,7 +174,7 @@ o.spec("tracked", () => { o(call).equals(2) o(live1Aborted).equals(false) o(live2Aborted).equals(false) - var deleteTwoStarted = false + let deleteTwoStarted = false live2.signal.onabort = () => { live2Aborted = true o(call).equals(4) @@ -206,18 +205,18 @@ o.spec("tracked", () => { }) o("tracks parallel removes correctly", () => { - var calls = 0 - var t = m.tracked(() => calls++) + let calls = 0 + const t = m.trackedList(() => calls++) t.set(1, "one") - var live1 = t.live()[0] + const live1 = t.live()[0] t.set(2, "two") - var live2 = t.live()[1] + const live2 = t.live()[1] t.delete(1) o(calls).equals(3) - o(live(t)).deepEquals([[1, "one", true], [2, "two", false]]) + o(readState(t.live())).deepEquals([[1, "one", true], [2, "two", false]]) o(t.live()[0]).equals(live1) o(t.live()[1]).equals(live2) o(t.list()).deepEquals([[2, "two"]]) @@ -228,7 +227,7 @@ o.spec("tracked", () => { t.replace(2, "dos") o(calls).equals(4) - o(live(t)).deepEquals([[1, "one", true], [2, "two", true], [2, "dos", false]]) + o(readState(t.live())).deepEquals([[1, "one", true], [2, "two", true], [2, "dos", false]]) o(t.live()[0]).equals(live1) o(t.live()[1]).equals(live2) o(t.list()).deepEquals([[2, "dos"]]) @@ -236,11 +235,11 @@ o.spec("tracked", () => { o(t.get(1)).equals(undefined) o(t.has(2)).equals(true) o(t.get(2)).equals("dos") - var live3 = t.live()[2] + const live3 = t.live()[2] live1.release() o(calls).equals(5) - o(live(t)).deepEquals([[2, "two", true], [2, "dos", false]]) + o(readState(t.live())).deepEquals([[2, "two", true], [2, "dos", false]]) o(t.live()[0]).equals(live2) o(t.list()).deepEquals([[2, "dos"]]) o(t.has(1)).equals(false) @@ -250,7 +249,7 @@ o.spec("tracked", () => { live2.release() o(calls).equals(6) - o(live(t)).deepEquals([[2, "dos", false]]) + o(readState(t.live())).deepEquals([[2, "dos", false]]) o(t.live()[0]).equals(live3) o(t.list()).deepEquals([[2, "dos"]]) o(t.has(1)).equals(false) @@ -260,97 +259,137 @@ o.spec("tracked", () => { }) o("tolerates release before abort", () => { - var calls = 0 - var t = m.tracked(() => calls++) + let calls = 0 + const t = m.trackedList(() => calls++) t.set(1, "one") o(calls).equals(1) - o(live(t)).deepEquals([[1, "one", false]]) + o(readState(t.live())).deepEquals([[1, "one", false]]) o(t.list()).deepEquals([[1, "one"]]) o(t.has(1)).equals(true) o(t.get(1)).equals("one") - var live1 = t.live()[0] + const live1 = t.live()[0] live1.release() o(calls).equals(1) - o(live(t)).deepEquals([[1, "one", false]]) + o(readState(t.live())).deepEquals([[1, "one", false]]) o(t.list()).deepEquals([[1, "one"]]) o(t.has(1)).equals(true) o(t.get(1)).equals("one") t.delete(1) o(calls).equals(2) - o(live(t)).deepEquals([]) + o(readState(t.live())).deepEquals([]) o(t.list()).deepEquals([]) o(t.has(1)).equals(false) o(t.get(1)).equals(undefined) }) o("tolerates double release before abort", () => { - var calls = 0 - var t = m.tracked(() => calls++) + let calls = 0 + const t = m.trackedList(() => calls++) t.set(1, "one") - var live1 = t.live()[0] + const live1 = t.live()[0] live1.release() live1.release() o(calls).equals(1) - o(live(t)).deepEquals([[1, "one", false]]) + o(readState(t.live())).deepEquals([[1, "one", false]]) o(t.list()).deepEquals([[1, "one"]]) o(t.has(1)).equals(true) o(t.get(1)).equals("one") t.delete(1) o(calls).equals(2) - o(live(t)).deepEquals([]) + o(readState(t.live())).deepEquals([]) o(t.list()).deepEquals([]) o(t.has(1)).equals(false) o(t.get(1)).equals(undefined) }) o("tolerates double release spanning delete", () => { - var calls = 0 - var t = m.tracked(() => calls++) + let calls = 0 + const t = m.trackedList(() => calls++) t.set(1, "one") - var live1 = t.live()[0] + const live1 = t.live()[0] live1.release() t.delete(1) live1.release() o(calls).equals(2) - o(live(t)).deepEquals([]) + o(readState(t.live())).deepEquals([]) o(t.list()).deepEquals([]) o(t.has(1)).equals(false) o(t.get(1)).equals(undefined) }) o("tracks double release after delete", () => { - var calls = 0 - var t = m.tracked(() => calls++) + let calls = 0 + const t = m.trackedList(() => calls++) t.set(1, "one") - var live1 = t.live()[0] + const live1 = t.live()[0] t.delete(1) o(calls).equals(2) - o(live(t)).deepEquals([[1, "one", true]]) + o(readState(t.live())).deepEquals([[1, "one", true]]) o(t.list()).deepEquals([]) o(t.has(1)).equals(false) o(t.get(1)).equals(undefined) live1.release() o(calls).equals(3) - o(live(t)).deepEquals([]) + o(readState(t.live())).deepEquals([]) o(t.list()).deepEquals([]) o(t.has(1)).equals(false) o(t.get(1)).equals(undefined) live1.release() o(calls).equals(3) - o(live(t)).deepEquals([]) + o(readState(t.live())).deepEquals([]) o(t.list()).deepEquals([]) o(t.has(1)).equals(false) o(t.get(1)).equals(undefined) }) }) + +o.spec("tracked", () => { + o("tracks values correctly", () => { + let calls = 0 + const trackHit = m.tracked(() => calls++) + + o(readState(trackHit("a"))).deepEquals([[0, "a", false]]) + o(readState(trackHit("a"))).deepEquals([[0, "a", false]]) + o(calls).equals(0) + + const list1 = trackHit("b") + o(readState(list1)).deepEquals([[0, "a", true], [1, "b", false]]) + o(calls).equals(0) + list1[0].release() + o(calls).equals(1) + + o(readState(trackHit("b"))).deepEquals([[1, "b", false]]) + o(calls).equals(1) + o(readState(trackHit("c"))).deepEquals([[1, "b", true], [2, "c", false]]) + o(calls).equals(1) + o(readState(trackHit("d"))).deepEquals([[1, "b", true], [2, "c", true], [3, "d", false]]) + o(calls).equals(1) + + const list2 = trackHit("d") + o(readState(list2)).deepEquals([[1, "b", true], [2, "c", true], [3, "d", false]]) + o(calls).equals(1) + list2[2].remove() + o(calls).equals(1) + + o(readState(trackHit("d"))).deepEquals([[1, "b", true], [2, "c", true], [3, "d", true]]) + o(calls).equals(1) + + list2[0].release() + o(calls).equals(2) + list2[1].release() + o(calls).equals(3) + list2[2].release() + o(calls).equals(4) + }) +})