Skip to content

fix(notifications): push notification reliability and reaction context#699

Draft
Just-Insane wants to merge 44 commits intoSableClient:devfrom
Just-Insane:fix/push-notifications
Draft

fix(notifications): push notification reliability and reaction context#699
Just-Insane wants to merge 44 commits intoSableClient:devfrom
Just-Insane:fix/push-notifications

Conversation

@Just-Insane
Copy link
Copy Markdown
Contributor

Summary

Consolidates three push-notification reliability branches. Fixes silent notification drops on mobile (iOS/Android), improves service worker session handling, and ensures reaction notifications include correct room and user context.


Changes

fix/clear-cache-sw-unregister

  • When the user triggers "Clear Cache" in settings, unregister all service workers before clearing. Prevents the SW from serving stale cached assets after a cache wipe.
  • Replaces the conditional if (self.__WB_MANIFEST) guard with precacheAndRoute(self.__WB_MANIFEST ?? []) for Workbox compatibility.

fix/reaction-notification-context

  • Pass room and userId to the reaction notification filter so it can correctly determine whether a reaction notification belongs to the current user.
  • Open joined rooms at the live timeline (bottom) when tapped from a notification, rather than at the last-read marker.
  • Defer the event-scoped jump until the target event is present in the live timeline, preventing a jump to index 0 on rooms with late-loading history.
  • Fix isEncryptedRoom flag in pushNotification.ts (was hardcoded false).

fix/sw-push-session-recovery

  • Session preloading: warm a cached Matrix session in the SW on install/activate so push handlers don't have to wait for a full requestSession round-trip.
  • Reliable mobile push: use getEffectiveEvent()?.type for decrypted event type checks; wrap all push handlers in try/catch with a fallback generic notification to prevent silent drops on iOS.
  • Parallel decryption: replace sequential requestDecryptionFromClient with Promise.any + shared timeout to fan out decryption across multiple open clients simultaneously.
  • Session TTL: increase preloaded session TTL to 24 h; add requestSessionWithTimeout fallback.
  • SW heartbeat: reset heartbeat backoff on foreground sync; kick sliding sync on foreground return.
  • Visibility signals: use clients.matchAll() visibilityState (live) instead of a stored flag to determine whether to suppress a push notification.
  • appEvents multi-subscriber: convert to a multi-subscriber pattern and cancel retry timers correctly.
  • Experiment config types: add experimentConfig, sessionSync types and useExperimentVariant to useClientConfig for feature-flag-gated SW sync phases.

Testing Checklist

  • Push notification arrives while app is backgrounded: notification shown, tap opens correct room
  • Reaction notification: correct sender context, not shown for own reactions
  • Notification tap on joined room: opens at live timeline (bottom)
  • Notification tap on event-specific link: jumps to correct message once loaded
  • Clear Cache: service workers unregistered, fresh assets loaded on next visit
  • No duplicate notifications when app is foregrounded

Changeset

  • fix: Push notification reliability (session recovery, encryption context, silent-drop prevention)
  • fix: Reaction notification context
  • feat: Unregister service workers on Clear Cache

Just-Insane and others added 30 commits April 11, 2026 17:30
…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.
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.
…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
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>
Unregister all service worker registrations before reloading when the
user clicks Clear Cache & Reload. On iOS/mobile, stale SWs can persist
and serve outdated assets even after an app update; this ensures the
next load starts completely fresh.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Just-Insane and others added 14 commits April 17, 2026 14:59
Add activeRoomIdAtom (synced from all RoomProviders) so
BackgroundNotifications can detect when the user is already viewing the
notification room.  When the room matches and the window is focused, the
background handler now returns early — no banner, no OS notification.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
- Increase jump timeout from 15s to 30s for slow sync catch-up
- Always pass eventId on navigation (even after timeout) so the
  room loads historical context around the notification message
  instead of dumping the user at live bottom

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant