'use client' import { useEffect, useRef } from 'react' /** * Closes an overlay when the user presses the browser/Android Back button, * without changing the visible URL. * * How it works * - On open: `history.pushState` (same path/search) so Back targets this overlay. * - On Back (`popstate`): call `onRequestClose` (do not `history.back` again). * - On programmatic close: `history.back()` once and ignore that `popstate`. * - Nested overlays share a module stack; Back always dismisses the topmost entry. * - When `allowClose` is false, Back is trapped by re-pushing the history entry. */ const HISTORY_STATE_KEY = 'consumerModalHistory' interface HistoryBackEntry { id: number onBack: () => void canClose: () => boolean } let nextId = 1 const stack: HistoryBackEntry[] = [] let ignorePopCount = 0 let listenerAttached = false function onPopState() { if (ignorePopCount > 0) { ignorePopCount -= 1 return } const top = stack[stack.length - 1] if (!top) return if (!top.canClose()) { // Browser already consumed the entry — restore so the overlay keeps Back trapped. window.history.pushState({ [HISTORY_STATE_KEY]: top.id }, '') return } stack.pop() top.onBack() } function ensureListener() { if (listenerAttached || typeof window === 'undefined') return window.addEventListener('popstate', onPopState) listenerAttached = true } function register(entry: HistoryBackEntry) { ensureListener() stack.push(entry) window.history.pushState({ [HISTORY_STATE_KEY]: entry.id }, '') } function unregister(entry: HistoryBackEntry) { const idx = stack.indexOf(entry) if (idx === -1) { // Already removed by user Back return } // Parent closed while nested children still registered — drop this entry and above. const removeCount = stack.length - idx stack.splice(idx, removeCount) if (typeof window === 'undefined' || removeCount === 0) return ignorePopCount += removeCount for (let i = 0; i < removeCount; i += 1) { window.history.back() } } /** Clears module stack between tests (jsdom shares the page history). */ export function resetCloseOnHistoryBackForTests() { stack.length = 0 ignorePopCount = 0 nextId = 1 } export interface UseCloseOnHistoryBackOptions { isOpen: boolean /** When false, no history entry is pushed. Default true. */ enabled?: boolean /** * When false while open, Back does not close — the history entry is re-pushed. * Use for `!isDismissable` / busy submit states. */ allowClose?: boolean onRequestClose: () => void } /** * Syncs overlay open state with the session history so Back dismisses the overlay. */ export function useCloseOnHistoryBack({ isOpen, enabled = true, allowClose = true, onRequestClose }: UseCloseOnHistoryBackOptions) { const onRequestCloseRef = useRef(onRequestClose) const allowCloseRef = useRef(allowClose) useEffect(() => { onRequestCloseRef.current = onRequestClose allowCloseRef.current = allowClose }, [allowClose, onRequestClose]) useEffect(() => { if (!isOpen || !enabled || typeof window === 'undefined') return const entry: HistoryBackEntry = { id: nextId++, onBack: () => { onRequestCloseRef.current() }, canClose: () => allowCloseRef.current, } register(entry) return () => { unregister(entry) } }, [enabled, isOpen]) }