diff --git a/package.json b/package.json
index d8ecd46..1131d91 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "multi-hoster-uploader",
- "version": "3.3.103",
+ "version": "3.3.104",
"description": "Upload files to doodstream, voe, vidmoly, byse simultaneously",
"main": "main.js",
"scripts": {
diff --git a/renderer/app.js b/renderer/app.js
index 64bca40..62b9b59 100644
--- a/renderer/app.js
+++ b/renderer/app.js
@@ -4738,54 +4738,67 @@ function _buildRecentRowHtml(row) {
let _recentLastRenderedSig = '';
let _recentLastRenderedLen = 0;
let _recentPendingAppends = 0;
+let _recentWorking = [];
+let _recentLastRange = { start: -1, end: -1 };
+let _recentScrollQueued = false;
+
+function _onRecentScroll() {
+ if (_recentScrollQueued) return;
+ _recentScrollQueued = true;
+ requestAnimationFrame(() => { _recentScrollQueued = false; _renderRecentVirtualRows(); });
+}
+
+function _renderRecentVirtualRows() {
+ const wrap = document.querySelector('.recent-files-table-wrap');
+ const tbody = document.getElementById('recentFilesBody');
+ if (!wrap || !tbody) return;
+ const total = _recentWorking.length;
+ if (!total) return;
+ const scrollTop = wrap.scrollTop;
+ const viewportHeight = Math.max(wrap.clientHeight, 400);
+ const startIdx = Math.max(0, Math.floor(scrollTop / VIRTUAL_ROW_HEIGHT) - VIRTUAL_OVERSCAN);
+ const endIdx = Math.min(total, Math.ceil((scrollTop + viewportHeight) / VIRTUAL_ROW_HEIGHT) + VIRTUAL_OVERSCAN);
+ if (startIdx === _recentLastRange.start && endIdx === _recentLastRange.end) return;
+ _recentLastRange = { start: startIdx, end: endIdx };
+ const topPad = startIdx * VIRTUAL_ROW_HEIGHT;
+ const bottomPad = Math.max(0, (total - endIdx) * VIRTUAL_ROW_HEIGHT);
+ const parts = [];
+ if (topPad > 0) parts.push(`
|
`);
+ for (let i = startIdx; i < endIdx; i++) parts.push(_buildRecentRowHtml(_recentWorking[i]));
+ if (bottomPad > 0) parts.push(` |
`);
+ tbody.innerHTML = parts.join('');
+}
function renderRecentUploadsPanel(appendOnly = false) {
const tbody = document.getElementById('recentFilesBody');
if (!tbody) return;
- const pendingAppends = _recentPendingAppends;
_recentPendingAppends = 0;
+ const wrap = tbody.closest('.recent-files-table-wrap');
+
if (!sessionFilesData.length) {
tbody.innerHTML = '| Noch keine Uploads in dieser Session. |
';
- _recentLastRenderedSig = '';
- _recentLastRenderedLen = 0;
- return;
- }
-
- const rows = sortRecentFiles(sessionFilesData);
- const sig = `${recentSortState.key}|${recentSortState.direction}`;
- const dateDescAppendOnly = appendOnly
- && pendingAppends > 0
- && sig === 'date|desc'
- && _recentLastRenderedSig === sig
- && tbody.querySelectorAll('.recent-file-row').length === _recentLastRenderedLen;
-
- const wrap = tbody.closest('.recent-files-table-wrap');
- const wasAtTop = !wrap || wrap.scrollTop <= 48;
-
- let wasAppendOnly = false;
- if (dateDescAppendOnly) {
- const added = Math.min(pendingAppends, rows.length);
- let html = '';
- for (let i = 0; i < added; i++) html += _buildRecentRowHtml(rows[i]);
- tbody.insertAdjacentHTML('afterbegin', html);
- let evict = (_recentLastRenderedLen + added) - rows.length;
- while (evict > 0) {
- const last = tbody.lastElementChild;
- if (!last) break;
- last.remove();
- evict--;
- }
- wasAppendOnly = true;
+ _recentWorking = [];
+ _recentLastRange = { start: -1, end: -1 };
} else {
- tbody.innerHTML = rows.map(_buildRecentRowHtml).join('');
+ const prevLen = _recentWorking.length;
+ _recentWorking = sortRecentFiles(sessionFilesData);
+ _recentLastRange = { start: -1, end: -1 };
+ const sig = `${recentSortState.key}|${recentSortState.direction}`;
+ if (wrap) {
+ const added = _recentWorking.length - prevLen;
+ if (sig === 'date|desc' && wrap.scrollTop <= 48) wrap.scrollTop = 0;
+ else if (sig === 'date|desc' && added > 0) wrap.scrollTop += added * VIRTUAL_ROW_HEIGHT;
+ }
+ _renderRecentVirtualRows();
}
- if (wrap && sig === 'date|desc' && wasAtTop) wrap.scrollTop = 0;
- _recentLastRenderedSig = sig;
- _recentLastRenderedLen = rows.length;
// Event delegation – bind once, not per-row
if (!_recentListenersBound) {
_recentListenersBound = true;
+ if (wrap) {
+ wrap.addEventListener('scroll', _onRecentScroll, { passive: true });
+ if (typeof window.ResizeObserver !== 'undefined') new window.ResizeObserver(_onRecentScroll).observe(wrap);
+ }
tbody.addEventListener('click', (e) => {
const tr = e.target.closest('.recent-file-row');
if (!tr) return;
@@ -4824,8 +4837,7 @@ function renderRecentUploadsPanel(appendOnly = false) {
});
}
- // Sort headers only change when the sort state changes — skip on appends.
- if (!wasAppendOnly) updateRecentSortHeaders();
+ updateRecentSortHeaders();
}
const HISTORY_RENDER_CAP = 2000;
diff --git a/renderer/styles.css b/renderer/styles.css
index f2e6ce3..e5b9420 100644
--- a/renderer/styles.css
+++ b/renderer/styles.css
@@ -657,6 +657,7 @@ body.col-resizing, body.col-resizing * { cursor: col-resize !important; user-sel
.recent-file-row {
cursor: pointer;
transition: background 0.15s;
+ height: 28px;
}
.recent-file-row:hover {
background: rgba(255, 255, 255, 0.03);
diff --git a/tasks/todo.md b/tasks/todo.md
index 817434c..41ab0f7 100644
--- a/tasks/todo.md
+++ b/tasks/todo.md
@@ -1,3 +1,40 @@
+# v3.3.104 — virtualize the Recent-uploads panel (the last non-virtual table)
+
+v3.3.103 log (real 2464-job, 4-hoster batch → 95 concurrent): the statSync fix HELD (batch-start main spike
+336→231ms with 10× more jobs), and the whole 90s ramp to 95 active was PRISTINE (mean ~11ms, fps=32,
+longtasks=0). Residual: rapidly clicking tabs DURING the 95-active upload → renderer-longtask 210-221ms
+(proc=0ms = layout). Cause: the Recent-uploads panel (renderRecentUploadsPanel) rendered ALL sessionFilesData
+rows (≤2000) into the DOM non-virtualized — the exact analog of the History table pre-v3.3.102. Switching to
+that view laid out ~2000 rows.
+
+Workflow w23318hm0 hit transient 529 overload (no cached results); did the fix directly using the proven
+History-virtualization template + Playwright empirical verification (stronger than agent review for layout).
+
+SHIPPED v3.3.104 (renderer/app.js + styles.css):
+- Virtualized renderRecentUploadsPanel mirroring History/_renderVirtualRows: tbody#recentFilesBody gets only
+ ~visible rows + top/bottom spacer (VIRTUAL_ROW_HEIGHT=28, OVERSCAN=10). Scroll handler (_onRecentScroll
+ rAF-coalesced) + ResizeObserver on .recent-files-table-wrap (doubles as show-trigger). _recentWorking holds
+ the sorted set. DROPPED the insertAdjacentHTML append-only fast path (a ~40-row window re-render is cheap);
+ every render re-renders the visible window. Scroll-position preserved on prepend (date|desc: scrollTop=0 at
+ top, else += added*ROW_HEIGHT).
+- SELECTION SAFE: _buildRecentRowHtml already stamps `selected` from selectedRecentIds.has(row.order) per row,
+ so off-screen-selected rows render selected when scrolled in; selectedRecentIds stays the source of truth;
+ shift-select already uses _recentSortCache (not the DOM). applyRecentSelectionClasses toggling only visible
+ rows is correct.
+- styles.css: .recent-file-row { height: 28px } so the virtualization math is exact (table already had
+ table-layout:fixed, so no column-jump fix needed unlike History).
+- Empty-state guard: _renderRecentVirtualRows returns early when total=0 so it never wipes the "Noch keine
+ Uploads" message.
+- PLAYWRIGHT-VERIFIED @2000 rows (bounded container): show-cost 118ms→2.4ms, DOM stays 29-39 rows, scroll maps
+ correctly (row1500→window@1486), scrollHeight exact (56014≈56000), off-screen-selected renders with class,
+ row height exactly 28. 407 tests pass, clean boot.
+
+Every large table is now virtualized (Queue, History, Recent). DEFERRED still (one-time/minor): batch-start
+~500ms first-render + residual 231ms main spike for 2464 jobs (one-time per batch); first-get-history-after-
+batch parse-cache/JSONL.
+
+---
+
# v3.3.103 — kill the batch-start 336ms main stall (synchronous statSync storm)
v3.3.102 log (real 224-job batch): History virtualization CONFIRMED (no get-history on tab switch),