mirror of
https://github.com/koala73/worldmonitor.git
synced 2026-04-25 17:14:57 +02:00
* fix(sw): preserve open modals when tab-hide auto-reload would fire Scenario: a Pro user opens the Clerk sign-in modal, enters their email, and switches to their mail app to fetch the code. If a deploy happens while they wait and the SW update toast's 5 s dwell window has elapsed, `visibilitychange: hidden` triggers `window.location.reload()` — which wipes the Clerk flow, so the code in the inbox is for a now-dead attempt and the user has to re-request. Same failure applies to UnifiedSettings, the ⌘K search modal, story/signal popups, and anything else with modal semantics: leaving the tab = lose your place. Fix: in `sw-update.ts`, the hidden-tab auto-reload now checks for any open modal/dialog via a compound selector (`[aria-modal="true"], [role="dialog"], .modal, .cl-modalBackdrop, dialog[open]`) and suppresses the reload when one matches. Covers Clerk's `.cl-modalBackdrop`, the site-wide `.modal` convention (UnifiedSettings, WidgetChatModal), and any well-authored dialog. The reload stays armed — next tab-hide after the modal closes fires it. Manual "Reload" button click is unaffected (explicit user intent). Over-matching is safe (worst case: user clicks Reload manually). Under-matching keeps the bug, so the selector errs generous. Tests: three new cases cover modal-open suppression, re-arming after modal close, and manual-click bypass. 25/25 sw-update tests pass. Follow-up ticket worth filing: add `aria-modal="true"` + `role="dialog"` to the modals that are missing them (SearchModal, StoryModal, SignalModal, WidgetChatModal, McpConnectModal, MobileWarningModal, CountryIntelModal, UnifiedSettings). That's the proper long-term a11y fix and would let us narrow the selector once coverage is complete. * fix(sw): filter modal guard by actual visibility, not just DOM presence Addresses review feedback on #3184: The previous selector (`[role="dialog"]` etc.) matched the UnifiedSettings overlay, which is created in its constructor at app startup (App.ts:977 → UnifiedSettings.ts:68-71 sets role="dialog") and stays in the DOM for the whole session. That meant auto-reload was effectively disabled for every user, not just those with an actually-open modal. Fix: don't just check for selector matches — check whether the matched element is actually rendered. Persistent modal overlays hide themselves via `display: none` (main.css:6744: `.modal-overlay { display: none }`) and reveal via an `.active` class (main.css:6750: `.active { display: flex }`), so `offsetParent === null` cleanly distinguishes closed from open. We prefer `checkVisibility()` where available (Chrome 105+, Safari 17.4+, Firefox 125+, which covers virtually all current WM users) and fall back to `offsetParent` otherwise. This also handles future modals automatically, without needing us to enumerate every `.xxx-modal-overlay.active` class the site might introduce. New tests: - Modal mounted AND visible → reload suppressed (original Clerk case) - Modal mounted but hidden → reload fires (reviewer's regression case) - Modal visible, then hidden on return → reload fires on next tab-hide - Manual Reload click unaffected in all cases 26/26 sw-update tests pass. * fix(sw): replace offsetParent fallback with getClientRects for fixed overlays Addresses second review finding on #3184: The previous fallback `el.offsetParent !== null` silently failed on every `position: fixed` overlay — which is every modal in this app: - `.modal-overlay` (main.css:6737) — UnifiedSettings, WidgetChatModal - `.story-modal-overlay` (main.css:3442) - `.country-intel-modal-overlay` active state (main.css:18415) MDN: `offsetParent` is specified to return null for any `position: fixed` element, regardless of visibility. So on Firefox <125 or Safari <17.4 (where `Element.checkVisibility()` is unavailable), `isModalOpen` would return false for actually-open modals → auto-reload fires → Clerk sign-in and every other fixed-position flow gets wiped exactly as PR #3184 was meant to prevent. Fix: fall back to `getClientRects().length > 0`. This returns 0 for `display: none` elements (how `.modal-overlay` hides when `.active` is absent) and non-zero for rendered elements, including position:fixed. It's universally supported and matches the semantics we want. New tests exercise the fallback path explicitly with a `supportsCheckVisibility` toggle on the fake env: - visible position:fixed modal + no checkVisibility → reload suppressed - hidden mounted modal + no checkVisibility → reload fires 28/28 sw-update tests pass. * fix(a11y): add role=dialog + aria-modal=true to five missing modals Addresses third review finding on #3184. SW auto-reload guard uses a `[role="dialog"]` selector but five modals were missing the attribute, so `isModalOpen()` returned false and the page could still auto-reload mid-flow on those screens. Broadening the selector to enumerate specific class names was rejected because the app has many non-modal `-overlay` classes (`#deckgl-overlay`, `.conflict-label-overlay`, `.layer-warn-overlay`, `.mobile-menu-overlay`) that would cause false positives and permanently disable auto-reload. Instead, standardize on the existing convention used by UnifiedSettings: every modal overlay sets `role="dialog"` + `aria-modal="true"` at creation. This makes the SW selector work AND improves screen-reader behavior (focus trap, background element suppression). Modals updated: - SearchModal (⌘K search) — both mobile sheet and desktop variants use the same element, single set-attributes call at create time - StoryModal (news story detail) - SignalModal (instability spike detail) - CountryIntelModal (country deep-dive overlay) - MobileWarningModal (mobile device warning) No change to sw-update.ts — the existing selector already covers the newly-attributed elements. All 28 sw-update tests still pass; typecheck clean.
647 lines
23 KiB
TypeScript
647 lines
23 KiB
TypeScript
import { describe, it, beforeEach } from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import { installSwUpdateHandler, OPEN_MODAL_SELECTOR } from '../src/bootstrap/sw-update.ts';
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Fake environment
|
|
// ---------------------------------------------------------------------------
|
|
|
|
interface FakeElement {
|
|
tagName: string;
|
|
className: string;
|
|
innerHTML: string;
|
|
dataset: Record<string, string>;
|
|
_listeners: Record<string, Array<(e: unknown) => void>>;
|
|
_removed: boolean;
|
|
classList: { _classes: Set<string>; add(c: string): void; remove(c: string): void; has(c: string): boolean };
|
|
remove(): void;
|
|
addEventListener(type: string, cb: (e: unknown) => void): void;
|
|
closest(sel: string): { dataset: Record<string, string> } | null;
|
|
checkVisibility?: () => boolean;
|
|
getClientRects?: () => { length: number };
|
|
}
|
|
|
|
interface FakeEnv {
|
|
doc: {
|
|
visibilityState: string;
|
|
setVisibilityState(v: string): void;
|
|
/**
|
|
* Test helpers modeling modal state. Overlays in this app are always
|
|
* `position: fixed`, so `offsetParent` would always be null — the
|
|
* visibility check must use `checkVisibility()` or `getClientRects()`.
|
|
*
|
|
* - modalMounted: element exists in DOM (matches OPEN_MODAL_SELECTOR
|
|
* on query). Maps to UnifiedSettings, SignalModal, etc. — mounted in
|
|
* their constructor at app startup and left in the DOM for the whole
|
|
* session.
|
|
* - modalVisible: the mounted element is actually rendered
|
|
* (checkVisibility() returns true / getClientRects().length > 0).
|
|
* - supportsCheckVisibility: test knob. When false, the fake element
|
|
* omits `checkVisibility` so the code path exercises the
|
|
* `getClientRects` fallback (simulates Firefox <125 / Safari <17.4).
|
|
*/
|
|
modalMounted: boolean;
|
|
modalVisible: boolean;
|
|
supportsCheckVisibility: boolean;
|
|
_removedListeners: Array<() => void>;
|
|
querySelector(sel: string): FakeElement | null;
|
|
querySelectorAll(sel: string): Iterable<FakeElement>;
|
|
createElement(tag: string): FakeElement;
|
|
body: {
|
|
appendChild(el: FakeElement): void;
|
|
contains(el: FakeElement | null): boolean;
|
|
};
|
|
addEventListener(type: string, cb: () => void): void;
|
|
removeEventListener(type: string, cb: () => void): void;
|
|
};
|
|
swContainer: {
|
|
_controller: object | null;
|
|
readonly controller: object | null;
|
|
addEventListener(type: string, cb: () => void): void;
|
|
fireControllerChange(): void;
|
|
};
|
|
reload: () => void;
|
|
reloadCalls: number[];
|
|
appendedToasts: FakeElement[];
|
|
visibilityListeners: Array<() => void>;
|
|
/** Pending dwell-timer callbacks. Each entry is the cb passed to setTimer (or a no-op if cleared). */
|
|
pendingTimers: Array<() => void>;
|
|
}
|
|
|
|
function makeEnv(): FakeEnv {
|
|
const visibilityListeners: Array<() => void> = [];
|
|
const appendedToasts: FakeElement[] = [];
|
|
const pendingTimers: Array<() => void> = [];
|
|
let _visibilityState = 'visible';
|
|
|
|
const doc: FakeEnv['doc'] = {
|
|
get visibilityState() { return _visibilityState; },
|
|
setVisibilityState(v: string) { _visibilityState = v; },
|
|
modalMounted: false,
|
|
modalVisible: false,
|
|
supportsCheckVisibility: true,
|
|
_removedListeners: [],
|
|
|
|
querySelector(sel: string): FakeElement | null {
|
|
if (sel === '.update-toast') return appendedToasts.at(-1) ?? null;
|
|
return null;
|
|
},
|
|
|
|
querySelectorAll(sel: string): Iterable<FakeElement> {
|
|
if (sel !== OPEN_MODAL_SELECTOR) return [];
|
|
if (!this.modalMounted && !this.modalVisible) return [];
|
|
const isVisible = this.modalVisible;
|
|
const el: FakeElement = {
|
|
tagName: 'DIV',
|
|
className: '',
|
|
innerHTML: '',
|
|
dataset: {},
|
|
_listeners: {},
|
|
_removed: false,
|
|
classList: {
|
|
_classes: new Set<string>(),
|
|
add() {}, remove() {}, has() { return false; },
|
|
},
|
|
remove() {},
|
|
addEventListener() {},
|
|
closest() { return null; },
|
|
// getClientRects is always available in real DOM; mirrors `display: none`
|
|
// semantics (empty list when hidden, non-empty when rendered — including
|
|
// `position: fixed` elements, unlike offsetParent).
|
|
getClientRects: () => ({ length: isVisible ? 1 : 0 }),
|
|
};
|
|
if (this.supportsCheckVisibility) {
|
|
el.checkVisibility = () => isVisible;
|
|
}
|
|
return [el];
|
|
},
|
|
|
|
createElement(_tag: string): FakeElement {
|
|
const el: FakeElement = {
|
|
tagName: _tag.toUpperCase(),
|
|
className: '',
|
|
innerHTML: '',
|
|
dataset: {},
|
|
_listeners: {},
|
|
_removed: false,
|
|
classList: {
|
|
_classes: new Set<string>(),
|
|
add(c) { this._classes.add(c); },
|
|
remove(c) { this._classes.delete(c); },
|
|
has(c) { return this._classes.has(c); },
|
|
},
|
|
remove() { this._removed = true; },
|
|
addEventListener(type: string, cb: (e: unknown) => void) {
|
|
this._listeners[type] ??= [];
|
|
this._listeners[type].push(cb);
|
|
},
|
|
closest(sel: string) {
|
|
if (sel === '[data-action]') return null; // overridden per-click in clickToastButton
|
|
return null;
|
|
},
|
|
};
|
|
return el;
|
|
},
|
|
|
|
body: {
|
|
appendChild(el: FakeElement) { appendedToasts.push(el); },
|
|
contains(el: FakeElement | null): boolean {
|
|
return el != null && !el._removed && appendedToasts.includes(el);
|
|
},
|
|
},
|
|
|
|
addEventListener(type: string, cb: () => void) {
|
|
if (type === 'visibilitychange') visibilityListeners.push(cb);
|
|
},
|
|
removeEventListener(type: string, cb: () => void) {
|
|
if (type === 'visibilitychange') {
|
|
const i = visibilityListeners.indexOf(cb);
|
|
if (i !== -1) visibilityListeners.splice(i, 1);
|
|
doc._removedListeners.push(cb);
|
|
}
|
|
},
|
|
};
|
|
|
|
const swListeners: Array<() => void> = [];
|
|
const swContainer: FakeEnv['swContainer'] = {
|
|
_controller: null,
|
|
get controller() { return this._controller; },
|
|
addEventListener(type: string, cb: () => void) {
|
|
if (type === 'controllerchange') swListeners.push(cb);
|
|
},
|
|
fireControllerChange() {
|
|
for (const cb of [...swListeners]) cb();
|
|
},
|
|
};
|
|
|
|
const reloadCalls: number[] = [];
|
|
const reload = () => reloadCalls.push(Date.now());
|
|
|
|
return { doc, swContainer, reload, reloadCalls, appendedToasts, visibilityListeners, pendingTimers };
|
|
}
|
|
|
|
function install(env: FakeEnv) {
|
|
installSwUpdateHandler({
|
|
swContainer: env.swContainer,
|
|
document: env.doc,
|
|
reload: env.reload,
|
|
raf: (cb) => cb(), // synchronous — skips real rAF
|
|
setTimer: (cb, _ms) => {
|
|
const idx = env.pendingTimers.length;
|
|
env.pendingTimers.push(cb);
|
|
return idx as unknown as ReturnType<typeof setTimeout>;
|
|
},
|
|
clearTimer: (_id) => {
|
|
const idx = _id as unknown as number;
|
|
if (idx !== null && idx >= 0 && idx < env.pendingTimers.length) {
|
|
env.pendingTimers.splice(idx, 1);
|
|
}
|
|
},
|
|
});
|
|
}
|
|
|
|
/** Simulate tab visibility change (e.g. going to background). */
|
|
function fireVisibility(env: FakeEnv) {
|
|
for (const cb of [...env.visibilityListeners]) cb();
|
|
}
|
|
|
|
/**
|
|
* Fire the next pending dwell timer (simulates VISIBLE_DWELL_MS elapsing).
|
|
* If the timer was cleared (dismiss/reload), the no-op is harmless.
|
|
*/
|
|
function fireDwellTimer(env: FakeEnv) {
|
|
const cb = env.pendingTimers.shift();
|
|
assert.ok(cb !== undefined, 'No pending dwell timer to fire');
|
|
cb();
|
|
}
|
|
|
|
/** Simulate a button click inside the latest toast. */
|
|
function clickToastButton(env: FakeEnv, action: string) {
|
|
const toast = env.appendedToasts.at(-1);
|
|
assert.ok(toast, 'No toast found');
|
|
const fakeTarget = {
|
|
closest(sel: string) {
|
|
if (sel === '[data-action]') return { dataset: { action } };
|
|
return null;
|
|
},
|
|
};
|
|
for (const cb of toast._listeners['click'] ?? []) {
|
|
(cb as (e: { target: typeof fakeTarget }) => void)({ target: fakeTarget });
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Tests
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe('installSwUpdateHandler', () => {
|
|
let env: FakeEnv;
|
|
beforeEach(() => { env = makeEnv(); });
|
|
|
|
// --- first-visit skip -------------------------------------------------------
|
|
|
|
it('does not show a toast on the first controllerchange (no prior controller)', () => {
|
|
env.swContainer._controller = null;
|
|
install(env);
|
|
env.swContainer.fireControllerChange();
|
|
assert.equal(env.appendedToasts.length, 0);
|
|
});
|
|
|
|
it('shows a toast on controllerchange when a controller was already active', () => {
|
|
env.swContainer._controller = {};
|
|
install(env);
|
|
env.swContainer.fireControllerChange();
|
|
assert.equal(env.appendedToasts.length, 1);
|
|
});
|
|
|
|
// --- reload button ----------------------------------------------------------
|
|
|
|
it('calls reload when the Reload button is clicked', () => {
|
|
env.swContainer._controller = {};
|
|
install(env);
|
|
env.swContainer.fireControllerChange();
|
|
clickToastButton(env, 'reload');
|
|
assert.equal(env.reloadCalls.length, 1);
|
|
});
|
|
|
|
// --- dismiss button ---------------------------------------------------------
|
|
|
|
it('does not call reload when dismiss is clicked', () => {
|
|
env.swContainer._controller = {};
|
|
install(env);
|
|
env.swContainer.fireControllerChange();
|
|
clickToastButton(env, 'dismiss');
|
|
assert.equal(env.reloadCalls.length, 0);
|
|
});
|
|
|
|
it('removes the visibilitychange listener when dismiss is clicked', () => {
|
|
env.swContainer._controller = {};
|
|
install(env);
|
|
env.swContainer.fireControllerChange();
|
|
assert.ok(env.visibilityListeners.length > 0, 'expected a listener after toast shown');
|
|
clickToastButton(env, 'dismiss');
|
|
assert.equal(env.visibilityListeners.length, 0);
|
|
});
|
|
|
|
// --- hidden-tab auto-reload (requires dwell) --------------------------------
|
|
|
|
it('calls reload when tab goes hidden after dwell timer elapses', () => {
|
|
env.swContainer._controller = {};
|
|
install(env);
|
|
env.swContainer.fireControllerChange(); // visible → dwell timer starts
|
|
fireDwellTimer(env); // 5 s elapsed → autoReloadAllowed = true
|
|
env.doc.setVisibilityState('hidden');
|
|
fireVisibility(env);
|
|
assert.equal(env.reloadCalls.length, 1);
|
|
});
|
|
|
|
it('does NOT call reload when tab goes hidden before dwell timer fires', () => {
|
|
env.swContainer._controller = {};
|
|
install(env);
|
|
env.swContainer.fireControllerChange(); // visible → dwell timer pending
|
|
// Do NOT call fireDwellTimer — autoReloadAllowed stays false
|
|
env.doc.setVisibilityState('hidden');
|
|
fireVisibility(env);
|
|
assert.equal(env.reloadCalls.length, 0, 'no reload before dwell elapses');
|
|
});
|
|
|
|
it('does NOT call reload when tab goes hidden after dismiss', () => {
|
|
env.swContainer._controller = {};
|
|
install(env);
|
|
env.swContainer.fireControllerChange();
|
|
clickToastButton(env, 'dismiss');
|
|
env.doc.setVisibilityState('hidden');
|
|
fireVisibility(env);
|
|
assert.equal(env.reloadCalls.length, 0);
|
|
});
|
|
|
|
// --- dwell timer start/cancel mechanics -------------------------------------
|
|
|
|
it('starts dwell timer when toast appears while tab is visible', () => {
|
|
env.swContainer._controller = {};
|
|
install(env);
|
|
env.swContainer.fireControllerChange();
|
|
assert.equal(env.pendingTimers.length, 1, 'dwell timer queued on visible toast');
|
|
});
|
|
|
|
it('does NOT start dwell timer when toast appears while tab is hidden', () => {
|
|
env.swContainer._controller = {};
|
|
install(env);
|
|
env.doc.setVisibilityState('hidden');
|
|
env.swContainer.fireControllerChange();
|
|
assert.equal(env.pendingTimers.length, 0, 'no dwell timer when tab already hidden');
|
|
});
|
|
|
|
it('starts dwell timer when tab returns to visible after a hidden-tab toast', () => {
|
|
env.swContainer._controller = {};
|
|
install(env);
|
|
env.doc.setVisibilityState('hidden');
|
|
env.swContainer.fireControllerChange();
|
|
assert.equal(env.pendingTimers.length, 0, 'no timer while hidden');
|
|
|
|
env.doc.setVisibilityState('visible');
|
|
fireVisibility(env); // onHidden sees visible → startDwellTimer
|
|
assert.equal(env.pendingTimers.length, 1, 'dwell timer started on return to visible');
|
|
});
|
|
|
|
// --- PRIMARY: multi-deploy same-tab scenario --------------------------------
|
|
|
|
it('shows a new toast for deploy N+1 after deploy N was dismissed', () => {
|
|
env.swContainer._controller = {};
|
|
install(env);
|
|
|
|
// Deploy N
|
|
env.swContainer.fireControllerChange();
|
|
assert.equal(env.appendedToasts.length, 1, 'toast shown for deploy N');
|
|
|
|
// User dismisses deploy N
|
|
clickToastButton(env, 'dismiss');
|
|
assert.equal(env.reloadCalls.length, 0, 'no reload on dismiss');
|
|
|
|
// Deploy N+1
|
|
env.swContainer.fireControllerChange();
|
|
assert.equal(env.appendedToasts.length, 2, 'new toast shown for deploy N+1');
|
|
});
|
|
|
|
it('hidden-tab fallback fires for deploy N+1 after deploy N was dismissed', () => {
|
|
env.swContainer._controller = {};
|
|
install(env);
|
|
|
|
// Deploy N — dismiss (dwell timer cleared)
|
|
env.swContainer.fireControllerChange();
|
|
clickToastButton(env, 'dismiss');
|
|
assert.equal(env.reloadCalls.length, 0);
|
|
|
|
// Deploy N+1 — dwell then hide
|
|
env.swContainer.fireControllerChange();
|
|
assert.equal(env.appendedToasts.length, 2, 'new toast shown for N+1');
|
|
|
|
fireDwellTimer(env); // 5 s visible → autoReloadAllowed
|
|
env.doc.setVisibilityState('hidden');
|
|
fireVisibility(env);
|
|
assert.equal(env.reloadCalls.length, 1, 'reload fires on hidden after N+1 toast');
|
|
});
|
|
|
|
it('hidden-tab fallback does NOT fire when both N and N+1 toasts were dismissed', () => {
|
|
env.swContainer._controller = {};
|
|
install(env);
|
|
|
|
env.swContainer.fireControllerChange();
|
|
clickToastButton(env, 'dismiss');
|
|
|
|
env.swContainer.fireControllerChange();
|
|
clickToastButton(env, 'dismiss');
|
|
|
|
env.doc.setVisibilityState('hidden');
|
|
fireVisibility(env);
|
|
assert.equal(env.reloadCalls.length, 0, 'no reload — both toasts dismissed');
|
|
});
|
|
|
|
// --- P1 regression: hidden time must not count toward dwell ----------------
|
|
|
|
it('does NOT reload when dwell timer fires after the tab went hidden (background tick)', () => {
|
|
env.swContainer._controller = {};
|
|
install(env);
|
|
env.swContainer.fireControllerChange(); // visible → dwell starts
|
|
|
|
// Tab hides before dwell completes — timer should be cancelled
|
|
env.doc.setVisibilityState('hidden');
|
|
fireVisibility(env);
|
|
assert.equal(env.pendingTimers.length, 0, 'dwell timer cancelled on hide');
|
|
|
|
// Tab stays hidden — no reload
|
|
fireVisibility(env);
|
|
assert.equal(env.reloadCalls.length, 0, 'no reload — dwell never completed');
|
|
});
|
|
|
|
it('requires a full fresh dwell after hide/show cycle before auto-reload', () => {
|
|
env.swContainer._controller = {};
|
|
install(env);
|
|
env.swContainer.fireControllerChange(); // visible → dwell starts
|
|
|
|
// Hide at "1 s" — cancels dwell
|
|
env.doc.setVisibilityState('hidden');
|
|
fireVisibility(env);
|
|
assert.equal(env.pendingTimers.length, 0, 'dwell cancelled on hide');
|
|
|
|
// Return to visible — new dwell starts
|
|
env.doc.setVisibilityState('visible');
|
|
fireVisibility(env);
|
|
assert.equal(env.pendingTimers.length, 1, 'fresh dwell timer started on return');
|
|
|
|
// Complete the new dwell → autoReloadAllowed
|
|
fireDwellTimer(env);
|
|
|
|
// Hide → auto-reload fires
|
|
env.doc.setVisibilityState('hidden');
|
|
fireVisibility(env);
|
|
assert.equal(env.reloadCalls.length, 1, 'reload fires only after full dwell completes');
|
|
});
|
|
|
|
// --- P2 regression: stale dwell timer cleared when newer deploy supersedes --
|
|
|
|
it('cancels the previous dwell timer when a newer deploy supersedes the toast', () => {
|
|
env.swContainer._controller = {};
|
|
install(env);
|
|
|
|
// Deploy N: visible → dwell timer starts
|
|
env.swContainer.fireControllerChange();
|
|
assert.equal(env.pendingTimers.length, 1, 'N dwell timer queued');
|
|
|
|
// Deploy N+1: supersedes toast → old dwell must be cancelled
|
|
env.swContainer.fireControllerChange();
|
|
assert.equal(env.pendingTimers.length, 1, 'exactly one dwell timer active (N+1 only)');
|
|
});
|
|
|
|
// --- visible-transition must NOT reload (P1 regression guard) ---------------
|
|
|
|
it('does NOT reload when visibilitychange fires while state is still visible', () => {
|
|
env.swContainer._controller = {};
|
|
install(env);
|
|
env.swContainer.fireControllerChange();
|
|
fireDwellTimer(env);
|
|
// tab stays visible — fire visibilitychange anyway (e.g. focus events on some browsers)
|
|
env.doc.setVisibilityState('visible');
|
|
fireVisibility(env);
|
|
assert.equal(env.reloadCalls.length, 0);
|
|
});
|
|
|
|
it('does NOT reload when tab goes hidden then returns to visible', () => {
|
|
env.swContainer._controller = {};
|
|
install(env);
|
|
env.swContainer.fireControllerChange();
|
|
fireDwellTimer(env); // dwell elapsed → autoReloadAllowed
|
|
|
|
// go hidden → should reload
|
|
env.doc.setVisibilityState('hidden');
|
|
fireVisibility(env);
|
|
assert.equal(env.reloadCalls.length, 1);
|
|
|
|
// now visible would not add a second reload
|
|
env.doc.setVisibilityState('visible');
|
|
fireVisibility(env);
|
|
assert.equal(env.reloadCalls.length, 1, 'no second reload on visible transition');
|
|
});
|
|
|
|
// --- background-loop prevention (infinite reload bug fix) -------------------
|
|
|
|
it('does NOT auto-reload when update fires while tab is already hidden', () => {
|
|
env.swContainer._controller = {};
|
|
install(env);
|
|
|
|
// Tab is in the background when the SW update activates
|
|
env.doc.setVisibilityState('hidden');
|
|
env.swContainer.fireControllerChange();
|
|
|
|
// visibilitychange fires but tab is still hidden — must NOT reload (prevents infinite loop)
|
|
fireVisibility(env);
|
|
assert.equal(env.reloadCalls.length, 0, 'no auto-reload when already in background');
|
|
});
|
|
|
|
it('allows auto-reload after user returns to the tab that received a background update', () => {
|
|
env.swContainer._controller = {};
|
|
install(env);
|
|
|
|
// Update fires while hidden
|
|
env.doc.setVisibilityState('hidden');
|
|
env.swContainer.fireControllerChange();
|
|
fireVisibility(env);
|
|
assert.equal(env.reloadCalls.length, 0, 'no reload yet — tab still hidden');
|
|
|
|
// User returns to the tab — dwell timer starts
|
|
env.doc.setVisibilityState('visible');
|
|
fireVisibility(env);
|
|
assert.equal(env.reloadCalls.length, 0, 'no reload on becoming visible');
|
|
|
|
// Dwell elapses → autoReloadAllowed = true
|
|
fireDwellTimer(env);
|
|
|
|
// User switches away — auto-reload is now allowed
|
|
env.doc.setVisibilityState('hidden');
|
|
fireVisibility(env);
|
|
assert.equal(env.reloadCalls.length, 1, 'reload fires when user switches away after seeing toast');
|
|
});
|
|
|
|
// --- modal-open guard (preserves Clerk sign-in, Settings, etc.) ------------
|
|
|
|
it('does NOT auto-reload when a modal is visibly open while the tab goes hidden', () => {
|
|
env.swContainer._controller = {};
|
|
install(env);
|
|
env.swContainer.fireControllerChange();
|
|
fireDwellTimer(env); // autoReloadAllowed = true
|
|
|
|
// Simulate e.g. Clerk sign-in modal: mounted AND visible
|
|
env.doc.modalMounted = true;
|
|
env.doc.modalVisible = true;
|
|
|
|
env.doc.setVisibilityState('hidden');
|
|
fireVisibility(env);
|
|
assert.equal(env.reloadCalls.length, 0, 'reload suppressed while modal is visibly open');
|
|
});
|
|
|
|
it('DOES auto-reload when a modal is mounted-but-hidden (persistent dialog case)', () => {
|
|
// Regression for the reviewer-flagged bug: UnifiedSettings mounts in its
|
|
// constructor with role="dialog" and stays in the DOM forever, but hides
|
|
// via `display: none` when .active is not set. A naive selector match
|
|
// would permanently disable auto-reload. The visibility filter fixes this.
|
|
env.swContainer._controller = {};
|
|
install(env);
|
|
env.swContainer.fireControllerChange();
|
|
fireDwellTimer(env);
|
|
|
|
env.doc.modalMounted = true; // dialog element exists in DOM
|
|
env.doc.modalVisible = false; // but it's hidden (display:none, no .active)
|
|
|
|
env.doc.setVisibilityState('hidden');
|
|
fireVisibility(env);
|
|
assert.equal(env.reloadCalls.length, 1, 'reload fires when mounted dialog is not actually visible');
|
|
});
|
|
|
|
it('auto-reloads on the NEXT tab-hide after the modal is closed (hidden)', () => {
|
|
env.swContainer._controller = {};
|
|
install(env);
|
|
env.swContainer.fireControllerChange();
|
|
fireDwellTimer(env);
|
|
|
|
// First hide with modal visibly open — suppressed
|
|
env.doc.modalMounted = true;
|
|
env.doc.modalVisible = true;
|
|
env.doc.setVisibilityState('hidden');
|
|
fireVisibility(env);
|
|
assert.equal(env.reloadCalls.length, 0);
|
|
|
|
// User returns, closes modal (mounted stays true, visible goes false),
|
|
// then switches tabs again
|
|
env.doc.setVisibilityState('visible');
|
|
fireVisibility(env);
|
|
env.doc.modalVisible = false;
|
|
env.doc.setVisibilityState('hidden');
|
|
fireVisibility(env);
|
|
assert.equal(env.reloadCalls.length, 1, 'reload fires on next hide after modal hidden');
|
|
});
|
|
|
|
it('manual Reload button click still works while a modal is open', () => {
|
|
env.swContainer._controller = {};
|
|
install(env);
|
|
env.swContainer.fireControllerChange();
|
|
|
|
env.doc.modalMounted = true;
|
|
env.doc.modalVisible = true;
|
|
clickToastButton(env, 'reload');
|
|
assert.equal(env.reloadCalls.length, 1, 'explicit click bypasses modal guard');
|
|
});
|
|
|
|
// --- fallback path (engines without Element.checkVisibility) ---------------
|
|
|
|
it('uses getClientRects() fallback to detect visible position:fixed modals', () => {
|
|
// Regression for the reviewer-flagged fallback bug: offsetParent would
|
|
// return null for every `position: fixed` overlay even when visible, so
|
|
// on Firefox <125 / Safari <17.4 the modal guard would false-negative
|
|
// and auto-reload would wipe the Clerk sign-in. getClientRects() does
|
|
// not have this flaw.
|
|
env.swContainer._controller = {};
|
|
install(env);
|
|
env.swContainer.fireControllerChange();
|
|
fireDwellTimer(env);
|
|
|
|
env.doc.supportsCheckVisibility = false; // simulate older engine
|
|
env.doc.modalMounted = true;
|
|
env.doc.modalVisible = true; // fixed-position overlay, visible
|
|
|
|
env.doc.setVisibilityState('hidden');
|
|
fireVisibility(env);
|
|
assert.equal(env.reloadCalls.length, 0, 'reload suppressed via getClientRects fallback');
|
|
});
|
|
|
|
it('fallback path still allows reload when a mounted modal is hidden', () => {
|
|
env.swContainer._controller = {};
|
|
install(env);
|
|
env.swContainer.fireControllerChange();
|
|
fireDwellTimer(env);
|
|
|
|
env.doc.supportsCheckVisibility = false;
|
|
env.doc.modalMounted = true;
|
|
env.doc.modalVisible = false; // display:none → getClientRects().length === 0
|
|
|
|
env.doc.setVisibilityState('hidden');
|
|
fireVisibility(env);
|
|
assert.equal(env.reloadCalls.length, 1, 'reload fires when fallback reports not-rendered');
|
|
});
|
|
|
|
// --- listener leak regression -----------------------------------------------
|
|
|
|
it('removes the previous visibilitychange handler when a newer deploy replaces the toast', () => {
|
|
env.swContainer._controller = {};
|
|
install(env);
|
|
|
|
// Deploy N — show toast, do NOT dismiss
|
|
env.swContainer.fireControllerChange();
|
|
assert.equal(env.visibilityListeners.length, 1, 'one listener after deploy N');
|
|
|
|
// Deploy N+1 — replaces toast
|
|
env.swContainer.fireControllerChange();
|
|
assert.equal(env.visibilityListeners.length, 1, 'still exactly one listener after N+1');
|
|
assert.ok(env.doc._removedListeners.length > 0, 'old listener was explicitly removed');
|
|
});
|
|
});
|