The microtask-coalesce path from 3.3.1 (queueMicrotask + Set so 500
finishing jobs become one queueJobs.filter pass instead of 500) lived
inline in renderer/app.js. Pulled out into lib/coalesced-set.js with
an injectable scheduler so a Node test can drive timing without
async waits.
API: makeCoalescedSet({ apply, scheduler? }) returns
add(id) — queue an id for the next batch
drainSync() — flush synchronously (used by beforeunload)
pendingSize() — diagnostics
isScheduled() — diagnostics
Renderer rewires the previous _pendingDoneRemovalIds + manual
queueMicrotask plumbing to the new helper. Optional-chained: if the
script fails to load, a slower per-event filter runs as fallback.
Coverage:
- multiple adds same tick → 1 apply, all ids deduped
- duplicate ids deduped
- batches between flushes stay independent
- add after flush re-schedules
- drainSync flushes synchronously, queued microtask becomes a no-op
- empty drainSync is a no-op
- throwing apply doesn't lock out subsequent batches
- default scheduler (queueMicrotask) runs eventually
- 5000-id burst still coalesces to 1 apply
137/137 green.
145 lines
4.5 KiB
JavaScript
145 lines
4.5 KiB
JavaScript
const { test } = require('node:test');
|
|
const assert = require('node:assert/strict');
|
|
|
|
const { makeCoalescedSet } = require('../lib/coalesced-set');
|
|
|
|
// Synchronous scheduler stand-in: collects callbacks instead of running
|
|
// them, so tests can drive the timing explicitly.
|
|
function makeManualScheduler() {
|
|
const queue = [];
|
|
const fn = (cb) => queue.push(cb);
|
|
fn.flush = () => {
|
|
while (queue.length) {
|
|
const cb = queue.shift();
|
|
cb();
|
|
}
|
|
};
|
|
fn.queueLength = () => queue.length;
|
|
return fn;
|
|
}
|
|
|
|
test('throws if apply callback missing', () => {
|
|
assert.throws(() => makeCoalescedSet());
|
|
assert.throws(() => makeCoalescedSet({}));
|
|
assert.throws(() => makeCoalescedSet({ apply: 'not-a-fn' }));
|
|
});
|
|
|
|
test('multiple adds in one tick coalesce into one apply call', () => {
|
|
const sched = makeManualScheduler();
|
|
const calls = [];
|
|
const cs = makeCoalescedSet({
|
|
apply: (drop) => calls.push([...drop].sort()),
|
|
scheduler: sched
|
|
});
|
|
|
|
cs.add('a'); cs.add('b'); cs.add('c');
|
|
assert.equal(sched.queueLength(), 1, 'only one microtask scheduled');
|
|
assert.equal(cs.pendingSize(), 3);
|
|
|
|
sched.flush();
|
|
assert.deepEqual(calls, [['a', 'b', 'c']]);
|
|
assert.equal(cs.pendingSize(), 0);
|
|
});
|
|
|
|
test('duplicate adds are deduplicated', () => {
|
|
const sched = makeManualScheduler();
|
|
const calls = [];
|
|
const cs = makeCoalescedSet({ apply: (d) => calls.push([...d]), scheduler: sched });
|
|
cs.add('a'); cs.add('a'); cs.add('a');
|
|
sched.flush();
|
|
assert.deepEqual(calls, [['a']]);
|
|
});
|
|
|
|
test('two batches in series stay independent', () => {
|
|
const sched = makeManualScheduler();
|
|
const calls = [];
|
|
const cs = makeCoalescedSet({ apply: (d) => calls.push([...d]), scheduler: sched });
|
|
|
|
cs.add('x'); cs.add('y');
|
|
sched.flush();
|
|
cs.add('z');
|
|
sched.flush();
|
|
|
|
assert.deepEqual(calls, [['x', 'y'], ['z']]);
|
|
});
|
|
|
|
test('add after flush re-schedules a new microtask', () => {
|
|
const sched = makeManualScheduler();
|
|
const cs = makeCoalescedSet({ apply: () => {}, scheduler: sched });
|
|
cs.add('a');
|
|
assert.equal(sched.queueLength(), 1);
|
|
sched.flush();
|
|
assert.equal(sched.queueLength(), 0);
|
|
assert.equal(cs.isScheduled(), false);
|
|
cs.add('b');
|
|
assert.equal(sched.queueLength(), 1, 'new add → new microtask');
|
|
});
|
|
|
|
test('drainSync flushes synchronously without waiting for scheduler', () => {
|
|
const sched = makeManualScheduler();
|
|
const calls = [];
|
|
const cs = makeCoalescedSet({ apply: (d) => calls.push([...d]), scheduler: sched });
|
|
cs.add('p'); cs.add('q');
|
|
cs.drainSync();
|
|
assert.deepEqual(calls, [['p', 'q']]);
|
|
assert.equal(cs.pendingSize(), 0);
|
|
|
|
// Pending microtask was for the same ids — when it runs, pending is empty
|
|
// → apply NOT called twice.
|
|
sched.flush();
|
|
assert.equal(calls.length, 1, 'queued microtask is a no-op after drainSync');
|
|
});
|
|
|
|
test('drainSync on empty set is a no-op', () => {
|
|
let called = 0;
|
|
const cs = makeCoalescedSet({ apply: () => called++ });
|
|
cs.drainSync();
|
|
assert.equal(called, 0);
|
|
});
|
|
|
|
test('throwing apply does not lock out subsequent batches', () => {
|
|
const sched = makeManualScheduler();
|
|
let attempt = 0;
|
|
const cs = makeCoalescedSet({
|
|
apply: () => { attempt++; if (attempt === 1) throw new Error('boom'); },
|
|
scheduler: sched
|
|
});
|
|
cs.add('a');
|
|
// First flush throws inside apply but is swallowed; coalescer must still work.
|
|
sched.flush();
|
|
cs.add('b');
|
|
sched.flush();
|
|
assert.equal(attempt, 2, 'second batch still ran despite first throwing');
|
|
});
|
|
|
|
test('default scheduler is queueMicrotask (or Promise fallback) — runs eventually', async () => {
|
|
const calls = [];
|
|
const cs = makeCoalescedSet({ apply: (d) => calls.push([...d]) });
|
|
cs.add('z');
|
|
// Wait one microtask
|
|
await Promise.resolve();
|
|
assert.deepEqual(calls, [['z']]);
|
|
});
|
|
|
|
test('no-op tick: scheduler fires while pending is empty (e.g. drained)', () => {
|
|
const sched = makeManualScheduler();
|
|
let called = 0;
|
|
const cs = makeCoalescedSet({ apply: () => called++, scheduler: sched });
|
|
cs.add('a');
|
|
cs.drainSync();
|
|
assert.equal(called, 1);
|
|
// Pending microtask still in queue → flush; pending is empty → apply NOT called again.
|
|
sched.flush();
|
|
assert.equal(called, 1);
|
|
});
|
|
|
|
test('large burst of 5000 adds coalesces to one apply call', () => {
|
|
const sched = makeManualScheduler();
|
|
const calls = [];
|
|
const cs = makeCoalescedSet({ apply: (d) => calls.push(d.size), scheduler: sched });
|
|
for (let i = 0; i < 5000; i++) cs.add('id-' + i);
|
|
assert.equal(sched.queueLength(), 1);
|
|
sched.flush();
|
|
assert.deepEqual(calls, [5000]);
|
|
});
|