-
Notifications
You must be signed in to change notification settings - Fork 12
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
* Add batchedEffect() * Update readme * Handle exceptions and nested batches
- Loading branch information
1 parent
d33de6b
commit 752e8f9
Showing
3 changed files
with
349 additions
and
0 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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(); | ||
}; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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); | ||
}); | ||
}); |