fix(notifications): restore background push notifications and improve SW session recovery#671
fix(notifications): restore background push notifications and improve SW session recovery#671Just-Insane wants to merge 24 commits intoSableClient:devfrom
Conversation
…t fallback Matrix access tokens are long-lived and only invalidated on logout or server revocation. The previous 60s TTL caused iOS push handlers (which restart the SW per push) to reject cached sessions as stale, resulting in generic 'New Message' notifications. Also adds a requestSessionWithTimeout fallback in handleMinimalPushPayload that asks live window clients for a fresh session when neither the in-memory map nor the persisted cache contains a usable session.
…ssion from push handler When phase3AdaptiveBackoffJitter is enabled, successful foreground/focus session pushes (phase1ForegroundResync) now reset heartbeatFailuresRef to 0. Previously a period of SW controller absence (e.g. SW update) could inflate the heartbeat interval to its maximum (30 min) even after the SW became healthy again, reducing session-refresh frequency below the intended 10-minute rate. Also captures the loadPersistedSession() result in onPushNotification and assigns it to preloadedSession, avoiding a redundant second cache read in handleMinimalPushPayload when the SW is restarted by iOS for a push event.
When iOS backgrounds the PWA, the WKWebView JS thread can be frozen before visibilitychange fires. This leaves appIsVisible stuck at true and clients.matchAll() returning a stale 'visible' state — both signals stale simultaneously — causing the dual AND gate to wrongly suppress push notifications for backgrounded apps. Replace the stale-flag check with checkLiveVisibility(): ping each window client via postMessage and require a response within 500 ms to confirm the app is genuinely in the foreground. A frozen/backgrounded page cannot respond, so the timeout causes checkLiveVisibility to return false and the notification is shown correctly. The encrypted-event path already uses this pattern (requestDecryptionFromClient acts as the live check) and is unaffected. Also added the matching checkVisibility/visibilityCheckResult message pair to HandleDecryptPushEvent so the page can respond to the new ping.
There was a problem hiding this comment.
Pull request overview
This PR improves service worker (SW) session recovery and media authentication behavior to reduce missed/incorrect push notifications and 401s when the SW restarts or a tab is unavailable, and adds an app↔SW “live visibility” handshake to decide when to suppress OS notifications.
Changes:
- Persist SW session with a timestamp and enforce a 24h TTL; warm SW
preloadedSessionduring push handling. - Add SW↔client live visibility ping and use it to suppress OS notifications only when a tab confirms it’s visible.
- Expand authenticated media interception and add retry logic to recover from token refresh races.
Reviewed changes
Copilot reviewed 4 out of 4 changed files in this pull request and generated 8 comments.
| File | Description |
|---|---|
| src/sw.ts | Session persistence/TTL, push visibility ping, media auth path expansion, media fetch retry, and Workbox precache call change. |
| src/app/pages/client/ClientNonUIFeatures.tsx | Responds to SW visibility pings from the client page. |
| src/app/hooks/useAppVisibility.ts | Adds foreground/focus/heartbeat-driven session push behavior (plus experiment/config wiring). |
| .changeset/sw-push-session-recovery.md | Changeset entry for the SW reliability fix. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| const age = typeof s.persistedAt === 'number' ? Date.now() - s.persistedAt : Infinity; | ||
| const MAX_SESSION_AGE_MS = 24 * 60 * 60 * 1000; // 24 hours | ||
| if (age > MAX_SESSION_AGE_MS) { | ||
| console.debug('[SW] loadPersistedSession: session expired', { | ||
| age, | ||
| accessToken: s.accessToken.slice(0, 8), | ||
| }); |
There was a problem hiding this comment.
loadPersistedSession() no longer validates that the cached JSON has string accessToken/baseUrl before using them. As written, a corrupted cache entry can throw (e.g. s.accessToken.slice(...)) and/or propagate non-string values into fetch/auth, and the debug log leaks part of the access token. Please restore strict type checks (and avoid logging token material) before returning a SessionInfo.
| const checkId = `vis-${Date.now()}-${idx}`; | ||
|
|
||
| const promise = new Promise<boolean>((resolve) => { | ||
| visibilityCheckPendingMap.set(checkId, resolve); | ||
| }); |
There was a problem hiding this comment.
checkLiveVisibility() builds checkId from Date.now() + idx, which can collide across overlapping calls (e.g. multiple push events close together), overwriting entries in visibilityCheckPendingMap and resolving the wrong promise. Use a collision-resistant id (e.g. crypto.randomUUID() or a monotonic counter) for the map key.
| const result = await Promise.race( | ||
| Array.from(windowClients).map((c) => requestSessionWithTimeout(c.id, 1500)) | ||
| ); | ||
| session = result ?? undefined; |
There was a problem hiding this comment.
In handleMinimalPushPayload(), using Promise.race over per-client requestSessionWithTimeout calls can return undefined as soon as the first client responds with no session (or a fast failure), even if another client would return a valid session shortly after. This defeats the intended fallback behavior; prefer selecting the first defined session (e.g. Promise.all + find, or a Promise.any-style helper that ignores undefined).
| const result = await Promise.race( | |
| Array.from(windowClients).map((c) => requestSessionWithTimeout(c.id, 1500)) | |
| ); | |
| session = result ?? undefined; | |
| const results = await Promise.all( | |
| Array.from(windowClients).map((c) => requestSessionWithTimeout(c.id, 1500)) | |
| ); | |
| session = results.find((result) => result != null) ?? undefined; |
| // Capture the persisted session result into preloadedSession so that | ||
| // getAnyStoredSession() returns it in handleMinimalPushPayload without a | ||
| // second cache read. | ||
| const [, persistedSession, clients] = await Promise.all([ |
There was a problem hiding this comment.
The comment says persisting the session into preloadedSession makes getAnyStoredSession() return it, but getAnyStoredSession() only reads from the sessions Map. As a result, handleMinimalPushPayload() will still re-read the cache (and won’t benefit from preloadedSession). Either update the comment or wire preloadedSession into getAnyStoredSession()/handleMinimalPushPayload() so the warm-up actually avoids the extra cache read.
| if (self.__WB_MANIFEST) { | ||
| precacheAndRoute(self.__WB_MANIFEST); | ||
| } | ||
| precacheAndRoute(self.__WB_MANIFEST); |
There was a problem hiding this comment.
precacheAndRoute(self.__WB_MANIFEST) is now unguarded. If __WB_MANIFEST is undefined in any build mode (e.g. dev, tests, or if injectManifest fails), this will throw at SW startup. Safer is to keep the guard or default to an empty array when the manifest isn’t injected.
| precacheAndRoute(self.__WB_MANIFEST); | |
| precacheAndRoute(self.__WB_MANIFEST ?? []); |
| if (data.type === 'checkVisibility') { | ||
| const { id } = data as { id: string }; | ||
| navigator.serviceWorker.controller?.postMessage({ | ||
| type: 'visibilityCheckResult', | ||
| id, | ||
| visible: document.visibilityState === 'visible', | ||
| }); | ||
| return; |
There was a problem hiding this comment.
The visibility ping response uses navigator.serviceWorker.controller?.postMessage(...). Since the SW uses clients.matchAll({ includeUncontrolled: true }), some visible clients may be uncontrolled and have controller === null, so they’ll never respond and the SW will incorrectly treat the app as not visible. Consider posting via (await navigator.serviceWorker.ready).active (or the registration) when controller is null so uncontrolled-but-visible tabs can respond.
| import { useClientConfig, useExperimentVariant } from './useClientConfig'; | ||
| import { useSetting } from '../state/hooks/settings'; | ||
| import { settingsAtom } from '../state/settings'; |
There was a problem hiding this comment.
useAppVisibility imports useExperimentVariant from ./useClientConfig, but src/app/hooks/useClientConfig.ts does not export it, and ClientConfig also doesn’t declare the sessionSync property used below. This file will not typecheck/compile as-is; either add the missing export/types to useClientConfig.ts or remove/guard the experiment + sessionSync usage here.
| 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); |
There was a problem hiding this comment.
useAppVisibility now relies on activeSession (baseUrl/accessToken/userId) for pushSessionNow/heartbeat, but no call sites pass it (e.g. ClientRoot still calls useAppVisibility(mx) only). With activeSession undefined, the new session-sync logic will always be skipped and the backoff reset/heartbeat won’t take effect. Either make activeSession required or update the callers to pass the current session.
| 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); | |
| function getActiveSessionFromClient(mx: MatrixClient | undefined): Session | undefined { | |
| if (!mx) return undefined; | |
| const baseUrl = mx.getHomeserverUrl?.(); | |
| const accessToken = mx.getAccessToken?.(); | |
| const userId = mx.getUserId?.(); | |
| if (!baseUrl || !accessToken || !userId) return undefined; | |
| return { | |
| baseUrl, | |
| accessToken, | |
| userId, | |
| } as Session; | |
| } | |
| export function useAppVisibility(mx: MatrixClient | undefined, activeSession?: Session) { | |
| const clientConfig = useClientConfig(); | |
| const [usePushNotifications] = useSetting(settingsAtom, 'usePushNotifications'); | |
| const pushSubAtom = useAtom(pushSubscriptionAtom); | |
| const isMobile = mobileOrTablet(); | |
| const resolvedActiveSession = activeSession ?? getActiveSessionFromClient(mx); | |
| const sessionSyncConfig = clientConfig.sessionSync; | |
| const sessionSyncVariant = useExperimentVariant('sessionSyncStrategy', resolvedActiveSession?.userId); |
The postMessage round-trip ping (checkLiveVisibility) introduced a new race: iOS can background the app without immediately freezing the JS thread, so the page can still respond 'visible' in the brief window before the freeze — causing the notification to be suppressed. client.visibilityState from clients.matchAll() is updated by the browser engine when the OS signals a visibility transition, independently of the page JS thread, making it immune to this race. When matchAll() returns zero clients (an iOS Safari PWA quirk) we default to showing the notification rather than silently dropping it. Removes checkLiveVisibility(), visibilityCheckPendingMap, the visibilityCheckResult message handler, and the checkVisibility handler in ClientNonUIFeatures.
…ntVariant to useClientConfig
…chAll() visibilityState
…ndler Restores the dual-signal visibility check in the service worker (appIsVisible flag OR clients.matchAll visibilityState) and the setAppVisible message handler. Also restores the visibilitychange listener in ClientNonUIFeatures that posts visibility state to the SW. These were removed in f79b75e which broke background notification delivery, particularly on iOS Safari where clients.matchAll() can return stale results after SW suspension.
…imers 1. appEvents.ts: Replace single-callback onVisibilityChange/onVisibilityHidden slots with Set-based multi-subscriber pattern. Subscriptions return an unsubscribe function, preventing silent overwrites. 2. useAppVisibility.ts: Update to use emitVisibilityChange/emitVisibilityHidden for dispatching and onVisibilityChange() subscription for togglePusher. 3. BackgroundNotifications.tsx: Track retry setTimeout IDs in a Set and cancel them on effect cleanup, preventing orphaned background clients on unmount.
…e handler - sw.ts: add type validation in loadPersistedSession before accessing fields - sw.ts: remove access token leak from debug log - sw.ts: replace Promise.race with Promise.all+find in handleMinimalPushPayload to avoid returning undefined from first fast-failing client - sw.ts: fix misleading comment about preloadedSession/getAnyStoredSession - sw.ts: add ?? [] fallback for precacheAndRoute(self.__WB_MANIFEST) - ClientRoot: pass activeSession to useAppVisibility - index.tsx: add controllerchange listener to re-push session when SW updates via skipWaiting — fixes notifications stopping after SW replacement
…e handler - sw.ts: add type validation in loadPersistedSession before accessing fields - sw.ts: remove access token leak from debug log - sw.ts: replace Promise.race with Promise.all+find in handleMinimalPushPayload to avoid returning undefined from first fast-failing client - sw.ts: fix misleading comment about preloadedSession/getAnyStoredSession - sw.ts: add ?? [] fallback for precacheAndRoute(self.__WB_MANIFEST) - ClientRoot: pass activeSession to useAppVisibility - index.tsx: add controllerchange listener to re-push session when SW updates via skipWaiting — fixes notifications stopping after SW replacement
…e handler - sw.ts: add type validation in loadPersistedSession before accessing fields - sw.ts: remove access token leak from debug log - sw.ts: replace Promise.race with Promise.all+find in handleMinimalPushPayload to avoid returning undefined from first fast-failing client - sw.ts: fix misleading comment about preloadedSession/getAnyStoredSession - sw.ts: add ?? [] fallback for precacheAndRoute(self.__WB_MANIFEST) - ClientRoot: pass activeSession to useAppVisibility - index.tsx: add controllerchange listener to re-push session when SW updates via skipWaiting — fixes notifications stopping after SW replacement
5ecd51f to
350a54a
Compare
…e handler - sw.ts: add type validation in loadPersistedSession before accessing fields - sw.ts: remove access token leak from debug log - sw.ts: replace Promise.race with Promise.all+find in handleMinimalPushPayload to avoid returning undefined from first fast-failing client - sw.ts: fix misleading comment about preloadedSession/getAnyStoredSession - sw.ts: add ?? [] fallback for precacheAndRoute(self.__WB_MANIFEST) - ClientRoot: pass activeSession to useAppVisibility - index.tsx: add controllerchange listener to re-push session when SW updates via skipWaiting — fixes notifications stopping after SW replacement
iOS PWA freezes the page thread before visibilitychange fires, leaving appIsVisible stuck at true and suppressing push notifications. Replace the unreliable OR of appIsVisible / matchAll().visibilityState with a live checkVisibility round-trip: the SW posts a ping to every window client and only suppresses if a client confirms visible within 500 ms. Frozen or killed pages cannot respond, so the timeout resolves false and the OS notification fires correctly.
… on this branch Removes imports and usages of usePresenceAutoIdle, presenceAutoIdledAtom, useInitBookmarks, presenceMode setting, and presenceAutoIdleTimeoutMs config that were accidentally merged from other feature branches but don't exist on fix/sw-push-session-recovery. Restores PresenceFeature to upstream dev shape.
…atchAll The checkLiveVisibility approach (postMessage ping with 500ms timeout) was causing false-positive suppression on iOS: the push event itself can briefly wake a suspended page, allowing it to respond with visibilityState='visible' even when the user is not looking at the app. This caused background notifications to silently stop after a period of inactivity. Revert to upstream/dev's approach: OR of appIsVisible flag (set via visibilitychange listener) and clients.matchAll() visibilityState. Remove the checkLiveVisibility function, visibilityCheckPendingMap, and the client-side checkVisibility responder.
- Fix preloadedSession comment: only media fetch handlers use it, not handleMinimalPushPayload - Fix changeset frontmatter: '@sable/client': patch → default: patch
…Visibility Removes the Session dependency from useAppVisibility by deriving the userId directly from the MatrixClient instance.
retryImmediately() is a no-op on SlidingSyncSdk — it returns true without touching the polling loop. Call slidingSync.resend() on foreground/focus to abort a stale long-poll and start a fresh one. Also fixes activeSession references that should use mx methods (getHomeserverUrl/getAccessToken/getUserId).
…dling - Use getEffectiveEvent()?.type for decrypted event type in BackgroundNotifications - Fix isEncryptedRoom flag in pushNotification.ts (was hardcoded false) - Add isEncryptedRoom: true to relay payload when decryption succeeds - Wrap push handlers in try/catch with fallback notifications (prevents silent drops on iOS) - Parallelize requestDecryptionFromClient with Promise.any + shared timeout (was sequential)
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…cations Add sessionSync.phase1ForegroundResync and phase2VisibleHeartbeat to config.json so the service worker session stays fresh on iOS. Without these flags useAppVisibility disables both foreground resync (phase1) and the 10-min visible heartbeat (phase2), leaving the CacheStorage session to age out after 24 h with no refresh. When iOS kills the SW while backgrounded and the session has gone stale, push decryption fails and notifications are silently dropped. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
onPushNotification already fetches the persisted session and stores it in preloadedSession. Thread that through handleMinimalPushPayload's fallback chain so we skip the second cache.match() call on iOS restarts where the in-memory sessions Map is empty. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
When backgrounded, the service worker manages the badge from push payloads. The app's local unread state may be stale before sync catches up, causing the badge to flash on then immediately off. Guard clearAppBadge() with a visibility check so the SW badge persists. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Description
Restores background push notification delivery and hardens the service worker's session management. Notifications were silently dropped when the app was backgrounded because two upstream mechanisms — the
appIsVisibleflag in the SW andtogglePusheron visibility changes — had been removed.Notification & visibility fixes
appIsVisibleflag andsetAppVisiblehandler insw.ts— the SW now tracks whether the app is visible via messages from the main thread, used alongsideclients.matchAll()visibilityState as a dual-signal check before suppressing push notifications.setAppVisiblefrom the main thread inClientNonUIFeatures.tsx— bridges document visibility changes to the service worker.togglePusheron visibility changes inuseAppVisibility.ts— re-registers the push endpoint when the app returns to the foreground, ensuring the homeserver can deliver pushes.Service worker session recovery
requestSessionWithTimeoutfallback —Promise.racewrapper so the SW falls back to its cachedpreloadedSessionif the main client is unresponsive, instead of hanging.preloadedSessionfrom push handler — the session fetched during push processing is stored for reuse within the same SW lifetime.Robustness improvements
appEvents.ts→ Set-based multi-subscriber pattern — replaces single-callback slots withSet-backed subscriptions that return unsubscribe functions, preventing silent overwrites when multiple consumers subscribe.BackgroundNotifications.tsxretry timer cleanup — trackssetTimeoutIDs in aSetand cancels them on effect cleanup, preventing orphaned background clients on unmount.Experiment / session-sync config types
useClientConfig.ts— addsExperimentConfig,SessionSyncConfig, andExperimentSelectiontypes plusselectExperimentVariant/useExperimentVarianthooks for feature-flag gating.DM sidebar fixes
RoomNavItem.tsx— fix room topic/status description sticking to the wrong row by replacinguseRoomTopicwith a per-roomgetStateEvent+useEffect.useUserPresence.ts— simplify null-safety with optional chaining on theUserobject.DirectDMsList.tsx— usegetDirectRoomAvatarUrldirectly instead of falling throughgetRoomAvatarUrlfirst.Fixes #
Type of change
Checklist:
AI disclosure:
The notification/visibility restoration (
appIsVisibleflag,setAppVisiblehandler,togglePusherreconnection), theappEventsmulti-subscriber refactor, and theBackgroundNotificationsretry timer cleanup were developed with AI assistance. The session recovery improvements (requestSessionWithTimeout, backoff reset, TTL increase) were drafted with AI assistance and reviewed against the existing SW session-handshake protocol. The experiment config types, DM sidebar fixes, and presence cleanup were developed with AI assistance.