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.
74 lines
2.5 KiB
JavaScript
74 lines
2.5 KiB
JavaScript
// Microtask-coalesced set. Adds are O(1); the apply callback runs once per
|
|
// scheduler tick with every id collected since the last flush.
|
|
//
|
|
// Used by the renderer to merge a burst of done-jobs (e.g. 500 jobs all
|
|
// finishing within milliseconds) into a single queueJobs.filter() pass —
|
|
// without this each event was its own O(N) sweep, so 500 finishes were
|
|
// O(N²) and visibly froze the UI on completion.
|
|
//
|
|
// Loaded both as a CommonJS module (Node tests) and as a browser global
|
|
// (renderer/app.js via index.html script tag).
|
|
|
|
(function (root) {
|
|
'use strict';
|
|
|
|
/**
|
|
* Build a coalesced set.
|
|
* @param {{ apply: (Set) => void, scheduler?: (cb: () => void) => void }} opts
|
|
* apply: called once per scheduler tick with the accumulated ids.
|
|
* scheduler: defaults to queueMicrotask. Tests can pass a synchronous
|
|
* stand-in to avoid async waits.
|
|
*/
|
|
function makeCoalescedSet(opts) {
|
|
if (!opts || typeof opts.apply !== 'function') {
|
|
throw new TypeError('makeCoalescedSet: { apply: fn } required');
|
|
}
|
|
const apply = opts.apply;
|
|
const scheduler = typeof opts.scheduler === 'function'
|
|
? opts.scheduler
|
|
: (typeof queueMicrotask === 'function' ? queueMicrotask : (cb) => Promise.resolve().then(cb));
|
|
let pending = new Set();
|
|
let scheduled = false;
|
|
|
|
function flush() {
|
|
scheduled = false;
|
|
if (pending.size === 0) return;
|
|
const drop = pending;
|
|
pending = new Set();
|
|
try { apply(drop); } catch (e) {
|
|
// Don't let a failing apply lock out the next batch — surface it
|
|
// but keep the coalescer usable.
|
|
if (typeof console !== 'undefined' && console.error) console.error(e);
|
|
}
|
|
}
|
|
|
|
return {
|
|
add(id) {
|
|
pending.add(id);
|
|
if (!scheduled) {
|
|
scheduled = true;
|
|
scheduler(flush);
|
|
}
|
|
},
|
|
/**
|
|
* Synchronously consume any pending ids. Used by beforeunload paths
|
|
* where we can't wait for the next microtask before persisting.
|
|
*/
|
|
drainSync() {
|
|
if (pending.size === 0) return;
|
|
const drop = pending;
|
|
pending = new Set();
|
|
scheduled = false;
|
|
apply(drop);
|
|
},
|
|
/** Introspection for tests + diagnostics. */
|
|
pendingSize() { return pending.size; },
|
|
isScheduled() { return scheduled; }
|
|
};
|
|
}
|
|
|
|
const api = { makeCoalescedSet };
|
|
if (typeof module !== 'undefined' && module.exports) module.exports = api;
|
|
else if (root) root.CoalescedSet = api;
|
|
})(typeof window !== 'undefined' ? window : this);
|