From c2b5d7a7e1a4058e8bda22fc467ad09a6ad7a17e Mon Sep 17 00:00:00 2001 From: rameel Date: Mon, 26 Oct 2026 00:10:31 +0500 Subject: [PATCH] utilities: keep a single pending watch callback watch() defers the callback with setTimeout on every effect run, but remembered only the last timer. When the effect ran more than once before the timers fired, e.g. after a change made in the same tick as an immediate watch was created, every timer called the callback, and all of them read the same latest value: the first reported the change, the rest reported it again with the old value equal to the new one. The disposer cancelled only the last timer, so the earlier ones still fired after disposal. Cancel the pending timer before setting a new one. The callback now runs once with the latest value, and disposal cancels it. --- src/utilities/watch.js | 4 ++ tests/utilities/watch.test.js | 70 +++++++++++++++++++++++++++++++++++ vitest.config.js | 3 ++ 3 files changed, 77 insertions(+) create mode 100644 tests/utilities/watch.test.js diff --git a/src/utilities/watch.js b/src/utilities/watch.js index 1ad48ff..6618cc9 100644 --- a/src/utilities/watch.js +++ b/src/utilities/watch.js @@ -22,6 +22,10 @@ export function watch(get_value, callback, options = null) { } if (initialized || (options?.immediate ?? true)) { + // Keep a single pending callback: it reports the latest value anyway, + // and the disposer cancels only the timer it knows about. + clearTimeout(timer_id); + // Prevent the watcher from detecting its own dependencies. timer_id = setTimeout(() => { callback(new_value, old_value); diff --git a/tests/utilities/watch.test.js b/tests/utilities/watch.test.js new file mode 100644 index 0000000..3e29a58 --- /dev/null +++ b/tests/utilities/watch.test.js @@ -0,0 +1,70 @@ +import { describe, expect, test } from "vitest"; +import { watch } from "@/utilities/watch"; +import Alpine from "alpinejs"; + +// watch takes Alpine from the global scope +globalThis.Alpine = Alpine; + +// Lets Alpine run the scheduled effects, but not the timers the watcher sets +const flush_effects = () => new Promise(queueMicrotask); + +// Lets the timers set so far fire; the effects must be flushed first, +// otherwise the timers they set would fire after this one +const flush_timers = () => new Promise(resolve => setTimeout(resolve)); + +describe("watch", () => { + test("reports changes made before the callback once", async () => { + const data = Alpine.reactive({ value: 0 }); + const tracing = []; + + watch( + () => data.value, + (value, old_value) => tracing.push([value, old_value]), + { immediate: false }); + + data.value = 1; + await flush_effects(); + + data.value = 2; + await flush_effects(); + await flush_timers(); + + expect(tracing).toEqual([[2, 0]]); + }); + + test("immediate: reports a change made before the first callback once", async () => { + const data = Alpine.reactive({ value: 0 }); + const tracing = []; + + watch( + () => data.value, + (value, old_value) => tracing.push([value, old_value])); + + data.value = 1; + await flush_effects(); + await flush_timers(); + + expect(tracing).toEqual([[1, 0]]); + }); + + test("no callback after disposal", async () => { + const data = Alpine.reactive({ value: 0 }); + const tracing = []; + + const dispose = watch( + () => data.value, + (value, old_value) => tracing.push([value, old_value]), + { immediate: false }); + + data.value = 1; + await flush_effects(); + + data.value = 2; + await flush_effects(); + + dispose(); + await flush_timers(); + + expect(tracing).toEqual([]); + }); +}); diff --git a/vitest.config.js b/vitest.config.js index 6a9c657..ead4bc1 100644 --- a/vitest.config.js +++ b/vitest.config.js @@ -8,6 +8,9 @@ import { } from "url"; export default defineConfig({ + define: { + __DEV__: true + }, test: { globals: true, environment: "jsdom",