From 03a9453b870cac199fa48f3d8764f2e5e7c0e072 Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Mon, 6 Apr 2026 12:39:23 -0400 Subject: [PATCH 1/8] feat(session-sync): phased SW resync with experiment-ready config --- config.json | 19 +++ src/app/hooks/useAppVisibility.ts | 199 +++++++++++++++++++++++++++- src/app/hooks/useClientConfig.ts | 10 ++ src/app/pages/client/ClientRoot.tsx | 2 +- 4 files changed, 225 insertions(+), 5 deletions(-) diff --git a/config.json b/config.json index f0c3c8b61..d5b80c74a 100644 --- a/config.json +++ b/config.json @@ -15,10 +15,29 @@ "settingsLinkBaseUrl": "https://app.sable.moe", + "experiments": { + "sessionSyncStrategy": { + "enabled": false, + "rolloutPercentage": 0, + "controlVariant": "control", + "variants": ["session-sync-heartbeat", "session-sync-adaptive"] + } + }, + "slidingSync": { "enabled": true }, + "sessionSync": { + "phase1ForegroundResync": false, + "phase2VisibleHeartbeat": false, + "phase3AdaptiveBackoffJitter": false, + "foregroundDebounceMs": 1500, + "heartbeatIntervalMs": 600000, + "resumeHeartbeatSuppressMs": 60000, + "heartbeatMaxBackoffMs": 1800000 + }, + "featuredCommunities": { "openAsDefault": false, "spaces": [ diff --git a/src/app/hooks/useAppVisibility.ts b/src/app/hooks/useAppVisibility.ts index 7fd5f2325..e0a09afa0 100644 --- a/src/app/hooks/useAppVisibility.ts +++ b/src/app/hooks/useAppVisibility.ts @@ -1,23 +1,112 @@ -import { useEffect } from 'react'; +import { useCallback, useEffect, useRef } from 'react'; import { MatrixClient } from '$types/matrix-sdk'; +import { Session } from '$state/sessions'; import { useAtom } from 'jotai'; import { togglePusher } from '../features/settings/notifications/PushNotifications'; import { appEvents } from '../utils/appEvents'; -import { useClientConfig } from './useClientConfig'; +import { useClientConfig, useExperimentVariant } from './useClientConfig'; import { useSetting } from '../state/hooks/settings'; import { settingsAtom } from '../state/settings'; import { pushSubscriptionAtom } from '../state/pushSubscription'; import { mobileOrTablet } from '../utils/user-agent'; import { createDebugLogger } from '../utils/debugLogger'; +import { pushSessionToSW } from '../../sw-session'; const debugLog = createDebugLogger('AppVisibility'); -export function useAppVisibility(mx: MatrixClient | undefined) { +const DEFAULT_FOREGROUND_DEBOUNCE_MS = 1500; +const DEFAULT_HEARTBEAT_INTERVAL_MS = 10 * 60 * 1000; +const DEFAULT_RESUME_HEARTBEAT_SUPPRESS_MS = 60 * 1000; +const DEFAULT_HEARTBEAT_MAX_BACKOFF_MS = 30 * 60 * 1000; + +export function useAppVisibility(mx: MatrixClient | undefined, activeSession?: Session) { const clientConfig = useClientConfig(); const [usePushNotifications] = useSetting(settingsAtom, 'usePushNotifications'); const pushSubAtom = useAtom(pushSubscriptionAtom); const isMobile = mobileOrTablet(); + const sessionSyncConfig = clientConfig.sessionSync; + const sessionSyncVariant = useExperimentVariant('sessionSyncStrategy', activeSession?.userId); + + // Derive phase flags from experiment variant; fall back to direct config when not in experiment. + const inSessionSync = sessionSyncVariant.inExperiment; + const syncVariant = sessionSyncVariant.variant; + const phase1ForegroundResync = inSessionSync + ? syncVariant === 'session-sync-heartbeat' || syncVariant === 'session-sync-adaptive' + : sessionSyncConfig?.phase1ForegroundResync === true; + const phase2VisibleHeartbeat = inSessionSync + ? syncVariant === 'session-sync-heartbeat' || syncVariant === 'session-sync-adaptive' + : sessionSyncConfig?.phase2VisibleHeartbeat === true; + const phase3AdaptiveBackoffJitter = inSessionSync + ? syncVariant === 'session-sync-adaptive' + : sessionSyncConfig?.phase3AdaptiveBackoffJitter === true; + + const foregroundDebounceMs = Math.max( + 0, + sessionSyncConfig?.foregroundDebounceMs ?? DEFAULT_FOREGROUND_DEBOUNCE_MS + ); + const heartbeatIntervalMs = Math.max( + 1000, + sessionSyncConfig?.heartbeatIntervalMs ?? DEFAULT_HEARTBEAT_INTERVAL_MS + ); + const resumeHeartbeatSuppressMs = Math.max( + 0, + sessionSyncConfig?.resumeHeartbeatSuppressMs ?? DEFAULT_RESUME_HEARTBEAT_SUPPRESS_MS + ); + const heartbeatMaxBackoffMs = Math.max( + heartbeatIntervalMs, + sessionSyncConfig?.heartbeatMaxBackoffMs ?? DEFAULT_HEARTBEAT_MAX_BACKOFF_MS + ); + + const lastForegroundPushAtRef = useRef(0); + const suppressHeartbeatUntilRef = useRef(0); + const heartbeatFailuresRef = useRef(0); + + const pushSessionNow = useCallback( + (reason: 'foreground' | 'focus' | 'heartbeat'): 'sent' | 'skipped' => { + const baseUrl = activeSession?.baseUrl; + const accessToken = activeSession?.accessToken; + const userId = activeSession?.userId; + const canPush = + !!mx && + typeof baseUrl === 'string' && + typeof accessToken === 'string' && + typeof userId === 'string' && + 'serviceWorker' in navigator && + !!navigator.serviceWorker.controller; + + if (!canPush) { + debugLog.warn('network', 'Skipped SW session sync', { + reason, + hasClient: !!mx, + hasBaseUrl: !!baseUrl, + hasAccessToken: !!accessToken, + hasUserId: !!userId, + hasSwController: !!navigator.serviceWorker?.controller, + }); + return 'skipped'; + } + + pushSessionToSW(baseUrl, accessToken, userId); + debugLog.info('network', 'Pushed session to SW', { + reason, + phase1ForegroundResync, + phase2VisibleHeartbeat, + phase3AdaptiveBackoffJitter, + }); + return 'sent'; + }, + [ + activeSession?.accessToken, + activeSession?.baseUrl, + activeSession?.userId, + mx, + phase1ForegroundResync, + phase2VisibleHeartbeat, + phase3AdaptiveBackoffJitter, + ] + ); + useEffect(() => { const handleVisibilityChange = () => { const isVisible = document.visibilityState === 'visible'; @@ -29,15 +118,56 @@ export function useAppVisibility(mx: MatrixClient | undefined) { appEvents.onVisibilityChange?.(isVisible); if (!isVisible) { appEvents.onVisibilityHidden?.(); + return; + } + + if (!phase1ForegroundResync) return; + + const now = Date.now(); + if (now - lastForegroundPushAtRef.current < foregroundDebounceMs) return; + lastForegroundPushAtRef.current = now; + + if ( + pushSessionNow('foreground') === 'sent' && + phase3AdaptiveBackoffJitter && + phase2VisibleHeartbeat + ) { + suppressHeartbeatUntilRef.current = now + resumeHeartbeatSuppressMs; + } + }; + + const handleFocus = () => { + if (!phase1ForegroundResync) return; + if (document.visibilityState !== 'visible') return; + + const now = Date.now(); + if (now - lastForegroundPushAtRef.current < foregroundDebounceMs) return; + lastForegroundPushAtRef.current = now; + + if ( + pushSessionNow('focus') === 'sent' && + phase3AdaptiveBackoffJitter && + phase2VisibleHeartbeat + ) { + suppressHeartbeatUntilRef.current = now + resumeHeartbeatSuppressMs; } }; document.addEventListener('visibilitychange', handleVisibilityChange); + window.addEventListener('focus', handleFocus); return () => { document.removeEventListener('visibilitychange', handleVisibilityChange); + window.removeEventListener('focus', handleFocus); }; - }, []); + }, [ + foregroundDebounceMs, + phase1ForegroundResync, + phase2VisibleHeartbeat, + phase3AdaptiveBackoffJitter, + pushSessionNow, + resumeHeartbeatSuppressMs, + ]); useEffect(() => { if (!mx) return; @@ -52,4 +182,65 @@ export function useAppVisibility(mx: MatrixClient | undefined) { appEvents.onVisibilityChange = null; }; }, [mx, clientConfig, usePushNotifications, pushSubAtom, isMobile]); + + useEffect(() => { + if (!phase2VisibleHeartbeat) return undefined; + + // Reset adaptive backoff/suppression so a config or session change starts fresh. + heartbeatFailuresRef.current = 0; + suppressHeartbeatUntilRef.current = 0; + + let timeoutId: number | undefined; + + const getDelayMs = (): number => { + let delay = heartbeatIntervalMs; + + if (phase3AdaptiveBackoffJitter) { + const failures = heartbeatFailuresRef.current; + const backoffFactor = Math.min(2 ** failures, heartbeatMaxBackoffMs / heartbeatIntervalMs); + delay = Math.min(heartbeatMaxBackoffMs, Math.round(heartbeatIntervalMs * backoffFactor)); + + // Add +-20% jitter to avoid synchronized heartbeat spikes across many clients. + const jitter = 0.8 + Math.random() * 0.4; + delay = Math.max(1000, Math.round(delay * jitter)); + } + + return delay; + }; + + const tick = () => { + const now = Date.now(); + + if (document.visibilityState !== 'visible' || !navigator.onLine) { + timeoutId = window.setTimeout(tick, getDelayMs()); + return; + } + + if (phase3AdaptiveBackoffJitter && now < suppressHeartbeatUntilRef.current) { + timeoutId = window.setTimeout(tick, getDelayMs()); + return; + } + + const result = pushSessionNow('heartbeat'); + if (phase3AdaptiveBackoffJitter) { + // Only reset on a successful send; 'skipped' (prerequisites not ready) + // should not grow the backoff — those aren't push failures. + if (result === 'sent') heartbeatFailuresRef.current = 0; + } + + timeoutId = window.setTimeout(tick, getDelayMs()); + }; + + timeoutId = window.setTimeout(tick, getDelayMs()); + + return () => { + if (timeoutId !== undefined) window.clearTimeout(timeoutId); + }; + }, [ + heartbeatIntervalMs, + heartbeatMaxBackoffMs, + phase2VisibleHeartbeat, + phase3AdaptiveBackoffJitter, + pushSessionNow, + ]); } diff --git a/src/app/hooks/useClientConfig.ts b/src/app/hooks/useClientConfig.ts index e523f15a7..46020c88e 100644 --- a/src/app/hooks/useClientConfig.ts +++ b/src/app/hooks/useClientConfig.ts @@ -43,6 +43,16 @@ export type ClientConfig = { matrixToBaseUrl?: string; settingsLinkBaseUrl?: string; + + sessionSync?: { + phase1ForegroundResync?: boolean; + phase2VisibleHeartbeat?: boolean; + phase3AdaptiveBackoffJitter?: boolean; + foregroundDebounceMs?: number; + heartbeatIntervalMs?: number; + resumeHeartbeatSuppressMs?: number; + heartbeatMaxBackoffMs?: number; + }; }; const ClientConfigContext = createContext(null); diff --git a/src/app/pages/client/ClientRoot.tsx b/src/app/pages/client/ClientRoot.tsx index 1a653e950..0d41d5ecf 100644 --- a/src/app/pages/client/ClientRoot.tsx +++ b/src/app/pages/client/ClientRoot.tsx @@ -254,7 +254,7 @@ export function ClientRoot({ children }: ClientRootProps) { useSyncNicknames(mx); useLogoutListener(mx); - useAppVisibility(mx); + useAppVisibility(mx, activeSession); useEffect( () => () => { From 9329b90f6ae9061e7ce1037ccb0b83d33870f6f5 Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Mon, 6 Apr 2026 12:39:23 -0400 Subject: [PATCH 2/8] chore: add changeset for sw-session-resync-flags --- .changeset/sw-session-resync-flags.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/sw-session-resync-flags.md diff --git a/.changeset/sw-session-resync-flags.md b/.changeset/sw-session-resync-flags.md new file mode 100644 index 000000000..a35d36b6d --- /dev/null +++ b/.changeset/sw-session-resync-flags.md @@ -0,0 +1,5 @@ +--- +default: minor +--- + +Add phased service-worker session re-sync controls (foreground resync, visible heartbeat, adaptive backoff/jitter). From 82da60df074c4f1d526800937772231cc1d866fc Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Thu, 9 Apr 2026 09:10:31 -0400 Subject: [PATCH 3/8] fix(session-sync): kick sync retry on foreground, fix backoff counter, pass userId to SW --- src/app/hooks/useAppVisibility.ts | 21 +++++++++++++++++---- src/app/pages/client/ClientRoot.tsx | 4 ++-- 2 files changed, 19 insertions(+), 6 deletions(-) diff --git a/src/app/hooks/useAppVisibility.ts b/src/app/hooks/useAppVisibility.ts index e0a09afa0..8bfe4f9d9 100644 --- a/src/app/hooks/useAppVisibility.ts +++ b/src/app/hooks/useAppVisibility.ts @@ -121,6 +121,10 @@ export function useAppVisibility(mx: MatrixClient | undefined, activeSession?: S return; } + // Always kick the sync loop on foreground regardless of phase flags — + // the SDK may be sitting in exponential backoff after iOS froze the tab. + mx?.retryImmediately(); + if (!phase1ForegroundResync) return; const now = Date.now(); @@ -137,9 +141,13 @@ export function useAppVisibility(mx: MatrixClient | undefined, activeSession?: S }; const handleFocus = () => { - if (!phase1ForegroundResync) return; if (document.visibilityState !== 'visible') return; + // Always kick the sync loop on focus for the same reason as above. + mx?.retryImmediately(); + + if (!phase1ForegroundResync) return; + const now = Date.now(); if (now - lastForegroundPushAtRef.current < foregroundDebounceMs) return; lastForegroundPushAtRef.current = now; @@ -162,6 +170,7 @@ export function useAppVisibility(mx: MatrixClient | undefined, activeSession?: S }; }, [ foregroundDebounceMs, + mx, phase1ForegroundResync, phase2VisibleHeartbeat, phase3AdaptiveBackoffJitter, @@ -223,9 +232,13 @@ export function useAppVisibility(mx: MatrixClient | undefined, activeSession?: S const result = pushSessionNow('heartbeat'); if (phase3AdaptiveBackoffJitter) { - // Only reset on a successful send; 'skipped' (prerequisites not ready) - // should not grow the backoff — those aren't push failures. - if (result === 'sent') heartbeatFailuresRef.current = 0; + if (result === 'sent') { + heartbeatFailuresRef.current = 0; + } else { + // 'skipped' means prerequisites (SW controller, session) aren't ready. + // Treat as a transient failure so backoff grows until the SW is ready. + heartbeatFailuresRef.current += 1; + } } timeoutId = window.setTimeout(tick, getDelayMs()); diff --git a/src/app/pages/client/ClientRoot.tsx b/src/app/pages/client/ClientRoot.tsx index 0d41d5ecf..c313c4688 100644 --- a/src/app/pages/client/ClientRoot.tsx +++ b/src/app/pages/client/ClientRoot.tsx @@ -203,7 +203,7 @@ export function ClientRoot({ children }: ClientRootProps) { log.log('initClient for', activeSession.userId); const newMx = await initClient(activeSession); loadedUserIdRef.current = activeSession.userId; - pushSessionToSW(activeSession.baseUrl, activeSession.accessToken); + pushSessionToSW(activeSession.baseUrl, activeSession.accessToken, activeSession.userId); return newMx; }, [activeSession, activeSessionId, setActiveSessionId]) ); @@ -232,7 +232,7 @@ export function ClientRoot({ children }: ClientRootProps) { activeSession.userId, '— reloading client' ); - pushSessionToSW(activeSession.baseUrl, activeSession.accessToken); + pushSessionToSW(activeSession.baseUrl, activeSession.accessToken, activeSession.userId); if (mx?.clientRunning) { stopClient(mx); } From 7b39e184702bcc30f6c98f3a2a7e738207dffc2b Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Thu, 9 Apr 2026 18:04:09 -0400 Subject: [PATCH 4/8] fix(notifications): prevent stale appIsVisible from suppressing push Add pagehide listener alongside visibilitychange so the SW gets a visible:false message when the app is backgrounded quickly on iOS Safari before visibilitychange can be delivered. Also add a 30 s TTL on the appIsVisible SW flag so that if the app suspended the JS context before either message reached the SW, the flag expires automatically rather than permanently suppressing incoming push notifications. --- src/app/pages/client/ClientNonUIFeatures.tsx | 15 ++++++++++++++- src/sw.ts | 14 +++++++++++++- 2 files changed, 27 insertions(+), 2 deletions(-) diff --git a/src/app/pages/client/ClientNonUIFeatures.tsx b/src/app/pages/client/ClientNonUIFeatures.tsx index 26ac2f431..d64129246 100644 --- a/src/app/pages/client/ClientNonUIFeatures.tsx +++ b/src/app/pages/client/ClientNonUIFeatures.tsx @@ -644,10 +644,23 @@ function SyncNotificationSettingsWithServiceWorker() { navigator.serviceWorker.ready.then((reg) => reg.active?.postMessage(msg)); }; + const postHidden = () => { + // pagehide fires more reliably than visibilitychange on iOS Safari PWA + // when the user locks the screen or backgrounds the app quickly, making + // it less likely that the SW is left with a stale appIsVisible=true. + const msg = { type: 'setAppVisible', visible: false }; + navigator.serviceWorker.controller?.postMessage(msg); + navigator.serviceWorker.ready.then((reg) => reg.active?.postMessage(msg)); + }; + // Report initial visibility immediately, then track changes. postVisibility(); document.addEventListener('visibilitychange', postVisibility); - return () => document.removeEventListener('visibilitychange', postVisibility); + window.addEventListener('pagehide', postHidden); + return () => { + document.removeEventListener('visibilitychange', postVisibility); + window.removeEventListener('pagehide', postHidden); + }; }, []); useEffect(() => { diff --git a/src/sw.ts b/src/sw.ts index bd09cd8d3..22849bbe6 100644 --- a/src/sw.ts +++ b/src/sw.ts @@ -12,6 +12,13 @@ let notificationSoundEnabled = true; // The clients.matchAll() visibilityState is unreliable on iOS Safari PWA, // so we use this explicit flag as a fallback. let appIsVisible = false; +// Timestamp (Date.now()) of the last time appIsVisible was set to true. +// Used to expire the flag if the app backgrounded before the SW received the +// hidden message (e.g. iOS suspended the JS context mid-visibilitychange). +let appVisibleSetAt = 0; +// If no visible heartbeat has been received in this window, treat the flag as +// stale and do not suppress push notifications. +const APP_VISIBLE_TTL_MS = 30_000; let showMessageContent = false; let showEncryptedMessageContent = false; let clearNotificationsOnRead = false; @@ -571,6 +578,7 @@ self.addEventListener('message', (event: ExtendableMessageEvent) => { if (type === 'setAppVisible') { if (typeof (data as { visible?: unknown }).visible === 'boolean') { appIsVisible = (data as { visible: boolean }).visible; + if (appIsVisible) appVisibleSetAt = Date.now(); } } if (type === 'setNotificationSettings') { @@ -751,8 +759,12 @@ const onPushNotification = async (event: PushEvent) => { // pill notification handles the alert instead. // Combine clients.matchAll() visibility with the explicit appIsVisible flag // because iOS Safari PWA often returns empty or stale results from matchAll(). + // Guard against the flag being stale: if the app was backgrounded quickly and + // the SW never received the hidden message (iOS can suspend the JS context + // before postMessage is processed), the flag expires after APP_VISIBLE_TTL_MS. + const appIsVisibleFresh = appIsVisible && Date.now() - appVisibleSetAt < APP_VISIBLE_TTL_MS; const hasVisibleClient = - appIsVisible || clients.some((client) => client.visibilityState === 'visible'); + appIsVisibleFresh || clients.some((client) => client.visibilityState === 'visible'); console.debug( '[SW push] appIsVisible:', appIsVisible, From 179517c99823fde4dd59ac42f28c334e99b33441 Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Fri, 10 Apr 2026 18:11:25 -0400 Subject: [PATCH 5/8] fix(notifications): prefer matchAll() visibilityState over stale appIsVisible flag The existing TTL guard (APP_VISIBLE_TTL_MS) handles the case where matchAll() returns zero clients. This commit improves the case where matchAll() does return clients: their visibilityState is now used as the authoritative signal rather than being OR-ed with the potentially-stale appIsVisible flag. On iOS Safari PWA the visibilitychange handler may fail to deliver postMessage({ visible: false }) before iOS suspends the JS thread. matchAll() still reports the correct visibilityState:'hidden' for the backgrounded page, so trusting it directly prevents stale appIsVisible=true from suppressing push notifications when the app is backgrounded. --- src/sw.ts | 20 ++++++++++++++------ 1 file changed, 14 insertions(+), 6 deletions(-) diff --git a/src/sw.ts b/src/sw.ts index 22849bbe6..dd5c76b33 100644 --- a/src/sw.ts +++ b/src/sw.ts @@ -757,14 +757,22 @@ const onPushNotification = async (event: PushEvent) => { // If the app is open and visible, skip the OS push notification — the in-app // pill notification handles the alert instead. - // Combine clients.matchAll() visibility with the explicit appIsVisible flag - // because iOS Safari PWA often returns empty or stale results from matchAll(). - // Guard against the flag being stale: if the app was backgrounded quickly and - // the SW never received the hidden message (iOS can suspend the JS context - // before postMessage is processed), the flag expires after APP_VISIBLE_TTL_MS. + // + // Two-tier visibility check: + // 1. When clients.matchAll() returns ≥1 client, trust its visibilityState + // directly. iOS can suspend the JS thread before postMessage({ visible: + // false }) is processed, leaving appIsVisible stuck at true. matchAll() + // still reports the backgrounded client as 'hidden', so it is the + // authoritative signal when available. + // 2. When matchAll() returns zero clients (a separate iOS Safari PWA quirk + // where the page is invisible to the SW even while visible), fall back to + // the TTL-gated flag: the flag expires after APP_VISIBLE_TTL_MS so a stale + // true from a quick background doesn't permanently suppress notifications. const appIsVisibleFresh = appIsVisible && Date.now() - appVisibleSetAt < APP_VISIBLE_TTL_MS; const hasVisibleClient = - appIsVisibleFresh || clients.some((client) => client.visibilityState === 'visible'); + clients.length > 0 + ? clients.some((client) => client.visibilityState === 'visible') + : appIsVisibleFresh; console.debug( '[SW push] appIsVisible:', appIsVisible, From 56690f5a10cb725f9efa681f2a99a6c55e5a7cfb Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Sat, 11 Apr 2026 11:14:20 -0400 Subject: [PATCH 6/8] fix(notifications): never suppress push when clients.matchAll() returns empty MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The TTL-gated appIsVisibleFresh fallback was designed to handle the iOS Safari PWA quirk where clients.matchAll() returns an empty list even when the app is visually open. In practice, the TTL window (30 s) meant any push that arrived within 30 seconds of the app being last visible was silently dropped — including the common case of backgrounding the app and immediately receiving a reply. pagehide + visibilitychange together are now much more reliable at delivering setAppVisible:false before the SW processes the push. When both events DO fail (iOS kills the JS context mid-event), clients.matchAll() ALSO tends to return the backgrounded client as visibilityState:'hidden', which is already handled correctly by the first branch. The clients.length === 0 path is therefore a triple-failure edge case where we simply cannot determine visibility. Erring toward showing the notification (a possible duplicate, handled gracefully by the in-app banner) is always better than silently dropping it. Removes appVisibleSetAt, APP_VISIBLE_TTL_MS, and the stale-flag fallback. --- src/sw.ts | 31 +++++++++++-------------------- 1 file changed, 11 insertions(+), 20 deletions(-) diff --git a/src/sw.ts b/src/sw.ts index dd5c76b33..9895861c6 100644 --- a/src/sw.ts +++ b/src/sw.ts @@ -12,13 +12,6 @@ let notificationSoundEnabled = true; // The clients.matchAll() visibilityState is unreliable on iOS Safari PWA, // so we use this explicit flag as a fallback. let appIsVisible = false; -// Timestamp (Date.now()) of the last time appIsVisible was set to true. -// Used to expire the flag if the app backgrounded before the SW received the -// hidden message (e.g. iOS suspended the JS context mid-visibilitychange). -let appVisibleSetAt = 0; -// If no visible heartbeat has been received in this window, treat the flag as -// stale and do not suppress push notifications. -const APP_VISIBLE_TTL_MS = 30_000; let showMessageContent = false; let showEncryptedMessageContent = false; let clearNotificationsOnRead = false; @@ -578,7 +571,6 @@ self.addEventListener('message', (event: ExtendableMessageEvent) => { if (type === 'setAppVisible') { if (typeof (data as { visible?: unknown }).visible === 'boolean') { appIsVisible = (data as { visible: boolean }).visible; - if (appIsVisible) appVisibleSetAt = Date.now(); } } if (type === 'setNotificationSettings') { @@ -758,21 +750,20 @@ const onPushNotification = async (event: PushEvent) => { // If the app is open and visible, skip the OS push notification — the in-app // pill notification handles the alert instead. // - // Two-tier visibility check: - // 1. When clients.matchAll() returns ≥1 client, trust its visibilityState - // directly. iOS can suspend the JS thread before postMessage({ visible: - // false }) is processed, leaving appIsVisible stuck at true. matchAll() - // still reports the backgrounded client as 'hidden', so it is the - // authoritative signal when available. - // 2. When matchAll() returns zero clients (a separate iOS Safari PWA quirk - // where the page is invisible to the SW even while visible), fall back to - // the TTL-gated flag: the flag expires after APP_VISIBLE_TTL_MS so a stale - // true from a quick background doesn't permanently suppress notifications. - const appIsVisibleFresh = appIsVisible && Date.now() - appVisibleSetAt < APP_VISIBLE_TTL_MS; + // When clients.matchAll() returns ≥1 client, trust its visibilityState + // directly. iOS can suspend the JS thread before postMessage({ visible: + // false }) is processed, leaving appIsVisible stuck at true. matchAll() + // still reports the backgrounded client as 'hidden', so it is the + // authoritative and most reliable signal. + // + // When matchAll() returns zero clients (a separate iOS Safari PWA quirk), + // visibility is unknowable — do NOT suppress. Better to show a duplicate + // (handled gracefully by the in-app banner) than to silently drop a + // notification while the app is backgrounded. const hasVisibleClient = clients.length > 0 ? clients.some((client) => client.visibilityState === 'visible') - : appIsVisibleFresh; + : false; console.debug( '[SW push] appIsVisible:', appIsVisible, From 0152d4fb3eb407bd23e23e9218291d18e77ac518 Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Sun, 12 Apr 2026 15:51:05 -0400 Subject: [PATCH 7/8] fix(session-sync): don't increment heartbeat backoff when SW prerequisites not ready 'skipped' from pushSessionNow means mx/session/SW controller aren't ready yet, not that a real push attempt failed. Incrementing heartbeatFailuresRef in this case caused exponential backoff to build up during app startup, keeping the heartbeat interval large long after prerequisites became available. Only reset failures to 0 on a successful 'sent'; leave count unchanged on 'skipped' so startup latency never inflates the backoff. Resolves Copilot review comment on PR #573. --- src/app/hooks/useAppVisibility.ts | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/src/app/hooks/useAppVisibility.ts b/src/app/hooks/useAppVisibility.ts index 8bfe4f9d9..88c76a315 100644 --- a/src/app/hooks/useAppVisibility.ts +++ b/src/app/hooks/useAppVisibility.ts @@ -233,12 +233,13 @@ export function useAppVisibility(mx: MatrixClient | undefined, activeSession?: S const result = pushSessionNow('heartbeat'); if (phase3AdaptiveBackoffJitter) { if (result === 'sent') { + // Successful push — reset backoff so next interval is the base rate. heartbeatFailuresRef.current = 0; - } else { - // 'skipped' means prerequisites (SW controller, session) aren't ready. - // Treat as a transient failure so backoff grows until the SW is ready. - heartbeatFailuresRef.current += 1; } + // 'skipped' means prerequisites (SW controller, session) aren't ready yet. + // Do NOT increment failures here: the app may simply be starting up and we + // do not want startup latency to drive exponential backoff that persists + // long after the prerequisites become available. } timeoutId = window.setTimeout(tick, getDelayMs()); From ca267ef9e08a8b7a15fe68140d1ae27ff1c35c63 Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Sun, 12 Apr 2026 17:43:05 -0400 Subject: [PATCH 8/8] fix(session-sync): add missing experiment hook types and format sw.ts --- src/app/hooks/useClientConfig.ts | 83 ++++++++++++++++++++++++++++++++ src/sw.ts | 4 +- 2 files changed, 84 insertions(+), 3 deletions(-) diff --git a/src/app/hooks/useClientConfig.ts b/src/app/hooks/useClientConfig.ts index 46020c88e..e8782a4ed 100644 --- a/src/app/hooks/useClientConfig.ts +++ b/src/app/hooks/useClientConfig.ts @@ -5,6 +5,21 @@ export type HashRouterConfig = { basename?: string; }; +export type ExperimentConfig = { + enabled?: boolean; + rolloutPercentage?: number; + variants?: string[]; + controlVariant?: string; +}; + +export type ExperimentSelection = { + key: string; + enabled: boolean; + rolloutPercentage: number; + variant: string; + inExperiment: boolean; +}; + export type ClientConfig = { defaultHomeserver?: number; homeserverList?: string[]; @@ -14,6 +29,8 @@ export type ClientConfig = { disableAccountSwitcher?: boolean; hideUsernamePasswordFields?: boolean; + experiments?: Record; + pushNotificationDetails?: { pushNotifyUrl?: string; vapidPublicKey?: string; @@ -65,6 +82,72 @@ export function useClientConfig(): ClientConfig { return config; } +const DEFAULT_CONTROL_VARIANT = 'control'; + +const normalizeRolloutPercentage = (value?: number): number => { + if (typeof value !== 'number' || Number.isNaN(value)) return 100; + if (value < 0) return 0; + if (value > 100) return 100; + return value; +}; + +const hashToUInt32 = (input: string): number => { + let hash = 0; + for (let index = 0; index < input.length; index += 1) { + hash = (hash * 131 + input.charCodeAt(index)) % 4294967291; + } + return hash; +}; + +export const selectExperimentVariant = ( + key: string, + experiment: ExperimentConfig | undefined, + subjectId: string | undefined +): ExperimentSelection => { + const controlVariant = experiment?.controlVariant ?? DEFAULT_CONTROL_VARIANT; + const variants = (experiment?.variants?.filter((variant) => variant.length > 0) ?? []).filter( + (variant) => variant !== controlVariant + ); + const enabled = experiment?.enabled === true; + const rolloutPercentage = normalizeRolloutPercentage(experiment?.rolloutPercentage); + + if (!enabled || !subjectId || variants.length === 0 || rolloutPercentage === 0) { + return { + key, + enabled, + rolloutPercentage, + variant: controlVariant, + inExperiment: false, + }; + } + + const rolloutBucket = hashToUInt32(`${key}:rollout:${subjectId}`) % 10000; + const rolloutCutoff = Math.floor(rolloutPercentage * 100); + if (rolloutBucket >= rolloutCutoff) { + return { + key, + enabled, + rolloutPercentage, + variant: controlVariant, + inExperiment: false, + }; + } + + const variantIndex = hashToUInt32(`${key}:variant:${subjectId}`) % variants.length; + return { + key, + enabled, + rolloutPercentage, + variant: variants[variantIndex], + inExperiment: true, + }; +}; + +export const useExperimentVariant = (key: string, subjectId?: string): ExperimentSelection => { + const clientConfig = useClientConfig(); + return selectExperimentVariant(key, clientConfig.experiments?.[key], subjectId); +}; + export const clientDefaultServer = (clientConfig: ClientConfig): string => clientConfig.homeserverList?.[clientConfig.defaultHomeserver ?? 0] ?? 'matrix.org'; diff --git a/src/sw.ts b/src/sw.ts index 9895861c6..42ba033da 100644 --- a/src/sw.ts +++ b/src/sw.ts @@ -761,9 +761,7 @@ const onPushNotification = async (event: PushEvent) => { // (handled gracefully by the in-app banner) than to silently drop a // notification while the app is backgrounded. const hasVisibleClient = - clients.length > 0 - ? clients.some((client) => client.visibilityState === 'visible') - : false; + clients.length > 0 ? clients.some((client) => client.visibilityState === 'visible') : false; console.debug( '[SW push] appIsVisible:', appIsVisible,