Skip to content

Commit

Permalink
Add batchedEffect() (#69)
Browse files Browse the repository at this point in the history
* Add batchedEffect()

* Update readme

* Handle exceptions and nested batches
  • Loading branch information
justinfagnani authored May 21, 2024
1 parent d33de6b commit 752e8f9
Show file tree
Hide file tree
Showing 3 changed files with 349 additions and 0 deletions.
26 changes: 26 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -517,6 +517,32 @@ a.set(1);
// after a microtask, logs: 2, 1
```
#### Batched Effects
Sometimes it may be useful to run an effect _synchronously_ after updating signals. The proposed Signals API intentionally makes this difficult, because signals are not allowed to be read or written to within a watcher callback, but it is possible as long as signals are accessed after the watcher notification callbacks have completed.
`batchedEffect()` and `batch()` allow you to create effects that run synchronously at the end of a `batch()` callback, if that callback updates any signals the effects depend on.
```js
const a = new Signal.State(0);
const b = new Signal.State(0);

batchedEffect(() => {
console.log("a + b =", a.get() + b.get());
});

// Logs: a + b = 0

batch(() => {
a.set(1);
b.set(1);
});

// Logs: a + b = 2
```
Synchronous batched effects can be useful when abstracting over signals to use them as a backing storage mechanism. In some cases you may want the effect of a signal update to be synchronously observable, but also to allow batching when possible for the usual performacne and coherence reasons.
## Contributing
See: [./CONTRIBUTING.md](CONTRIBUTING.md)
Expand Down
90 changes: 90 additions & 0 deletions src/subtle/batched-effect.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
import { Signal } from "signal-polyfill";

const notifiedEffects = new Set<{
computed: Signal.Computed<void>;
watcher: Signal.subtle.Watcher;
}>();

let batchDepth = 0;

/**
* Runs the given function inside of a "batch" and calls any batched effects
* (those created with `batchEffect()`) that depend on updated signals
* synchronously after the function completes.
*
* Batches can be nested, and effects will only be called once at the end of the
* outermost batch.
*
* Batching does not change how the signal graph updates, or change any other
* watcher or effect system. Accessing signals that are updated within a batch
* will return their updates value. Other computations, watcher, and effects
* created outside of a batch that depend on updated signals will be run as
* usual.
*/
export const batch = (fn: () => void) => {
batchDepth++;
try {
// Run the function to notifiy watchers
fn();
} finally {
batchDepth--;

if (batchDepth !== 0) {
return;
}

// Copy then clear the notified effects
const effects = [...notifiedEffects];
notifiedEffects.clear();

// Run all the batched effect callbacks and re-enable the watchers
let exceptions!: any[];

for (const { computed, watcher } of effects) {
watcher.watch(computed);
try {
computed.get();
} catch (e) {
(exceptions ??= []).push(e);
}
}

if (exceptions !== undefined) {
if (exceptions.length === 1) {
throw exceptions![0];
} else {
throw new AggregateError(
exceptions!,
"Multiple exceptions thrown in batched effects",
);
}
}
}
};

/**
* Creates an effect that runs synchronously at the end of a `batch()` call if
* any of the signals it depends on have been updated.
*
* The effect also runs asynchronously, on the microtask queue, if any of the
* signals it depends on have been updated outside of a `batch()` call.
*/
export const batchedEffect = (effectFn: () => void) => {
const computed = new Signal.Computed(effectFn);
const watcher = new Signal.subtle.Watcher(async () => {
// Synchonously add the effect to the notified effects
notifiedEffects.add(entry);

// Check if our effect is still in the notified effects
await 0;

if (notifiedEffects.has(entry)) {
// If it is, then we call it async and remove it
notifiedEffects.delete(entry);
computed.get();
}
});
const entry = { computed, watcher };
watcher.watch(computed);
computed.get();
};
233 changes: 233 additions & 0 deletions tests/subtle/batched-effect.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,233 @@
import { describe, test, assert } from "vitest";
import { Signal } from "signal-polyfill";
import { batchedEffect, batch } from "../../src/subtle/batched-effect.ts";

describe("batchedEffect()", () => {
test("calls the effect function synchronously at the end of a batch", async () => {
const a = new Signal.State(0);
const b = new Signal.State(0);

let callCount = 0;

batchedEffect(() => {
a.get();
b.get();
callCount++;
});

// Effect callbacks are called immediately
assert.strictEqual(callCount, 1);

batch(() => {
a.set(1);
b.set(1);
});

// Effect callbacks are batched and called sync
assert.strictEqual(callCount, 2);

batch(() => {
a.set(2);
});
assert.strictEqual(callCount, 3);

await 0;

// No lingering effect calls
assert.strictEqual(callCount, 3);
});

test("nested batches", async () => {
const a = new Signal.State(0);
const b = new Signal.State(0);
const c = new Signal.State(0);

let callCount = 0;

batchedEffect(() => {
a.get();
b.get();
c.get();
callCount++;
});

batch(() => {
a.set(1);
batch(() => {
b.set(1);
});
c.set(1);
});

// Effect callbacks are batched and called sync
assert.strictEqual(callCount, 2);
});

test("batch nested in an effect", async () => {
const a = new Signal.State(0);
const b = new Signal.State(0);

let log: Array<string> = [];

batchedEffect(() => {
log.push("A");
a.get();
batch(() => {
b.set(a.get());
});
});

assert.deepEqual(log, ["A"]);

batchedEffect(() => {
log.push("B");
b.get();
});

assert.deepEqual(log, ["A", "B"]);
log.length = 0;

batch(() => {
a.set(1);
});

// Both effects should run
assert.deepEqual(log, ["A", "B"]);
});

test("calls the effect function asynchronously outside a batch", async () => {
const a = new Signal.State(0);
const b = new Signal.State(0);

let callCount = 0;

batchedEffect(() => {
a.get();
b.get();
callCount++;
});

a.set(1);
b.set(1);

// Non-batched changes are not called sync
assert.strictEqual(callCount, 1);

await 0;

// Non-batched changes are called async
assert.strictEqual(callCount, 2);
});

test("handles mixed batched and unbatched changes", async () => {
const a = new Signal.State(0);
const b = new Signal.State(0);

let callCount = 0;

batchedEffect(() => {
a.get();
b.get();
callCount++;
});

a.set(1);

batch(() => {
b.set(1);
});

// Effect callbacks are batched and called sync
assert.strictEqual(callCount, 2);

batch(() => {
a.set(2);
});
assert.strictEqual(callCount, 3);

await 0;

// No lingering effect calls
assert.strictEqual(callCount, 3);
});

test("exceptions in batches", () => {
const a = new Signal.State(0);

let callCount = 0;
let errorCount = 0;

batchedEffect(() => {
a.get();
callCount++;
});

try {
batch(() => {
a.set(1);
throw new Error("oops");
});
} catch (e) {
// Pass
errorCount++;
}

// batch() propagates exceptions
assert.strictEqual(errorCount, 1);

// Effect callbacks still called if their dependencies were updated
// before the exception
assert.strictEqual(callCount, 2);

// New batches still work

batch(() => {
a.set(2);
});

assert.strictEqual(callCount, 3);
});

test("exceptions in effects", async () => {
const a = new Signal.State(0);

let callCount1 = 0;
let callCount2 = 0;
let errorCount = 0;

try {
batchedEffect(() => {
a.get();
callCount1++;
throw new Error("oops");
});
} catch (e) {
// Pass
errorCount++;
}

// Effects are called immediately, so the exception is thrown immediately
assert.strictEqual(errorCount, 1);

// A second effect, to test that it still runs
batchedEffect(() => {
a.get();
callCount2++;
});

try {
batch(() => {
a.set(1);
});
} catch (e) {
// Pass
errorCount++;
}

// batch() propagates exceptions
assert.strictEqual(errorCount, 2);
assert.strictEqual(callCount1, 2);
// Later effects are still called
assert.strictEqual(callCount2, 2);
});
});

0 comments on commit 752e8f9

Please sign in to comment.