ui(spa): stamp initial history entry + close live buses on pagehide so back-button works like a real SPA

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 <librelad@digitalangels.vip>
This commit is contained in:
librelad 2026-05-27 23:28:25 +01:00
parent 51069ae05a
commit dd1264e335
3 changed files with 55 additions and 5 deletions

View File

@ -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)
// ------------------------------------------------------------------

View File

@ -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

View File

@ -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; }
};