@excom/kit-utils 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,214 @@
1
+ import { toArray } from "./common";
2
+
3
+ type QueueName = string;
4
+ type QueueStatus =
5
+ | "pending"
6
+ | "settling"
7
+ | "canceled"
8
+ | "resolved"
9
+ | "rejected";
10
+ type State = { status: QueueStatus; value?: any };
11
+ type Config = {
12
+ name?: QueueName;
13
+ initialValue?: any;
14
+ };
15
+ type QueueCallback = (state: State, config: Config) => any;
16
+ type OneOrManyCbs = QueueCallback | QueueCallback[];
17
+
18
+ export class Queue {
19
+ config: Config;
20
+ state: State;
21
+ callbacks: {
22
+ [key in QueueStatus]: Array<QueueCallback>;
23
+ } = {
24
+ pending: [],
25
+ settling: [],
26
+ canceled: [],
27
+ resolved: [],
28
+ rejected: [],
29
+ };
30
+ waitingStates = ["pending", "settling"];
31
+ finishedStates = ["resolved", "rejected", "canceled"];
32
+
33
+ constructor(opts?: Config) {
34
+ this.init(opts);
35
+ }
36
+ private init(opts: Config = {}) {
37
+ this.config = {
38
+ name: opts?.name,
39
+ initialValue: opts?.initialValue,
40
+ };
41
+ // drop stale state so a finished queue cannot leak
42
+ // @ts-expect-error - these are set in the subsequent call to _changeState
43
+ this.state = undefined;
44
+ return this._changeState({ status: "pending", value: opts?.initialValue });
45
+ }
46
+
47
+ onPending(cbs: OneOrManyCbs) {
48
+ return this._on("pending", cbs);
49
+ }
50
+ offPending(cbs: OneOrManyCbs) {
51
+ return this._off("pending", cbs);
52
+ }
53
+ oncePending(cbs: OneOrManyCbs) {
54
+ return this._once("pending", cbs);
55
+ }
56
+ reset(opts: Config = this.config) {
57
+ this.cancel();
58
+ this.init(opts);
59
+ return this;
60
+ }
61
+ onResolved(cbs: OneOrManyCbs) {
62
+ return this._on("resolved", cbs);
63
+ }
64
+ offResolved(cbs: OneOrManyCbs) {
65
+ return this._off("resolved", cbs);
66
+ }
67
+ onceResolved(cbs: OneOrManyCbs) {
68
+ return this._once("resolved", cbs);
69
+ }
70
+ resolve(value?: any) {
71
+ return this._changeState({ status: "resolved", value });
72
+ }
73
+ onRejected(cbs: OneOrManyCbs) {
74
+ return this._on("rejected", cbs);
75
+ }
76
+ offRejected(cbs: OneOrManyCbs) {
77
+ return this._off("rejected", cbs);
78
+ }
79
+ onceRejected(cbs: OneOrManyCbs) {
80
+ return this._once("rejected", cbs);
81
+ }
82
+ reject(value?: any) {
83
+ return this._changeState({ status: "rejected", value });
84
+ }
85
+ onCanceled(cbs: OneOrManyCbs) {
86
+ return this._on("canceled", cbs);
87
+ }
88
+ offCanceled(cbs: OneOrManyCbs) {
89
+ return this._off("canceled", cbs);
90
+ }
91
+ onceCanceled(cbs: OneOrManyCbs) {
92
+ return this._once("canceled", cbs);
93
+ }
94
+ cancel(value?: any) {
95
+ return this._changeState({ status: "canceled", value });
96
+ }
97
+ onSettling(cbs: OneOrManyCbs) {
98
+ return this._on("settling", cbs);
99
+ }
100
+ offSettling(cbs: OneOrManyCbs) {
101
+ return this._off("settling", cbs);
102
+ }
103
+ onceSettling(cbs: OneOrManyCbs) {
104
+ return this._once("settling", cbs);
105
+ }
106
+ settle(syncOrAsyncValue?: any) {
107
+ if (syncOrAsyncValue instanceof Promise) {
108
+ this._changeState({ status: "settling", value: syncOrAsyncValue });
109
+ syncOrAsyncValue
110
+ .then(
111
+ (v) =>
112
+ // still this promise, and the queue has not finished
113
+ this.state.value === syncOrAsyncValue &&
114
+ !this.isFinished(this?.state?.status) &&
115
+ this._changeState({ status: "resolved", value: v })
116
+ )
117
+ .catch(
118
+ (v) =>
119
+ // still this promise, and the queue has not finished
120
+ this.state.value === syncOrAsyncValue &&
121
+ !this.isFinished(this?.state?.status) &&
122
+ this._changeState({ status: "rejected", value: v })
123
+ );
124
+ return this;
125
+ } else if (!this.isFinished(this?.state?.status)) {
126
+ return this._changeState({ status: "resolved", value: syncOrAsyncValue });
127
+ } else {
128
+ return this;
129
+ }
130
+ }
131
+ private _on(status: QueueStatus, _cbs: OneOrManyCbs) {
132
+ const callbacks = toArray(_cbs);
133
+ callbacks.forEach((cb) => {
134
+ if (!this.callbacks[status].includes(cb)) {
135
+ this.callbacks[status].push(cb);
136
+ }
137
+ if (this.state.status === status) {
138
+ cb(this.state, this.config);
139
+ }
140
+ });
141
+ return this;
142
+ }
143
+ private _off(status: QueueStatus, _cbs: OneOrManyCbs) {
144
+ const callbacks = toArray(_cbs);
145
+ this.callbacks[status] = this.callbacks[status].filter(
146
+ (cb) => !callbacks.includes(cb)
147
+ );
148
+ return this;
149
+ }
150
+ private _once(status: QueueStatus, _cbs: OneOrManyCbs) {
151
+ const callbacks = toArray(_cbs);
152
+ const onceCb = () => {
153
+ callbacks.forEach((cb) => cb(this.state, this.config));
154
+ this._off(status, onceCb);
155
+ };
156
+ return this._on(status, onceCb);
157
+ }
158
+ private _changeState({
159
+ status,
160
+ value,
161
+ }: {
162
+ status: QueueStatus;
163
+ value?: any;
164
+ }) {
165
+ if (this.isFinished(this?.state?.status) && status === "settling") {
166
+ // finished queues do not re-enter settling
167
+ return this;
168
+ }
169
+ if (this.isWaiting(status) || this.isWaiting(this?.state?.status)) {
170
+ this.state = {
171
+ value,
172
+ status,
173
+ };
174
+ // snapshot: a callback may `.offResolved` (etc.) mid-loop
175
+ [...this.callbacks[status]].forEach((cb) => cb(this.state, this.config));
176
+ }
177
+ return this;
178
+ }
179
+
180
+ private isFinished(status: QueueStatus) {
181
+ return this.finishedStates.includes(status);
182
+ }
183
+
184
+ private isWaiting(status: QueueStatus) {
185
+ return this.waitingStates.includes(status);
186
+ }
187
+ }
188
+ /*
189
+ * Callbacks run synchronously: on enqueue if the queue is already
190
+ * resolved, or in the same turn it resolves. No microtask hop (unlike
191
+ * Promise). Also supports cancel.
192
+ */
193
+ export class QueueManager {
194
+ queues: Record<QueueName, Queue> = {};
195
+ createQueue(queueName: QueueName, initialConf: Config = {}) {
196
+ if (this.queues[queueName]) {
197
+ this.queues[queueName].cancel();
198
+ }
199
+ this.queues[queueName] = new Queue({ name: queueName, ...initialConf });
200
+ return this.queues[queueName];
201
+ }
202
+ deleteQueue(queueName: QueueName) {
203
+ if (this.queues[queueName]) {
204
+ this.queues[queueName].cancel();
205
+ delete this.queues[queueName];
206
+ return true;
207
+ } else {
208
+ return false;
209
+ }
210
+ }
211
+ getQueue(queueName: QueueName, initialConf?: Config) {
212
+ return this.queues[queueName] || this.createQueue(queueName, initialConf);
213
+ }
214
+ }
@@ -0,0 +1 @@
1
+ Caching has been disabled for this project's "apply-exports" command.
@@ -0,0 +1 @@
1
+ Invoking: cd "$RUSH_PROJECT_FOLDER" && node ../heft-rig/scripts/apply-exports.mjs
@@ -0,0 +1,381 @@
1
+ import { BatchManager, ExecHandlerFn } from "../../batch-manager";
2
+ import { LoopGuard, type LoopGuardTrip } from "../../loop-guard";
3
+ import {
4
+ afterEach,
5
+ beforeEach,
6
+ describe,
7
+ expect,
8
+ it,
9
+ vi,
10
+ } from "@excom/heft-rig/node_modules/vitest";
11
+
12
+ const execHandler: ExecHandlerFn = ([deps, fn], notifs) =>
13
+ fn(deps.map((dep) => notifs[dep]));
14
+
15
+ const doBatch = (batchManager: BatchManager, cb: () => void) => {
16
+ const doLock = !batchManager.isLocked;
17
+ if (doLock) batchManager.lock();
18
+ cb();
19
+ if (doLock) batchManager.unlock();
20
+ };
21
+
22
+ describe("BatchManager", () => {
23
+ it("should create a batchManager", () => {
24
+ const batchManager = new BatchManager({ handlers: [] });
25
+ expect(batchManager).toBeInstanceOf(BatchManager);
26
+ });
27
+
28
+ it("should add an handler", () => {
29
+ const handlers = [[["foo-0"], vi.fn()]] as const;
30
+ const batchManager = new BatchManager({ handlers, execHandler });
31
+ expect(batchManager.handlers).toEqual(handlers);
32
+ });
33
+
34
+ it("should call handlers if not locked", () => {
35
+ const handlers = [[["foo-0"], vi.fn()]] as const;
36
+ const batchManager = new BatchManager({ handlers, execHandler });
37
+ batchManager.notify("foo-0", "notified value");
38
+ expect(handlers[0][1]).toHaveBeenCalledWith(["notified value"]);
39
+ });
40
+
41
+ it("should flush handlers when unlocked", () => {
42
+ const handlers = [[["foo-0"], vi.fn()]] as const;
43
+ const batchManager = new BatchManager({ handlers, execHandler });
44
+ doBatch(batchManager, () => {
45
+ batchManager.notify("foo-0", "notified value");
46
+ expect(handlers[0][1]).not.toHaveBeenCalled();
47
+ });
48
+ expect(handlers[0][1]).toHaveBeenCalledWith(["notified value"]);
49
+ });
50
+
51
+ it("should call handlers sequentially when notify happens during flush", () => {
52
+ let batchManager;
53
+ const handlers = [
54
+ [["foo-0"], vi.fn()],
55
+ [
56
+ ["bar-1"],
57
+ vi.fn(() => {
58
+ batchManager.notify("foo-0", "notified value");
59
+ batchManager.notify("baz-2", "third value");
60
+ }),
61
+ ],
62
+ [["baz-2"], vi.fn()],
63
+ ] as const;
64
+ batchManager = new BatchManager({ handlers, execHandler });
65
+ doBatch(batchManager, () => {
66
+ batchManager.notify("foo-0", "notified value");
67
+ batchManager.notify("bar-1", "second value");
68
+ });
69
+ expect(handlers[0][1]).toHaveBeenCalledWith(["notified value"]);
70
+ expect(handlers[1][1]).toHaveBeenCalledWith(["second value"]);
71
+ expect(handlers[2][1]).toHaveBeenCalledWith(["third value"]);
72
+ expect(handlers[0][1]).toHaveBeenCalledTimes(2);
73
+ expect(handlers[1][1]).toHaveBeenCalledTimes(1);
74
+ expect(handlers[2][1]).toHaveBeenCalledTimes(1);
75
+ });
76
+
77
+ it("should call handlers with latest value", () => {
78
+ const handlers = [[["foo-0"], vi.fn()]] as const;
79
+ const batchManager = new BatchManager({ handlers, execHandler });
80
+ doBatch(batchManager, () => {
81
+ batchManager.notify("foo-0", "notified value");
82
+ batchManager.notify("foo-0", "another notified value");
83
+ });
84
+ expect(handlers[0][1]).toHaveBeenCalledWith(["another notified value"]);
85
+ expect(handlers[0][1]).toHaveBeenCalledTimes(1);
86
+ });
87
+
88
+ it("should call handlers with latest value, only once if not processed yet, handler called with object", () => {
89
+ let batchManager;
90
+ const handlers = [
91
+ [
92
+ ["apiUrl"],
93
+ vi.fn(() => {
94
+ doBatch(batchManager, () => {
95
+ batchManager.notify("payloadValue", "another value");
96
+ batchManager.notify("emit", "not value");
97
+ });
98
+ }),
99
+ ],
100
+ [
101
+ ["apiUrl"],
102
+ vi.fn(() => {
103
+ batchManager.notify("payloadValue", "another real value");
104
+ }),
105
+ ],
106
+ [
107
+ ["payloadValue"],
108
+ vi.fn((val) => {
109
+ batchManager.notify(
110
+ "emit",
111
+ val["payloadValue"] === "another real value"
112
+ ? "real value"
113
+ : "broken"
114
+ );
115
+ }),
116
+ ],
117
+ [["emit"], vi.fn()],
118
+ ] as const;
119
+ batchManager = new BatchManager({ handlers });
120
+ doBatch(batchManager, () => {
121
+ batchManager.notify("apiUrl", "a value");
122
+ });
123
+ expect(handlers[0][1]).toHaveBeenCalledWith(
124
+ expect.objectContaining({
125
+ apiUrl: "a value",
126
+ })
127
+ );
128
+ expect(handlers[1][1]).toHaveBeenCalledWith(
129
+ expect.objectContaining({
130
+ apiUrl: "a value",
131
+ })
132
+ );
133
+ expect(handlers[2][1]).toHaveBeenCalledWith(
134
+ expect.objectContaining({
135
+ payloadValue: "another real value",
136
+ })
137
+ );
138
+ expect(handlers[3][1]).toHaveBeenCalledWith(
139
+ expect.objectContaining({
140
+ emit: "real value",
141
+ })
142
+ );
143
+ expect(handlers[0][1]).toHaveBeenCalledTimes(1);
144
+ expect(handlers[1][1]).toHaveBeenCalledTimes(1);
145
+ expect(handlers[2][1]).toHaveBeenCalledTimes(1);
146
+ });
147
+
148
+ it("multiple dependencies: runs handlers correctly", () => {
149
+ const handlers = [
150
+ [["foo-0", "bar-1"], vi.fn()],
151
+ [["bar-1", "baz-2"], vi.fn()],
152
+ [["baz-2"], vi.fn()],
153
+ ] as const;
154
+ const batchManager = new BatchManager({ handlers, execHandler });
155
+ doBatch(batchManager, () => {
156
+ batchManager.notify("foo-0", "a value");
157
+ batchManager.notify("bar-1", "another value");
158
+ batchManager.notify("baz-2", "real value");
159
+ });
160
+ expect(handlers[0][1]).toHaveBeenCalledWith(["a value", "another value"]);
161
+ expect(handlers[1][1]).toHaveBeenCalledWith([
162
+ "another value",
163
+ "real value",
164
+ ]);
165
+ expect(handlers[2][1]).toHaveBeenCalledWith(["real value"]);
166
+ expect(handlers[0][1]).toHaveBeenCalledTimes(1);
167
+ expect(handlers[1][1]).toHaveBeenCalledTimes(1);
168
+ expect(handlers[2][1]).toHaveBeenCalledTimes(1);
169
+ });
170
+
171
+ it("multiple dependencies: should call handlers with latest value, only once if not processed yet", () => {
172
+ let batchManager;
173
+ const handlers = [
174
+ [
175
+ ["foo-0", "bar-1"],
176
+ vi.fn(() => {
177
+ doBatch(batchManager, () => {
178
+ batchManager.notify("foobar-3", "another value");
179
+ batchManager.notify("baz-2", "not value");
180
+ });
181
+ }),
182
+ ],
183
+ [
184
+ ["bar-1", "baz-2"],
185
+ vi.fn(() => {
186
+ batchManager.notify("foobar-3", "real value");
187
+ }),
188
+ ],
189
+ [["baz-2"], vi.fn()],
190
+ [["foobar-3"], vi.fn()],
191
+ ] as const;
192
+ batchManager = new BatchManager({ handlers, execHandler });
193
+ doBatch(batchManager, () => {
194
+ batchManager.notify("foo-0", "a value");
195
+ batchManager.notify("bar-1", "another value");
196
+ });
197
+ expect(handlers[0][1]).toHaveBeenCalledWith(["a value", "another value"]);
198
+ expect(handlers[1][1]).toHaveBeenCalledWith(["another value", "not value"]);
199
+ expect(handlers[2][1]).toHaveBeenCalledWith(["not value"]);
200
+ expect(handlers[3][1]).toHaveBeenCalledWith(["real value"]);
201
+ expect(handlers[0][1]).toHaveBeenCalledTimes(1);
202
+ expect(handlers[1][1]).toHaveBeenCalledTimes(1);
203
+ expect(handlers[2][1]).toHaveBeenCalledTimes(1);
204
+ expect(handlers[3][1]).toHaveBeenCalledTimes(1);
205
+ });
206
+
207
+ it("multiple dependencies: preserves order of dependency values", () => {
208
+ const handlers = [
209
+ [["foo-0", "bar-1"], vi.fn()],
210
+ [["bar-1", "baz-2"], vi.fn()],
211
+ [
212
+ ["baz-2", "foo-0"],
213
+ vi.fn(() => {
214
+ batchManager.notify("foobar-3", "some value");
215
+ }),
216
+ ],
217
+ [
218
+ ["foobar-3"],
219
+ vi.fn(() => {
220
+ batchManager.notify("bar-1", "new value");
221
+ }),
222
+ ],
223
+ [["bar-1", "baz-2", "foo-0"], vi.fn()],
224
+ ] as const;
225
+ const batchManager = new BatchManager({ handlers, execHandler });
226
+ doBatch(batchManager, () => {
227
+ batchManager.notify("foo-0", "a value");
228
+ batchManager.notify("baz-2", "real value");
229
+ });
230
+ expect(handlers[0][1]).toHaveBeenCalledWith(["a value", undefined]);
231
+ expect(handlers[1][1]).toHaveBeenCalledWith([undefined, "real value"]);
232
+ expect(handlers[2][1]).toHaveBeenCalledWith(["real value", "a value"]);
233
+ expect(handlers[3][1]).toHaveBeenCalledWith(["some value"]);
234
+ expect(handlers[4][1]).toHaveBeenCalledWith([
235
+ "new value",
236
+ "real value",
237
+ "a value",
238
+ ]);
239
+ expect(handlers[0][1]).toHaveBeenCalledTimes(2);
240
+ expect(handlers[1][1]).toHaveBeenCalledTimes(2);
241
+ expect(handlers[2][1]).toHaveBeenCalledTimes(1);
242
+ expect(handlers[3][1]).toHaveBeenCalledTimes(1);
243
+ });
244
+
245
+ it("a handler that locks mid-flush defers the rest until unlock", () => {
246
+ let batchManager: BatchManager;
247
+ const handlers = [
248
+ [
249
+ ["foo-0"],
250
+ vi.fn(() => {
251
+ batchManager.lock();
252
+ }),
253
+ ],
254
+ [["foo-0"], vi.fn()],
255
+ ] as const;
256
+ batchManager = new BatchManager({ handlers, execHandler });
257
+ batchManager.notify("foo-0", "value");
258
+ expect(handlers[0][1]).toHaveBeenCalledTimes(1);
259
+ expect(handlers[1][1]).not.toHaveBeenCalled();
260
+ // still queued, so the notifications are kept for the deferred handler
261
+ expect(batchManager.queuedHandlers).toHaveLength(1);
262
+ expect(batchManager.notifs).toEqual({ "foo-0": "value" });
263
+ batchManager.unlock();
264
+ expect(handlers[1][1]).toHaveBeenCalledWith(["value"]);
265
+ expect(batchManager.queuedHandlers).toHaveLength(0);
266
+ expect(batchManager.notifs).toEqual({});
267
+ });
268
+
269
+ it("uses a per-handler exec function and a custom clearNotifs", () => {
270
+ const perHandler = vi.fn();
271
+ const handlers = [
272
+ [["foo-0"], vi.fn(), perHandler],
273
+ [["foo-0"], vi.fn()],
274
+ ] as const;
275
+ const clearNotifs = vi.fn((notifs) => ({ ...notifs, cleared: true }));
276
+ const ctx = { name: "ctx" };
277
+ const batchManager = new BatchManager({
278
+ handlers,
279
+ execHandlerCtx: ctx,
280
+ clearNotifs,
281
+ });
282
+ batchManager.notify("foo-0", "v");
283
+ expect(perHandler).toHaveBeenCalledWith(handlers[0], { "foo-0": "v" });
284
+ expect(perHandler.mock.instances[0]).toBe(ctx);
285
+ expect(handlers[0][1]).not.toHaveBeenCalled();
286
+ // default exec handler calls fn with the notifs as `this` = ctx
287
+ expect(handlers[1][1]).toHaveBeenCalledWith({ "foo-0": "v" });
288
+ expect(handlers[1][1].mock.instances[0]).toBe(ctx);
289
+ expect(clearNotifs).toHaveBeenCalledTimes(1);
290
+ expect(batchManager.notifs).toEqual({ "foo-0": "v", cleared: true });
291
+ });
292
+ });
293
+
294
+ describe("BatchManager: loop guard", () => {
295
+ let trips: LoopGuardTrip[];
296
+ let off: () => void;
297
+
298
+ beforeEach(() => {
299
+ LoopGuard.reset();
300
+ LoopGuard.configure({ log: () => {} });
301
+ trips = [];
302
+ off = LoopGuard.onTrip((trip) => trips.push(trip));
303
+ });
304
+
305
+ afterEach(() => {
306
+ off();
307
+ LoopGuard.reset();
308
+ });
309
+
310
+ it("cuts a handler that keeps re-queuing itself inside one flush", () => {
311
+ LoopGuard.configure({ limit: 5 });
312
+ let batchManager: BatchManager;
313
+ const runs: number[] = [];
314
+ const handler = vi.fn((notifs: Record<string, number>) => {
315
+ runs.push(notifs.n);
316
+ /* Unlocked: an effect that writes the prop it reacts to would
317
+ * `notify` → nested flush → recurse forever. */
318
+ batchManager.notify("n", notifs.n + 1);
319
+ });
320
+ const ctx = { name: "ctx" };
321
+ batchManager = new BatchManager({
322
+ handlers: [[["n"], handler]],
323
+ execHandlerCtx: ctx,
324
+ });
325
+ expect(() => batchManager.notify("n", 0)).not.toThrow();
326
+ expect(handler).toHaveBeenCalledTimes(5);
327
+ expect(runs).toEqual([0, 1, 2, 3, 4]);
328
+ expect(trips).toHaveLength(1);
329
+ expect(trips[0]).toMatchObject({
330
+ kind: "batch",
331
+ target: ctx,
332
+ name: "n",
333
+ depth: 6,
334
+ limit: 5,
335
+ });
336
+ expect(batchManager.queuedHandlers).toEqual([]);
337
+ expect(batchManager.notifs).toEqual({});
338
+ });
339
+
340
+ it("cuts two handlers feeding each other through a locked batch", () => {
341
+ LoopGuard.configure({ limit: 4 });
342
+ let batchManager: BatchManager;
343
+ const a = vi.fn(() => doBatch(batchManager, () => batchManager.notify("b", 1)));
344
+ const b = vi.fn(() => doBatch(batchManager, () => batchManager.notify("a", 1)));
345
+ batchManager = new BatchManager({
346
+ handlers: [
347
+ [["a"], a],
348
+ [["b"], b],
349
+ ],
350
+ });
351
+ doBatch(batchManager, () => batchManager.notify("a", 0));
352
+ // one of the two crosses the limit first; the whole queue is dropped
353
+ expect(a.mock.calls.length + b.mock.calls.length).toBeLessThanOrEqual(9);
354
+ expect(trips).toHaveLength(1);
355
+ expect(trips[0].kind).toBe("batch");
356
+ expect(trips[0].target).toBe(batchManager);
357
+ expect(batchManager.queuedHandlers).toEqual([]);
358
+ });
359
+
360
+ it("counts runs per flush, so repeated separate notifications never trip", () => {
361
+ LoopGuard.configure({ limit: 3 });
362
+ const handler = vi.fn();
363
+ const batchManager = new BatchManager({ handlers: [[["n"], handler]] });
364
+ for (let i = 0; i < 20; i++) batchManager.notify("n", i);
365
+ expect(handler).toHaveBeenCalledTimes(20);
366
+ expect(trips).toEqual([]);
367
+ });
368
+
369
+ it("does not trip a handler legitimately re-run once per dependency", () => {
370
+ LoopGuard.configure({ limit: 3 });
371
+ let batchManager: BatchManager;
372
+ const handler = vi.fn((notifs: Record<string, unknown>) => {
373
+ // reacting to `a` writes `b` once; reacting to `b` writes nothing
374
+ if ("a" in notifs && !("b" in notifs)) batchManager.notify("b", 1);
375
+ });
376
+ batchManager = new BatchManager({ handlers: [[["a", "b"], handler]] });
377
+ doBatch(batchManager, () => batchManager.notify("a", 1));
378
+ expect(handler).toHaveBeenCalledTimes(2);
379
+ expect(trips).toEqual([]);
380
+ });
381
+ });