diff --git a/src/bootstrap/sw-update.ts b/src/bootstrap/sw-update.ts index b8e20cec5..0eb913bcb 100644 --- a/src/bootstrap/sw-update.ts +++ b/src/bootstrap/sw-update.ts @@ -1,6 +1,12 @@ +interface VisibleElementLike { + checkVisibility?: () => boolean; + getClientRects?: () => { length: number }; +} + interface DocumentLike { readonly visibilityState: string; querySelector: (sel: string) => Element | null; + querySelectorAll: (sel: string) => Iterable; createElement: (tag: string) => HTMLElement; body: { appendChild: (el: Element) => void; contains: (el: Element | null) => boolean }; addEventListener: (type: string, cb: () => void) => void; @@ -37,6 +43,44 @@ export interface SwUpdateHandlerOptions { export const SW_DEBUG_LOG_KEY = 'wm-sw-debug-log'; const SW_DEBUG_LOG_MAX = 30; +// Selectors that identify a modal/dialog candidate. Many site modals mount +// at app startup and stay in the DOM (e.g. UnifiedSettings sets +// role="dialog" in its constructor), so a raw selector match alone would +// permanently disable auto-reload. We only treat a match as "open" when +// the element is actually rendered — see isModalOpen() below. +export const OPEN_MODAL_SELECTOR = + '[aria-modal="true"], [role="dialog"], .cl-modalBackdrop, .modal-overlay, dialog[open]'; + +/** + * Any candidate that's actually visible → a real open modal. + * + * Preferred: `element.checkVisibility()` (Chrome 105+, Safari 17.4+, FF 125+). + * + * Fallback for older engines: `getClientRects().length > 0`. This returns 0 + * when the element has `display: none` (exactly how persistent overlays + * hide — see main.css `.modal-overlay { display: none }` / + * `.active { display: flex }`) and non-zero for rendered elements, + * including `position: fixed` overlays. We cannot use `offsetParent` here: + * MDN specifies it returns `null` for every `position: fixed` element + * regardless of visibility, so it would false-negative on the Story overlay + * (main.css:3442), the active Country Intel overlay (main.css:18415), and + * `.modal-overlay` itself — all of which are fixed-positioned. + */ +function isModalOpen(doc: DocumentLike): boolean { + for (const el of doc.querySelectorAll(OPEN_MODAL_SELECTOR)) { + const checkVisibility = el.checkVisibility; + if (typeof checkVisibility === 'function') { + if (checkVisibility.call(el)) return true; + continue; + } + const getClientRects = el.getClientRects; + if (typeof getClientRects === 'function' && getClientRects.call(el).length > 0) { + return true; + } + } + return false; +} + function appendDebugLog(entry: Record): void { try { const raw = sessionStorage.getItem(SW_DEBUG_LOG_KEY); @@ -163,6 +207,14 @@ export function installSwUpdateHandler(options: SwUpdateHandlerOptions = {}): vo } logSw('visibility-hidden', { autoReloadAllowed, dismissed }); if (!dismissed && autoReloadAllowed && doc.body.contains(toast)) { + // Don't interrupt an in-flight modal flow (Clerk email-code wait, + // Settings, ⌘K search, etc.). The reload stays armed — next tab-hide + // after the modal closes will fire it. User can also click Reload + // in the toast manually at any time. + if (isModalOpen(doc)) { + logSw('auto-reload-suppressed-modal-open'); + return; + } logSw('auto-reload-triggered'); reload(); } diff --git a/src/components/CountryIntelModal.ts b/src/components/CountryIntelModal.ts index 512b579da..a1e807d43 100644 --- a/src/components/CountryIntelModal.ts +++ b/src/components/CountryIntelModal.ts @@ -51,6 +51,8 @@ export class CountryIntelModal { constructor() { this.overlay = document.createElement('div'); this.overlay.className = 'country-intel-overlay'; + this.overlay.setAttribute('role', 'dialog'); + this.overlay.setAttribute('aria-modal', 'true'); this.overlay.innerHTML = `
diff --git a/src/components/MobileWarningModal.ts b/src/components/MobileWarningModal.ts index 2e0a96efc..4bf6b933c 100644 --- a/src/components/MobileWarningModal.ts +++ b/src/components/MobileWarningModal.ts @@ -10,6 +10,8 @@ export class MobileWarningModal { constructor() { this.element = document.createElement('div'); this.element.className = 'mobile-warning-overlay'; + this.element.setAttribute('role', 'dialog'); + this.element.setAttribute('aria-modal', 'true'); this.element.innerHTML = `
diff --git a/src/components/SearchModal.ts b/src/components/SearchModal.ts index cd2b6ab33..2ad83bdc1 100644 --- a/src/components/SearchModal.ts +++ b/src/components/SearchModal.ts @@ -200,6 +200,8 @@ export class SearchModal { private createModal(): void { this.overlay = document.createElement('div'); + this.overlay.setAttribute('role', 'dialog'); + this.overlay.setAttribute('aria-modal', 'true'); if (this.isMobile) { this.overlay.className = 'search-overlay search-mobile'; diff --git a/src/components/SignalModal.ts b/src/components/SignalModal.ts index 6a3fb4e5f..2860914bd 100644 --- a/src/components/SignalModal.ts +++ b/src/components/SignalModal.ts @@ -17,6 +17,8 @@ export class SignalModal { constructor() { this.element = document.createElement('div'); this.element.className = 'signal-modal-overlay'; + this.element.setAttribute('role', 'dialog'); + this.element.setAttribute('aria-modal', 'true'); this.element.innerHTML = `
diff --git a/src/components/StoryModal.ts b/src/components/StoryModal.ts index e6a3d37c7..d46e886e8 100644 --- a/src/components/StoryModal.ts +++ b/src/components/StoryModal.ts @@ -18,6 +18,8 @@ export function openStoryModal(data: StoryData): void { modalEl = document.createElement('div'); modalEl.className = 'story-modal-overlay'; + modalEl.setAttribute('role', 'dialog'); + modalEl.setAttribute('aria-modal', 'true'); modalEl.innerHTML = `