siftlode/frontend/src/lib/history.ts
npeter83 3d8015f3a9 fix(history): stop the next overlay's first Back being swallowed after a button/ESC close
useBackToClose closes an overlay programmatically (X button or ESC) by
setting the module-global suppressPop=true and calling history.back(), so
the remaining overlays' popstate handlers ignore that synthetic pop. But
the flag was only ever reset by one of those handlers — when the closed
overlay had no overlay underneath it (the common single-player case),
nothing consumed it and suppressPop leaked true.

The next overlay's first real Back then hit the stale true, reset it and
returned early without closing — the Back was silently swallowed. Symptom
in the YouTube-search flow: close the player with the X button, open
another, press Back once -> nothing happens; a second Back closes it AND
discards the search results, dumping you on the normal feed.

Fix: register a one-shot popstate listener alongside suppressPop=true so
the flag is cleared on that very pop even when no overlay remains to
consume it. Nesting is unaffected (an underlying overlay still short-
circuits first and the one-shot is a redundant no-op).
2026-07-01 01:42:00 +02:00

84 lines
3.6 KiB
TypeScript

// In-app browser-history integration so Back/Forward step through a module's sub-views and
// overlays before leaving the page — not straight back to the previous module.
//
// Two primitives share the existing page-history (App stamps `sfPage` in history.state):
// - useHistorySubview: a module's active sub-view rides in history.state._sub, so Back inside
// a module (e.g. Messages thread → list) returns to the parent view first.
// - useBackToClose: while an overlay/modal is mounted it occupies one history entry; Back
// closes the topmost one. A module-level stack keeps nesting correct — closing one modal by
// its own button pops exactly its entry without tripping the modals underneath.
//
// setPage() pushes a clean { sfPage } entry (dropping _sub/_ov), so each page starts at its root.
import { useEffect, useRef, useState } from "react";
// --- Sub-views (value carried in history.state._sub) ---------------------------------------
export function useHistorySubview<T>(root: T): {
view: T;
open: (next: T) => void;
back: () => void;
} {
const [view, setView] = useState<T>(() => (window.history.state?._sub as T) ?? root);
useEffect(() => {
const onPop = () => setView((window.history.state?._sub as T) ?? root);
window.addEventListener("popstate", onPop);
return () => window.removeEventListener("popstate", onPop);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
return {
view,
open: (next: T) => {
setView(next);
window.history.pushState({ ...window.history.state, _sub: next }, "");
},
back: () => window.history.back(),
};
}
// --- Overlays / modals (one history entry each, nesting-safe) -------------------------------
const overlayStack: number[] = [];
let overlayCounter = 0;
// Set while we pop our OWN entry programmatically (button/ESC close) so the other overlays'
// popstate handlers don't mistake it for a user Back and close themselves too.
let suppressPop = false;
/** While the calling component is mounted, Back (or the browser/mouse back button) closes it via
* `onClose` instead of navigating away. Mount the component only while the overlay is open. */
export function useBackToClose(onClose: () => void): void {
const cb = useRef(onClose);
cb.current = onClose;
useEffect(() => {
const token = ++overlayCounter;
overlayStack.push(token);
window.history.pushState({ ...window.history.state, _ov: token }, "");
const onPop = () => {
if (suppressPop) {
suppressPop = false;
return;
}
if (overlayStack[overlayStack.length - 1] === token) {
overlayStack.pop();
cb.current();
}
};
window.addEventListener("popstate", onPop);
return () => {
window.removeEventListener("popstate", onPop);
const idx = overlayStack.lastIndexOf(token);
if (idx !== -1) {
// Closed programmatically (not via Back): drop our own history entry, and tell the
// remaining overlays' handlers to ignore the resulting popstate. A one-shot listener
// clears the flag on that very popstate even when NO overlay remains underneath to
// consume it — otherwise suppressPop leaks `true` and silently swallows the NEXT
// overlay's first Back (reopen-after-button-close → first Back does nothing).
overlayStack.splice(idx, 1);
suppressPop = true;
const clearSuppress = () => {
suppressPop = false;
window.removeEventListener("popstate", clearSuppress);
};
window.addEventListener("popstate", clearSuppress);
window.history.back();
}
};
}, []);
}