File size: 10,825 Bytes
fa9c65f
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
import { setTrustedHtml, trustedHtml } from '@/utils/dom-utils';
interface VisibleElementLike {
  checkVisibility?: () => boolean;
  getClientRects?: () => { length: number };
}

interface DocumentLike {
  readonly visibilityState: string;
  querySelector: (sel: string) => Element | null;
  querySelectorAll: (sel: string) => Iterable<Element & VisibleElementLike>;
  createElement: (tag: string) => HTMLElement;
  body: { appendChild: (el: Element) => void; contains: (el: Element | null) => boolean };
  addEventListener: (type: string, cb: () => void) => void;
  removeEventListener: (type: string, cb: () => void) => void;
}

interface ServiceWorkerContainerLike {
  readonly controller: object | null;
  addEventListener: (type: string, cb: () => void) => void;
}

export interface SwUpdateHandlerOptions {
  swContainer?: ServiceWorkerContainerLike;
  document?: DocumentLike;
  reload?: () => void;
  /** Override requestAnimationFrame for testing (defaults to global rAF). */
  raf?: (cb: () => void) => void;
  /** Override setTimeout for testing. */
  setTimer?: (cb: () => void, ms: number) => ReturnType<typeof setTimeout>;
  /** Override clearTimeout for testing. */
  clearTimer?: (id: ReturnType<typeof setTimeout> | null) => void;
  /** Enable debug logging. Defaults to localStorage.getItem('wm-debug-sw') === '1'. */
  debug?: boolean;
  /** App version string included in debug log entries. */
  version?: string;
}

// ---------------------------------------------------------------------------
// Debug logging (opt-in via localStorage.setItem('wm-debug-sw', '1'))
// Persists a rolling 30-entry log in sessionStorage so it survives page reloads.
// Copy with: JSON.parse(sessionStorage.getItem('wm-sw-debug-log'))
// ---------------------------------------------------------------------------

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<string, unknown>): void {
  try {
    const raw = sessionStorage.getItem(SW_DEBUG_LOG_KEY);
    const log = (raw ? JSON.parse(raw) : []) as unknown[];
    log.push(entry);
    if (log.length > SW_DEBUG_LOG_MAX) log.splice(0, log.length - SW_DEBUG_LOG_MAX);
    sessionStorage.setItem(SW_DEBUG_LOG_KEY, JSON.stringify(log));
  } catch {}
}

/**
 * Wires up the SW update toast.
 *
 * On each controllerchange after the first (first = initial claim on a new session),
 * shows a dismissible "Update Available" toast.
 *
 * Auto-reload on tab-hide requires the tab to have been visible for at least
 * VISIBLE_DWELL_MS continuously since the toast appeared. This prevents two failure modes:
 *
 * 1. Background infinite loop: update detected in a hidden tab β†’ onHidden fires
 *    immediately β†’ reload β†’ new page β†’ same β†’ loop forever.
 *
 * 2. Session-restore ghost reload: session-restore briefly marks tabs visible for
 *    one animation frame, which would allow a hidden-tab auto-reload prematurely.
 *
 * Dismissing one version never suppresses toasts for future deploys.
 */
export function installSwUpdateHandler(options: SwUpdateHandlerOptions = {}): void {
  const swContainer = options.swContainer ?? navigator.serviceWorker;
  const doc = options.document ?? (document as unknown as DocumentLike);
  const reload = options.reload ?? (() => window.location.reload());
  const raf = options.raf ?? ((cb: () => void) => requestAnimationFrame(() => requestAnimationFrame(cb)));
  const setTimer = options.setTimer ?? ((cb: () => void, ms: number) => setTimeout(cb, ms));
  const clearTimer = options.clearTimer ?? ((id: ReturnType<typeof setTimeout> | null) => { if (id !== null) clearTimeout(id); });

  const debugEnabled = options.debug ?? (() => {
    try { return localStorage.getItem('wm-debug-sw') === '1'; } catch { return false; }
  })();
  const version = options.version;

  function logSw(event: string, extra: Record<string, unknown> = {}): void {
    if (!debugEnabled) return;
    const entry: Record<string, unknown> = {
      event,
      ts: new Date().toISOString(),
      visibility: doc.visibilityState,
      hasController: !!swContainer.controller,
      ...extra,
    };
    if (version !== undefined) entry.version = version;
    console.log('[SWDEBUG]', entry);
    appendDebugLog(entry);
  }

  // Minimum time the tab must remain visible after the toast appears before
  // auto-reload on tab-hide is enabled.
  const VISIBLE_DWELL_MS = 5_000;

  let currentOnHidden: (() => void) | null = null;
  let currentDwellCancel: (() => void) | null = null;

  const showToast = (): void => {
    if (currentOnHidden) {
      doc.removeEventListener('visibilitychange', currentOnHidden);
      currentOnHidden = null;
    }
    // P2: cancel stale dwell timer from the superseded toast so it cannot
    // fire after the toast is gone (prevents debug log pollution).
    if (currentDwellCancel) {
      currentDwellCancel();
      currentDwellCancel = null;
    }
    doc.querySelector('.update-toast')?.remove();

    const toast = doc.createElement('div');
    toast.className = 'update-toast';
    setTrustedHtml(toast, trustedHtml(`
      <div class="update-toast-icon">
        <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
          <polyline points="23 4 23 10 17 10"/>
          <path d="M20.49 15a9 9 0 1 1-.49-4.9L23 10"/>
        </svg>
      </div>
      <div class="update-toast-body">
        <div class="update-toast-title">Update Available</div>
        <div class="update-toast-detail">A new version is ready.</div>
      </div>
      <button class="update-toast-action" data-action="reload">Reload</button>
      <button class="update-toast-dismiss" data-action="dismiss" aria-label="Dismiss">\u00d7</button>
    `, "legacy direct innerHTML migration"));

    let dismissed = false;
    let autoReloadAllowed = false;
    let dwellTimerId: ReturnType<typeof setTimer> | null = null;

    const startDwellTimer = (): void => {
      if (dwellTimerId !== null || dismissed || autoReloadAllowed) return;
      logSw('dwell-timer-started', { delayMs: VISIBLE_DWELL_MS });
      dwellTimerId = setTimer(() => {
        dwellTimerId = null;
        autoReloadAllowed = true;
        logSw('dwell-timer-expired', { autoReloadAllowed: true });
      }, VISIBLE_DWELL_MS);
    };

    // If already visible when the toast appears, start the dwell timer immediately.
    if (doc.visibilityState === 'visible') startDwellTimer();

    logSw('toast-shown', { wasVisible: doc.visibilityState === 'visible' });

    const onHidden = (): void => {
      if (doc.visibilityState === 'visible') {
        // Tab returned to foreground β€” start dwell timer if not already running.
        logSw('visibility-visible');
        startDwellTimer();
        return;
      }
      // P1: hidden time must not count toward the dwell window β€” cancel the
      // in-flight timer so the full VISIBLE_DWELL_MS restarts on next foreground.
      if (!autoReloadAllowed && dwellTimerId !== null) {
        clearTimer(dwellTimerId);
        dwellTimerId = null;
        logSw('dwell-timer-cancelled-on-hide');
      }
      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();
      }
    };

    toast.addEventListener('click', (e) => {
      const action = (e.target as HTMLElement).closest<HTMLElement>('[data-action]')?.dataset.action;
      if (action === 'reload') {
        clearTimer(dwellTimerId);
        dwellTimerId = null;
        currentDwellCancel = null;
        logSw('reload-clicked');
        reload();
      } else if (action === 'dismiss') {
        clearTimer(dwellTimerId);
        dwellTimerId = null;
        currentDwellCancel = null;
        dismissed = true;
        logSw('dismiss-clicked');
        doc.removeEventListener('visibilitychange', onHidden);
        currentOnHidden = null;
        toast.classList.remove('visible');
        setTimeout(() => toast.remove(), 300);
      }
    });

    currentOnHidden = onHidden;
    currentDwellCancel = () => { clearTimer(dwellTimerId); dwellTimerId = null; };
    doc.addEventListener('visibilitychange', onHidden);
    doc.body.appendChild(toast);
    raf(() => toast.classList.add('visible'));
  };

  let hadController = !!swContainer.controller;
  logSw('handler-installed', { hadController });
  swContainer.addEventListener('controllerchange', () => {
    logSw('controllerchange', { hadController });
    if (!hadController) {
      hadController = true;
      return;
    }
    showToast();
  });
}