diff --git a/packages/reactivity-core/CHANGELOG.md b/packages/reactivity-core/CHANGELOG.md index 148a74d..c9e7222 100644 --- a/packages/reactivity-core/CHANGELOG.md +++ b/packages/reactivity-core/CHANGELOG.md @@ -1,5 +1,10 @@ # @conterra/reactivity-core +## v0.4.3 (Unreleased) + +- Introduce `subtleWatchDirty`, a function that allows one to watch for signal changes without triggering the re-evaluation of the signal. +- Deprecate `syncEffectOnce` (use `subtleWatchDirty` instead). + ## v0.4.2 - Fix `reactiveArray.splice()`: new elements could not be inserted. diff --git a/packages/reactivity-core/index.ts b/packages/reactivity-core/index.ts index 61f2f1b..0293046 100644 --- a/packages/reactivity-core/index.ts +++ b/packages/reactivity-core/index.ts @@ -46,5 +46,6 @@ export { } from "./ReactiveImpl"; export { syncEffect, syncEffectOnce, syncWatch, syncWatchValue } from "./sync"; export { effect, watch, watchValue, nextTick } from "./async"; +export { subtleWatchDirty } from "./watchDirty"; export * from "./collections"; export * from "./struct"; diff --git a/packages/reactivity-core/sync.ts b/packages/reactivity-core/sync.ts index e26cfd6..e7a0ce9 100644 --- a/packages/reactivity-core/sync.ts +++ b/packages/reactivity-core/sync.ts @@ -64,6 +64,8 @@ export function syncEffect(callback: EffectCallback): CleanupHandle { * * Note that `onInvalidate` will never be invoked more than once. * + * @deprecated This function is no longer needed and will be removed in a future release. + * * @group Watching */ export function syncEffectOnce(callback: EffectCallback, onInvalidate: () => void): CleanupHandle { diff --git a/packages/reactivity-core/watchDirty.test.ts b/packages/reactivity-core/watchDirty.test.ts new file mode 100644 index 0000000..66296b4 --- /dev/null +++ b/packages/reactivity-core/watchDirty.test.ts @@ -0,0 +1,79 @@ +import { expect, it, vi } from "vitest"; +import { computed, reactive } from "./ReactiveImpl"; +import { subtleWatchDirty } from "./watchDirty"; + +it("notifies the callback when the signal changed", () => { + const v = reactive(0); + const callback = vi.fn(); + subtleWatchDirty(v, callback); + + expect(callback).toHaveBeenCalledTimes(0); + + v.value = 1; + expect(callback).toHaveBeenCalledTimes(1); + + v.value = 2; + expect(callback).toHaveBeenCalledTimes(2); +}); + +it("stops notifications when the watch is destroyed", () => { + const v = reactive(0); + const callback = vi.fn(); + const { destroy } = subtleWatchDirty(v, callback); + + v.value = 1; + expect(callback).toHaveBeenCalledTimes(1); + + destroy(); + v.value = 2; + expect(callback).toHaveBeenCalledTimes(1); +}); + +it("does not trigger computed signals", () => { + const count = reactive(0); + const compute = vi.fn(() => count.value + 1); + const signal = computed(compute); + expect(compute).toHaveBeenCalledTimes(0); + + const callback = vi.fn(); + subtleWatchDirty(signal, callback); + expect(callback).toHaveBeenCalledTimes(0); + expect(compute).toHaveBeenCalledTimes(1); // watchDirty accesses the value once during setup + + count.value = 1; + expect(callback).toHaveBeenCalledTimes(1); + expect(compute).toHaveBeenCalledTimes(1); // not called again + + signal.value; + expect(compute).toHaveBeenCalledTimes(2); +}); + +it("tracks the signal even if the initial access throws", () => { + const count = reactive(0); + const compute = vi.fn(() => { + if (count.value === 0) { + throw new Error("oops!"); + } + return count.value + 1; + }); + const signal = computed(compute); + + const callback = vi.fn(); + subtleWatchDirty(signal, callback); + expect(callback).toHaveBeenCalledTimes(0); + + // Was called during setup and the error was ignored. + expect(compute).toHaveBeenCalledTimes(1); + expect(compute.mock.results).toMatchInlineSnapshot(` + [ + { + "type": "throw", + "value": [Error: oops!], + }, + ] + `); + + // Update triggers notification + count.value = 1; + expect(callback).toHaveBeenCalledTimes(1); +}); diff --git a/packages/reactivity-core/watchDirty.ts b/packages/reactivity-core/watchDirty.ts new file mode 100644 index 0000000..a7e7cb4 --- /dev/null +++ b/packages/reactivity-core/watchDirty.ts @@ -0,0 +1,57 @@ +import { effect as rawEffect } from "@preact/signals-core"; +import { CleanupHandle, ReadonlyReactive } from "./types"; + +/** + * **Experimental**. + * Notifies the given `callback` whenever the `signal` might have changed, + * without recomputing the signal's current value. + * + * This is a difficult to use, low level API that can be used to build higher level abstractions. + * + * Things to keep in mind when using this function: + * The `callback` should be cheap to invoke (as a signal might change often) and it **must not** throw an exception. + * It should also not make use of any reactive values. + * + * @param signal The signal being watched. + * @param callback The callback to be called whenever the signal might have changed. + * + * @group Watching + */ +export function subtleWatchDirty(signal: ReadonlyReactive, callback: () => void): CleanupHandle { + // Uses the effect's internals to track signal invalidations. + // The effect body is only called once! + // See https://github.com/preactjs/signals/issues/593#issuecomment-2349672856 + let start!: () => () => void; + const destroy = rawEffect(function (this: RawEffectInternals) { + this[_NOTIFY] = callback.bind(undefined); // hide 'this' + start = this[_START].bind(this); + }); + + const end = start(); + try { + signal.value; // Tracked + } catch (ignored) { + // We only care about the dependency being set up correctly. + void ignored; + } finally { + end(); + } + + return { + destroy + }; +} + +// Mangled member names. See https://github.com/preactjs/signals/blob/main/mangle.json. +const _NOTIFY = "N"; +const _START = "S"; + +interface RawEffectInternals { + // Notifies the effect that a dependency has changed. + // This usually schedules the effect to run again (when not overridden). + [_NOTIFY](): void; + + // Starts the effect and returns a function to stop it again. + // Signal accesses are tracked while the effect is running. + [_START](): () => void; +}