diff --git a/lib/coalesced-set.js b/lib/coalesced-set.js
new file mode 100644
index 0000000..4136d3f
--- /dev/null
+++ b/lib/coalesced-set.js
@@ -0,0 +1,73 @@
+// 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);
diff --git a/renderer/app.js b/renderer/app.js
index d969f56..4f0ef31 100644
--- a/renderer/app.js
+++ b/renderer/app.js
@@ -48,9 +48,16 @@ const _sessionDoneJobs = new Set(); // Job IDs already counted for uploadedBytes
const _completedUploadKeys = new Set(); // 'filepath|hoster' keys for done uploads (survives removeFromQueueOnDone)
const _deletedJobIds = new Set(); // IDs of jobs explicitly deleted by user (prevents re-creation from stale progress callbacks)
// Coalesce removeFromQueueOnDone removals into one filter pass per microtask
-// to avoid O(N²) behaviour when a burst of jobs finish at once.
-let _pendingDoneRemovalIds = new Set();
-let _doneRemovalScheduled = false;
+// to avoid O(N²) behaviour when a burst of jobs finish at once. Logic now
+// lives in lib/coalesced-set.js so it can be unit-tested with a manual
+// scheduler. Optional-chained so the renderer still works if the script
+// failed to load — falls back to immediate per-event filter (legacy slow
+// path), better than crashing.
+const _doneRemovalCoalescer = window.CoalescedSet
+ ? window.CoalescedSet.makeCoalescedSet({
+ apply: (drop) => { queueJobs = queueJobs.filter(j => !drop.has(j.id)); }
+ })
+ : null;
const queueSortState = { key: 'filename', direction: 'asc' };
// History state
@@ -1950,16 +1957,11 @@ function handleProgress(data) {
if (job.status === 'done' && config.globalSettings && config.globalSettings.removeFromQueueOnDone) {
removeJobFromIndex(job);
selectedJobIds.delete(job.id);
- _pendingDoneRemovalIds.add(job.id);
- if (!_doneRemovalScheduled) {
- _doneRemovalScheduled = true;
- queueMicrotask(() => {
- _doneRemovalScheduled = false;
- if (_pendingDoneRemovalIds.size === 0) return;
- const drop = _pendingDoneRemovalIds;
- _pendingDoneRemovalIds = new Set();
- queueJobs = queueJobs.filter(j => !drop.has(j.id));
- });
+ if (_doneRemovalCoalescer) {
+ _doneRemovalCoalescer.add(job.id);
+ } else {
+ // Legacy slow path: immediate filter when the lib script didn't load.
+ queueJobs = queueJobs.filter(j => j !== job);
}
}
@@ -3717,11 +3719,7 @@ window.addEventListener('beforeunload', () => {
// Drain pending done-removals synchronously before persisting so jobs the
// user expected to disappear (removeFromQueueOnDone=true) don't reappear
// on next launch. Microtask wouldn't run before the sync IPC below.
- if (_pendingDoneRemovalIds.size > 0) {
- const drop = _pendingDoneRemovalIds;
- _pendingDoneRemovalIds = new Set();
- queueJobs = queueJobs.filter(j => !drop.has(j.id));
- }
+ if (_doneRemovalCoalescer) _doneRemovalCoalescer.drainSync();
const globalSettings = {
...(config.globalSettings || {}),
pendingQueue: buildPersistedQueueState()
diff --git a/renderer/index.html b/renderer/index.html
index be63aaf..d2217e8 100644
--- a/renderer/index.html
+++ b/renderer/index.html
@@ -332,6 +332,7 @@
+