useBackToClose eagerly pushed a history entry on mount and called history.back() on unmount. During a modal->modal handoff (e.g. About -> Release Notes) the two ran interleaved in one React commit, so the entering modal's popstate listener mistook the leaving modal's back() for a genuine user Back and closed itself instantly. It also left the history pointer behind the surviving entry, so a later browser Back walked off the app. Replace the eager per-mount push/pop with a single shared popstate handler plus a coalesced microtask that reconciles history depth to the live overlay count once per tick. A handoff's -1/+1 nets to zero, so the new modal simply reuses the old entry -- no churn, no flash, and Back closes the modal in-app.
116 lines
5.1 KiB
TypeScript
116 lines
5.1 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) -------------------------------
|
|
// A SINGLE module-level popstate handler owns the whole overlay stack, and history is kept in
|
|
// sync LAZILY via a coalesced microtask rather than eagerly per mount/unmount. This matters for
|
|
// modal→modal handoffs (e.g. About → Release notes): the leaving modal unmounts and the entering
|
|
// one mounts in the SAME React commit, so within one tick the desired overlay-entry count drops
|
|
// by one then rises by one — a net zero. Reconciling once, after the tick settles, sees that net
|
|
// zero and touches history NOT AT ALL: the new modal simply reuses the old one's entry.
|
|
//
|
|
// The previous eager design pushed on mount and called history.back() on unmount independently.
|
|
// Interleaved during a handoff that produced (a) a flash-and-vanish where the new modal's popstate
|
|
// listener mistook the leaving modal's back() for a genuine user Back and closed itself, and
|
|
// (b) a mangled history where the pointer ended up BEHIND the surviving entry, so a later browser
|
|
// Back walked off the app entirely instead of closing the modal. Coalescing removes both.
|
|
type Overlay = { token: number; cb: () => void };
|
|
const overlayStack: Overlay[] = [];
|
|
let overlayCounter = 0;
|
|
// Overlay history entries we've actually pushed. Reconciled toward overlayStack.length.
|
|
let historyDepth = 0;
|
|
// Programmatic history.back() calls still awaiting their popstate — each must be ignored.
|
|
let pendingProgrammatic = 0;
|
|
let reconcileScheduled = false;
|
|
let listenerInstalled = false;
|
|
|
|
// Push/pop history entries until their count matches the live overlay count. Runs once per tick.
|
|
function reconcileHistory(): void {
|
|
reconcileScheduled = false;
|
|
const want = overlayStack.length;
|
|
while (historyDepth < want) {
|
|
historyDepth++;
|
|
window.history.pushState({ ...window.history.state, _ov: ++overlayCounter }, "");
|
|
}
|
|
while (historyDepth > want) {
|
|
historyDepth--;
|
|
pendingProgrammatic++;
|
|
window.history.back();
|
|
}
|
|
}
|
|
|
|
function scheduleReconcile(): void {
|
|
if (reconcileScheduled) return;
|
|
reconcileScheduled = true;
|
|
queueMicrotask(reconcileHistory);
|
|
}
|
|
|
|
function ensureOverlayListener(): void {
|
|
if (listenerInstalled) return;
|
|
listenerInstalled = true;
|
|
window.addEventListener("popstate", () => {
|
|
if (pendingProgrammatic > 0) {
|
|
pendingProgrammatic--; // our own back(): consume and ignore
|
|
return;
|
|
}
|
|
// Genuine user Back: the browser already dropped one overlay entry, so close the topmost
|
|
// overlay to match. Its unmount cleanup finds itself already off the stack and only re-syncs.
|
|
if (historyDepth > 0) historyDepth--;
|
|
const top = overlayStack.pop();
|
|
if (top) top.cb();
|
|
scheduleReconcile();
|
|
});
|
|
}
|
|
|
|
/** 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(() => {
|
|
ensureOverlayListener();
|
|
const entry: Overlay = { token: ++overlayCounter, cb: () => cb.current() };
|
|
overlayStack.push(entry);
|
|
scheduleReconcile();
|
|
return () => {
|
|
const idx = overlayStack.indexOf(entry);
|
|
// idx === -1 means the shared handler already removed us on a real user Back; either way,
|
|
// just re-sync history to the (possibly unchanged) overlay count on the next tick.
|
|
if (idx !== -1) overlayStack.splice(idx, 1);
|
|
scheduleReconcile();
|
|
};
|
|
}, []);
|
|
}
|