diff --git a/clients/web/store.js b/clients/web/store.js index 6a88e28f..72de5fd5 100644 --- a/clients/web/store.js +++ b/clients/web/store.js @@ -1,3 +1,16 @@ +// Cap each history buffer so a long session can't grow the arrays (and the +// per-render join/DOM rebuild) without bound. Older lines drop off the front. +// This is the main guard against the mobile "taps get slower over time" lag. +const HISTORY_BUFFER_LIMIT = 500; + +function pushCappedHistory(lines, text) { + lines.push(text); + const overflow = lines.length - HISTORY_BUFFER_LIMIT; + if (overflow > 0) { + lines.splice(0, overflow); + } +} + export function createStore() { const state = { connection: { @@ -58,9 +71,9 @@ export function createStore() { if (!state.historyBuffers[buffer]) { state.historyBuffers[buffer] = []; } - state.historyBuffers[buffer].push(text); + pushCappedHistory(state.historyBuffers[buffer], text); if (buffer !== "all") { - state.historyBuffers.all.push(text); + pushCappedHistory(state.historyBuffers.all, text); } notify(); }, diff --git a/clients/web/ui/history.js b/clients/web/ui/history.js index 804132f3..3fb33ea8 100644 --- a/clients/web/ui/history.js +++ b/clients/web/ui/history.js @@ -12,7 +12,8 @@ export function createHistoryView({ const isMobileLike = window.matchMedia("(pointer: coarse)").matches; let mobileCollapsed = isMobileLike; let renderedLogBuffer = ""; - let renderedLogCount = 0; + let renderedLogValue = null; + let renderScheduled = false; function ensureBufferPosition(bufferName) { if (!Object.hasOwn(bufferPositions, bufferName)) { @@ -72,41 +73,59 @@ export function createHistoryView({ } } - function render() { + function flushRender() { + renderScheduled = false; for (const name of getBufferNames()) { ensureBufferPosition(name); } const bufferName = store.state.historyBuffer; const lines = store.state.historyBuffers[bufferName] || []; - historyEl.value = lines.join("\n"); + const joined = lines.join("\n"); + + // Skip the DOM work entirely when nothing in the visible buffer changed. + // notify() fires on every state change (menu navigation, audio, etc.), so + // most flushes here are no-ops — this keeps arrow-key menu moves cheap. + if (bufferName === renderedLogBuffer && joined === renderedLogValue) { + return; + } + renderedLogBuffer = bufferName; + renderedLogValue = joined; + + historyEl.value = joined; historyEl.scrollTop = historyEl.scrollHeight; if (historyLogEl) { - const needsRebuild = renderedLogBuffer !== bufferName || renderedLogCount > lines.length; - if (needsRebuild) { - historyLogEl.replaceChildren(); - for (const line of lines) { - const row = document.createElement("p"); - row.className = "history-line"; - row.textContent = line; - historyLogEl.appendChild(row); - } - renderedLogBuffer = bufferName; - renderedLogCount = lines.length; - } else if (lines.length > renderedLogCount) { - for (let i = renderedLogCount; i < lines.length; i += 1) { - const row = document.createElement("p"); - row.className = "history-line"; - row.textContent = lines[i]; - historyLogEl.appendChild(row); - } - renderedLogBuffer = bufferName; - renderedLogCount = lines.length; + // Rebuild from scratch (bounded by the store's per-buffer cap). A full + // rebuild is correct even when the cap trims lines off the front, which + // the previous incremental count-based append could not detect. + const fragment = document.createDocumentFragment(); + for (const line of lines) { + const row = document.createElement("p"); + row.className = "history-line"; + row.textContent = line; + fragment.appendChild(row); } + historyLogEl.replaceChildren(fragment); historyLogEl.scrollTop = historyLogEl.scrollHeight; } } + // Coalesce bursts of notify() calls (a single tap can append many lines, each + // firing notify()) into one render per animation frame instead of one render + // per line. This is what turns the old O(lines added * buffer size) churn per + // tap into a single bounded render. + function render() { + if (renderScheduled) { + return; + } + renderScheduled = true; + if (typeof requestAnimationFrame === "function") { + requestAnimationFrame(flushRender); + } else { + flushRender(); + } + } + function renderMobileVisibility() { if (!historyContentEl || !historyToggleEl || !historyLogEl) { return;