fix(modal): stop Release Notes modal flashing shut on modal-to-modal handoff
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.
This commit is contained in:
parent
ed1b556ea5
commit
1a2700d8fc
1 changed files with 67 additions and 35 deletions
|
|
@ -35,11 +35,65 @@ export function useHistorySubview<T>(root: T): {
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Overlays / modals (one history entry each, nesting-safe) -------------------------------
|
// --- Overlays / modals (one history entry each, nesting-safe) -------------------------------
|
||||||
const overlayStack: number[] = [];
|
// 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;
|
let overlayCounter = 0;
|
||||||
// Set while we pop our OWN entry programmatically (button/ESC close) so the other overlays'
|
// Overlay history entries we've actually pushed. Reconciled toward overlayStack.length.
|
||||||
// popstate handlers don't mistake it for a user Back and close themselves too.
|
let historyDepth = 0;
|
||||||
let suppressPop = false;
|
// 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
|
/** 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. */
|
* `onClose` instead of navigating away. Mount the component only while the overlay is open. */
|
||||||
|
|
@ -47,38 +101,16 @@ export function useBackToClose(onClose: () => void): void {
|
||||||
const cb = useRef(onClose);
|
const cb = useRef(onClose);
|
||||||
cb.current = onClose;
|
cb.current = onClose;
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const token = ++overlayCounter;
|
ensureOverlayListener();
|
||||||
overlayStack.push(token);
|
const entry: Overlay = { token: ++overlayCounter, cb: () => cb.current() };
|
||||||
window.history.pushState({ ...window.history.state, _ov: token }, "");
|
overlayStack.push(entry);
|
||||||
const onPop = () => {
|
scheduleReconcile();
|
||||||
if (suppressPop) {
|
|
||||||
suppressPop = false;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (overlayStack[overlayStack.length - 1] === token) {
|
|
||||||
overlayStack.pop();
|
|
||||||
cb.current();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
window.addEventListener("popstate", onPop);
|
|
||||||
return () => {
|
return () => {
|
||||||
window.removeEventListener("popstate", onPop);
|
const idx = overlayStack.indexOf(entry);
|
||||||
const idx = overlayStack.lastIndexOf(token);
|
// idx === -1 means the shared handler already removed us on a real user Back; either way,
|
||||||
if (idx !== -1) {
|
// just re-sync history to the (possibly unchanged) overlay count on the next tick.
|
||||||
// Closed programmatically (not via Back): drop our own history entry, and tell the
|
if (idx !== -1) overlayStack.splice(idx, 1);
|
||||||
// remaining overlays' handlers to ignore the resulting popstate. A one-shot listener
|
scheduleReconcile();
|
||||||
// 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();
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue