diff --git a/containers/libreportal/frontend/components/admin/index.js b/containers/libreportal/frontend/components/admin/index.js index af103ce..7bbd544 100644 --- a/containers/libreportal/frontend/components/admin/index.js +++ b/containers/libreportal/frontend/components/admin/index.js @@ -49,6 +49,12 @@ LP.features.register({ // Drop OverviewPage's task-refresh registration so a finished verify/update // task doesn't repaint a torn-down board. try { ctx.services.tasks.refresh && ctx.services.tasks.refresh.unregister('overview-page'); } catch (_) {} + // Each sub-page binds a document-level click listener; nulling the global + // alone leaks it (the backup-stacking bug class), so abort each first. + try { window.overviewPage && window.overviewPage.dispose && window.overviewPage.dispose(); } catch (_) {} + try { window.systemPage && window.systemPage.dispose && window.systemPage.dispose(); } catch (_) {} + try { window.sshPage && window.sshPage.dispose && window.sshPage.dispose(); } catch (_) {} + try { window.peersPage && window.peersPage.dispose && window.peersPage.dispose(); } catch (_) {} window.overviewPage = null; window.systemPage = null; window.sshPage = null; diff --git a/containers/libreportal/frontend/components/admin/overview/js/overview-page.js b/containers/libreportal/frontend/components/admin/overview/js/overview-page.js index ada47ee..d5f28a4 100644 --- a/containers/libreportal/frontend/components/admin/overview/js/overview-page.js +++ b/containers/libreportal/frontend/components/admin/overview/js/overview-page.js @@ -7,6 +7,7 @@ class OverviewPage { this.rootId = rootId; this.taskManager = (typeof TaskManager !== 'undefined') ? new TaskManager() : null; this._bound = false; + this._ac = new AbortController(); } root() { return document.getElementById(this.rootId); } @@ -52,7 +53,7 @@ class OverviewPage { if (go) { this.go(go.dataset.adminGo); return; } if (e.target.closest('[data-admin-update]')) { this.runUpdate(); return; } if (e.target.closest('[data-admin-verify]')) { this.runVerify(); return; } - }); + }, { signal: this._ac.signal }); // When a verify or update task finishes, re-read the integrity status // and re-render so the badge reflects reality without a manual reload. // Registered with the task-refresh coordinator (single source of truth). @@ -65,6 +66,8 @@ class OverviewPage { }); } + dispose() { try { this._ac && this._ac.abort(); } catch (_) {} } + go(where) { if (where === 'backup') { window.spaClean?.navigate('/backup', true); diff --git a/containers/libreportal/frontend/components/admin/peers/js/peers-page.js b/containers/libreportal/frontend/components/admin/peers/js/peers-page.js index a2f5351..6e96106 100644 --- a/containers/libreportal/frontend/components/admin/peers/js/peers-page.js +++ b/containers/libreportal/frontend/components/admin/peers/js/peers-page.js @@ -14,6 +14,7 @@ class PeersPage { this.backupLocations = []; // populated for the loc_idx dropdown this.taskManager = (typeof TaskManager !== 'undefined') ? new TaskManager() : null; this.eventBound = false; + this._ac = new AbortController(); } async init() { @@ -71,9 +72,11 @@ class PeersPage { this.closeAllModals(); return; } - }); + }, { signal: this._ac.signal }); } + dispose() { try { this._ac && this._ac.abort(); } catch (_) {} } + render() { const list = document.getElementById('peers-list'); const empty = document.getElementById('peers-empty'); diff --git a/containers/libreportal/frontend/components/admin/ssh/js/ssh-page.js b/containers/libreportal/frontend/components/admin/ssh/js/ssh-page.js index 19047db..9a97aa6 100644 --- a/containers/libreportal/frontend/components/admin/ssh/js/ssh-page.js +++ b/containers/libreportal/frontend/components/admin/ssh/js/ssh-page.js @@ -10,6 +10,7 @@ class SshPage { this.taskManager = (typeof TaskManager !== 'undefined') ? new TaskManager() : null; this.data = null; this._bound = false; + this._ac = new AbortController(); } root() { return document.getElementById(this.rootId); } @@ -51,9 +52,11 @@ class SshPage { if (rm) { this.removeKey(rm.dataset.fp); return; } const tog = e.target.closest('[data-action="ssh-toggle-password"]'); if (tog) { this.togglePassword(tog.dataset.next); return; } - }); + }, { signal: this._ac.signal }); } + dispose() { try { this._ac && this._ac.abort(); } catch (_) {} } + render() { const root = this.root(); if (!root) return; diff --git a/containers/libreportal/frontend/components/admin/system/js/system-page.js b/containers/libreportal/frontend/components/admin/system/js/system-page.js index c17d200..dcef39e 100644 --- a/containers/libreportal/frontend/components/admin/system/js/system-page.js +++ b/containers/libreportal/frontend/components/admin/system/js/system-page.js @@ -28,6 +28,7 @@ class SystemPage { this.d = {}; // Active sub-view renderer. Disposed on each init(). this._subview = null; + this._ac = new AbortController(); } root() { return document.getElementById(this.rootId); } @@ -157,9 +158,11 @@ class SystemPage { if (window.navigateToRoute) window.navigateToRoute('/admin/system/storage'); return; } - }); + }, { signal: this._ac.signal }); } + dispose() { try { this._ac && this._ac.abort(); } catch (_) {} } + /* ---- formatting helpers (used by sub-pages via window.SystemFmt) ---- */ escape(s) { return String(s ?? '').replace(/[&<>"']/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c])); } bytes(n) { diff --git a/containers/libreportal/frontend/components/apps/core/js/app-tabbed-manager.js b/containers/libreportal/frontend/components/apps/core/js/app-tabbed-manager.js index b61029f..6297ff6 100755 --- a/containers/libreportal/frontend/components/apps/core/js/app-tabbed-manager.js +++ b/containers/libreportal/frontend/components/apps/core/js/app-tabbed-manager.js @@ -30,6 +30,7 @@ class AppTabbedManager { this.tasksManager = new TasksManager(); this.appsManager = new AppsManager(); this.initialized = false; + this._ac = new AbortController(); // backs teardown of the watchdog + popstate listeners (dispose()) // Button state management this.disabledButtons = new Set(); @@ -614,11 +615,11 @@ class AppTabbedManager { this._reconcileTimer = setTimeout(tick, next); }; this._reconcileTimer = setTimeout(tick, 1500); - window.addEventListener('taskBusReady', reconcile); - window.addEventListener('focus', reconcile); + window.addEventListener('taskBusReady', reconcile, { signal: this._ac.signal }); + window.addEventListener('focus', reconcile, { signal: this._ac.signal }); document.addEventListener('visibilitychange', () => { if (document.visibilityState === 'visible') reconcile(); - }); + }, { signal: this._ac.signal }); } // Set current app from URL BEFORE setting up URL monitoring @@ -687,7 +688,20 @@ class AppTabbedManager { if (this.currentApp && newAppName !== this.currentApp) { this.updateApp(newAppName); } - }); + }, { signal: this._ac.signal }); + } + + // Release everything bound for this mount so navigating away doesn't leave the + // reconcile loop + window/document listeners firing on the stale singleton (the + // backup-stacking bug class). Re-armed on the next initialize(). + dispose() { + try { this._ac && this._ac.abort(); } catch (_) {} + this._ac = new AbortController(); + if (this._reconcileTimer) { clearTimeout(this._reconcileTimer); this._reconcileTimer = null; } + this._watchdogStarted = false; + this._listenersWired = false; + try { window.servicesManager && window.servicesManager.unload && window.servicesManager.unload(); } catch (_) {} + try { window.toolsManager && window.toolsManager.unload && window.toolsManager.unload(); } catch (_) {} } // Watch for new tasks and switch to logs tab diff --git a/containers/libreportal/frontend/components/apps/index.js b/containers/libreportal/frontend/components/apps/index.js index 9c276a8..fc7a3ac 100644 --- a/containers/libreportal/frontend/components/apps/index.js +++ b/containers/libreportal/frontend/components/apps/index.js @@ -76,7 +76,11 @@ LP.features.register({ }, async unmount() { - // No-op: appsManager / appTabbedManager are shared system-loader singletons. - // The dirty-config nav guard fires in navigate() before unmount. + // appsManager / appTabbedManager are shared singletons (never null them), but + // the detail view's per-mount resources DO need releasing: the reconcile loop, + // the watchdog window/document listeners, and the active tab's Services + // intervals + log SSE. dispose() handles all of it (re-armed on next mount). + // The dirty-config nav guard still fires in navigate() before unmount. + try { window.appTabbedManager && window.appTabbedManager.dispose && window.appTabbedManager.dispose(); } catch (_) {} }, }); diff --git a/containers/libreportal/frontend/components/dashboard/index.js b/containers/libreportal/frontend/components/dashboard/index.js index d133e19..b26672d 100644 --- a/containers/libreportal/frontend/components/dashboard/index.js +++ b/containers/libreportal/frontend/components/dashboard/index.js @@ -27,16 +27,18 @@ LP.features.register({ if (typeof loadDashboardData === 'function') loadDashboardData(); }, 100); ctx.sub(() => clearTimeout(reloadTimer)); + + // Stop the 1 Hz update-countdown interval + drop this view's LiveSystem + // subscription on navigation away — both are module-private in data-loader.js + // and otherwise outlive the page (the leak class). ctx.teardown runs these. + ctx.sub(() => { if (typeof stopUpdateCountdown === 'function') stopUpdateCountdown(); }); + ctx.sub(() => { if (typeof detachDashboardLive === 'function') detachDashboardLive(); }); }, async unmount() { - // Nothing this view owns is destroyable from here: the dashboard has no - // handler-newed controller and no system-loader singleton to tear down. - // The pending reload timer is cancelled via the ctx.sub() above (ctx.teardown). - // The 1 Hz LiveSystem SSE sub (attachDashboardLive) self-releases on its - // next sample once the dashboard DOM is gone, and the 1 s update countdown - // is a module-private interval with no exported stopper (pre-existing; it - // self-clears on the next dashboard mount). Both are noted for the Phase 5 - // dashboard cleanup; neither is reachable here. + // This view's teardown is all registered via ctx.sub() in mount() and runs + // in ctx.teardown(): the pending reload timer, the 1 Hz update-countdown + // interval (stopUpdateCountdown), and this view's LiveSystem subscription + // (detachDashboardLive). No handler-newed controller or singleton to tear down. }, }); diff --git a/containers/libreportal/frontend/components/tasks/index.js b/containers/libreportal/frontend/components/tasks/index.js index b412c66..a4cd25c 100644 --- a/containers/libreportal/frontend/components/tasks/index.js +++ b/containers/libreportal/frontend/components/tasks/index.js @@ -16,6 +16,10 @@ LP.features.register({ if (window.tasksManager) { await window.tasksManager.init(); + // Re-arm the global live-log poller (idempotent) — it's first started in + // the constructor, which doesn't re-run, so a revisit after unmount cleared + // it would otherwise have no poller. unmount() clears it again. + if (typeof window.tasksManager.startGlobalLiveLogUpdater === 'function') window.tasksManager.startGlobalLiveLogUpdater(); } else { // Don't throw — matches handleTasks: the page still renders, task // functionality is just limited until the task-system component is ready. @@ -31,6 +35,17 @@ LP.features.register({ clearInterval(tm.refreshInterval); tm.refreshInterval = null; } + // The global live-log poller (constructor-started, handle was discarded) and + // any still-running per-task monitors — release them so they don't keep + // firing on a torn-down page. + if (tm && tm.globalLiveLogInterval) { + clearInterval(tm.globalLiveLogInterval); + tm.globalLiveLogInterval = null; + } + if (tm && tm.taskMonitors) { + for (const stop of Array.from(tm.taskMonitors.values())) { try { stop(); } catch (_) {} } + tm.taskMonitors.clear(); + } // Stop any open per-task log streams this view started (each removes its own // SSE listeners + map entry). Snapshot keys first — stopLogStreaming mutates // the map. Does NOT touch the shared bus. diff --git a/containers/libreportal/frontend/components/tasks/js/tasks-log-stream.js b/containers/libreportal/frontend/components/tasks/js/tasks-log-stream.js index d906b95..b5064e9 100644 --- a/containers/libreportal/frontend/components/tasks/js/tasks-log-stream.js +++ b/containers/libreportal/frontend/components/tasks/js/tasks-log-stream.js @@ -200,9 +200,11 @@ Object.assign(TasksManager.prototype, { }, // Start global live log updater - simple 2-second updates for all running tasks startGlobalLiveLogUpdater() { - + // Idempotent + keep the handle so unmount can clear it (it was discarded + // before — a permanent 2 s poll that outlived the page). + if (this.globalLiveLogInterval) return; // Update every 2 seconds - setInterval(async () => { + this.globalLiveLogInterval = setInterval(async () => { // Find all running tasks const runningTasks = this.tasks.filter(task => task.status === 'running'); diff --git a/containers/libreportal/frontend/components/tasks/js/tasks-row-expand.js b/containers/libreportal/frontend/components/tasks/js/tasks-row-expand.js index b02aadc..48e0bf2 100644 --- a/containers/libreportal/frontend/components/tasks/js/tasks-row-expand.js +++ b/containers/libreportal/frontend/components/tasks/js/tasks-row-expand.js @@ -92,10 +92,19 @@ Object.assign(TasksManager.prototype, { this.stopLogStreaming(taskId); window.removeEventListener('taskUpdated', onUpdate); window.removeEventListener('taskCompleted', onComplete); + if (this.taskMonitors) this.taskMonitors.delete(taskId); }; window.addEventListener('taskUpdated', onUpdate); window.addEventListener('taskCompleted', onComplete); + // Track this monitor so the tasks module's unmount() can release it if the + // user navigates away while the task is still running (these window + // listeners + interval otherwise outlive the page until the task completes). + (this.taskMonitors = this.taskMonitors || new Map()).set(taskId, () => { + if (statusUpdateInterval) { clearInterval(statusUpdateInterval); statusUpdateInterval = null; } + window.removeEventListener('taskUpdated', onUpdate); + window.removeEventListener('taskCompleted', onComplete); + }); }, // Auto-expand a task when it's created async autoExpandTask(taskId) { diff --git a/containers/libreportal/frontend/core/data-loader/js/data-loader.js b/containers/libreportal/frontend/core/data-loader/js/data-loader.js index 544d09b..026e958 100755 --- a/containers/libreportal/frontend/core/data-loader/js/data-loader.js +++ b/containers/libreportal/frontend/core/data-loader/js/data-loader.js @@ -215,6 +215,10 @@ async function loadDashboardData() { // Countdown timer for next automatic update let updateCountdownInterval = null; +function stopUpdateCountdown() { + if (updateCountdownInterval) { clearInterval(updateCountdownInterval); updateCountdownInterval = null; } +} + function startUpdateCountdown() { // Clear any existing countdown if (updateCountdownInterval) { @@ -467,6 +471,9 @@ async function loadSystemInfo() { // ticker feeds both. Cleanup hangs off a route-change check: if the dashboard // DOM goes away we drop the sub on the next sample. let _dashboardLiveUnsub = null; +function detachDashboardLive() { + if (typeof _dashboardLiveUnsub === 'function') { try { _dashboardLiveUnsub(); } catch (_) {} _dashboardLiveUnsub = null; } +} function attachDashboardLive() { if (!window.LiveSystem) return; if (_dashboardLiveUnsub) { try { _dashboardLiveUnsub(); } catch (_) {} _dashboardLiveUnsub = null; }