From dd1264e33559419938e0cc33ab2e35d35e5d99e7 Mon Sep 17 00:00:00 2001 From: librelad Date: Wed, 27 May 2026 23:28:25 +0100 Subject: [PATCH] ui(spa): stamp initial history entry + close live buses on pagehide so back-button works like a real SPA MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two reasons the back button was unreliable: 1. The very first history entry (the URL the user landed on) had state: null because handleInitialRoute() called navigate(path, false), and the pushState branch only ran when addToHistory=true. When the user later pushState'd forward and then hit back, the popstate handler's guard "e.state && e.state.route" was false on the initial entry, so it silently did nothing — back appeared broken. Now navigate() replaceState's the current entry whenever addToHistory=false, so the initial entry (and any back-compat URL rewrite) always carries its route. The popstate handler also now falls back to window.location when state.route is missing, so third-party history manipulation can't break us. 2. Open SSE streams (LiveSystem, taskEventBus, services-manager log tails) block the browser's back-forward cache. Without BFCache, back has to fully re-mount the page instead of restoring it instantly the way Amazon/GitHub feel. Now pagehide closes every live bus we own, and pageshow(persisted=true) reopens them when the page is restored from BFCache. Log tails aren't auto-resumed — Resume overlay handles that if the user comes back to a services tab. Public surface added: LiveSystem.pause()/resume() and ServicesManager.pauseStreams(). TaskEventBus already had stop()/ start(). The legacy-URL rewrite in handleAppDetail also now replaceState's with { route: canonical } instead of {} so the stamp is consistent across all internal history updates. Signed-off-by: librelad --- .../js/components/app/services-manager.js | 5 +++ containers/libreportal/frontend/js/spa.js | 43 ++++++++++++++++--- .../frontend/js/utils/system-live.js | 12 ++++++ 3 files changed, 55 insertions(+), 5 deletions(-) diff --git a/containers/libreportal/frontend/js/components/app/services-manager.js b/containers/libreportal/frontend/js/components/app/services-manager.js index 5075f60..9601032 100644 --- a/containers/libreportal/frontend/js/components/app/services-manager.js +++ b/containers/libreportal/frontend/js/components/app/services-manager.js @@ -681,6 +681,11 @@ class ServicesManager { for (const [name] of this.openLogStreams) this._closeLogStream(name); } + // Public hook for the page lifecycle (pagehide). Closes every live log + // tail so the browser can BFCache-snapshot the page instead of being + // forced into a full reload on back/forward. + pauseStreams() { this._stopAllLogs(); } + // ------------------------------------------------------------------ // Status refresh loop (only updates dots/text, not full re-render) // ------------------------------------------------------------------ diff --git a/containers/libreportal/frontend/js/spa.js b/containers/libreportal/frontend/js/spa.js index 900c2a3..fd48dcb 100755 --- a/containers/libreportal/frontend/js/spa.js +++ b/containers/libreportal/frontend/js/spa.js @@ -170,6 +170,14 @@ class LibrePortalSPAClean { // Update browser history if (addToHistory) { history.pushState({ route: path }, '', path); + } else { + // Even for "don't add to history" navigations (initial route, back-compat + // rewrites) we stamp the current entry's state with the resolved route. + // Without this the very first history entry has state: null, so popstate + // back to it from a later pushState'd entry hits the null guard and + // does nothing — exactly the "back button doesn't work" symptom. + const here = window.location.pathname + window.location.search; + history.replaceState({ route: path }, '', here); } this.currentRoute = path; @@ -373,7 +381,7 @@ class LibrePortalSPAClean { const taskId = url.searchParams.get('task'); const canonical = window.appPath(appName, tab, sub, taskId); if (canonical !== url.pathname + url.search) { - window.history.replaceState({}, '', canonical); + window.history.replaceState({ route: canonical }, '', canonical); } } @@ -612,11 +620,36 @@ window.navigateToRoute = function(href) { } }; -// Handle browser back/forward +// Handle browser back/forward. Prefer the route stamped on the history +// entry's state; fall back to the URL bar so entries that somehow lack +// state (third-party history manipulation, very-early popstate before +// init() finished) still re-render the right page instead of no-op'ing. window.addEventListener('popstate', (e) => { - if (e.state && e.state.route && window.spaClean) { - window.spaClean.navigate(e.state.route, false); - } + if (!window.spaClean) return; + const route = (e.state && e.state.route) || (window.location.pathname + window.location.search); + window.spaClean.navigate(route, false); +}); + +// Back-forward cache (BFCache) cooperation. Open SSE/WebSocket connections +// block the browser from snapshotting the page, so the back button ends up +// doing a full reload instead of restoring instantly. Closing every live +// bus we own on pagehide lets the snapshot happen; on pageshow with +// event.persisted === true the page just came back from BFCache and we +// reopen them. Fresh loads also fire pageshow but with persisted=false — +// we ignore those because the normal init paths already started everything. +window.addEventListener('pagehide', () => { + try { window.taskEventBus && window.taskEventBus.stop(); } catch (_) {} + try { window.LiveSystem && window.LiveSystem.pause && window.LiveSystem.pause(); } catch (_) {} + try { window.servicesManager && window.servicesManager.pauseStreams && window.servicesManager.pauseStreams(); } catch (_) {} +}); + +window.addEventListener('pageshow', (e) => { + if (!e.persisted) return; + try { window.taskEventBus && window.taskEventBus.start(); } catch (_) {} + try { window.LiveSystem && window.LiveSystem.resume && window.LiveSystem.resume(); } catch (_) {} + // Service log tail streams aren't auto-resumed — the user can click + // Resume on the overlay if they were tailing a log. Most BFCache restores + // are "back to dashboard", so silently dropping the tail is fine. }); // Handle internal link clicks diff --git a/containers/libreportal/frontend/js/utils/system-live.js b/containers/libreportal/frontend/js/utils/system-live.js index a65a5de..0842471 100644 --- a/containers/libreportal/frontend/js/utils/system-live.js +++ b/containers/libreportal/frontend/js/utils/system-live.js @@ -105,6 +105,18 @@ window.LiveSystem = (() => { // Fetch the last sample synchronously (or null if the stream hasn't // produced one yet). Useful for snapshot-style reads without a sub. get last() { return last; }, + // Force-close the stream and cancel any pending reopen. Used by the + // page lifecycle (pagehide) so the browser can put the page into + // BFCache; the visibilitychange handler would normally do this too, + // but firing explicitly leaves nothing to race. + pause() { + close(); + if (reopenTimer) { clearTimeout(reopenTimer); reopenTimer = null; } + }, + // Reopen if anything is still subscribed and the document is visible. + // Called from pageshow(persisted=true) when the page is restored from + // BFCache. + resume() { open(); }, // Test hook: count of active subscribers. get subCount() { return subs.size; } };