Skip to content

Commit

Permalink
Introduce subtleWatchDirty()
Browse files Browse the repository at this point in the history
  • Loading branch information
mbeckem committed Oct 4, 2024
1 parent a617662 commit 2f65787
Show file tree
Hide file tree
Showing 5 changed files with 144 additions and 0 deletions.
5 changes: 5 additions & 0 deletions packages/reactivity-core/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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.
Expand Down
1 change: 1 addition & 0 deletions packages/reactivity-core/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
2 changes: 2 additions & 0 deletions packages/reactivity-core/sync.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
79 changes: 79 additions & 0 deletions packages/reactivity-core/watchDirty.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
57 changes: 57 additions & 0 deletions packages/reactivity-core/watchDirty.ts
Original file line number Diff line number Diff line change
@@ -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<T>(signal: ReadonlyReactive<T>, 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;
}

0 comments on commit 2f65787

Please sign in to comment.