Multi-Hoster-Upload/lib/coalesced-set.js
Administrator 166a49dd0c test(coalesce): extract done-removal coalescer + 11 unit tests
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.
2026-04-28 11:59:32 +02:00

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);