Skip to content

Commit 84ce4d0

Browse files
committed
[2.x] fix(realtime): only force-reconnect on iOS visibilitychange
PR #4654 added a `visibilitychange` listener that forces a fresh Pusher instance and refreshes the visible discussion list whenever the tab has been hidden for >5s. That was needed on iOS Safari, where backgrounding the page silently drops the WebSocket without firing `close`. On every other platform the WebSocket survives tab backgrounding fine — but the visibilitychange handler still fired, causing an unnecessary `GET /api/discussions` request and full list re-render every time the user switched away and back. Gate the visibilitychange-triggered `forceReconnect()` on `isIOS()` so the workaround only runs on the platform that actually needs it. The `pageshow(persisted=true)` path stays unconditional — bfcache restoration only fires on browsers that bfcached the page, and the WebSocket was definitely torn down by then regardless of platform. `isIOS()` is broader than the existing `isSafariMobile()` core utility because all iOS browsers use WebKit and share the same backgrounding pathology — iOS Chrome (`CriOS`) and iOS Firefox (`FxiOS`) are excluded by `isSafariMobile()` but still need this workaround. Regression from #4654.
1 parent e5f3705 commit 84ce4d0

2 files changed

Lines changed: 35 additions & 7 deletions

File tree

extensions/realtime/js/src/forum/extend/Application.ts

Lines changed: 13 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import Application from 'flarum/common/Application';
55
import RealtimeState from '../RealtimeState';
66
import NotificationToast from '../components/NotificationToast';
77
import NotificationToastState from '../states/NotificationToastState';
8+
import isIOS from '../utils/isIOS';
89

910
export default function () {
1011
extend(Application.prototype, 'mount' as any, function () {
@@ -78,12 +79,17 @@ export default function () {
7879
app.websocket = new Pusher(wsKey, pusherOptions);
7980
setupChannels(app.websocket);
8081

81-
// iOS Safari silently drops WebSocket connections when the tab is
82-
// backgrounded or the device sleeps, without firing `close` — pusher-js's
83-
// built-in recovery never triggers, so realtime updates go missing until
84-
// the page is reloaded. iOS also bfcaches pages on app-switch, which
85-
// restores via `pageshow` (persisted=true) and does NOT fire
86-
// `visibilitychange` on return. We therefore hook both events.
82+
// iOS browsers (all WebKit) silently drop WebSocket connections when
83+
// the tab is backgrounded or the device sleeps, without firing `close`
84+
// — pusher-js's built-in recovery never triggers, so realtime updates
85+
// go missing until the page is reloaded. iOS also bfcaches pages on
86+
// app-switch, which restores via `pageshow` (persisted=true) and does
87+
// NOT fire `visibilitychange` on return. We therefore hook both events.
88+
//
89+
// The visibilitychange path is gated on `isIOS()`: desktop browsers
90+
// (and Android) maintain the WebSocket fine across tab backgrounding
91+
// and don't need a forced reconnect, which would otherwise cause an
92+
// unnecessary discussion-list refetch on every tab-switch return.
8793
//
8894
// `forceReconnect` constructs a fresh Pusher instance rather than
8995
// calling `connect()` on the existing one. pusher-js 7.6's default
@@ -134,7 +140,7 @@ export default function () {
134140
if (hiddenSince === null) return;
135141
const wasHiddenFor = Date.now() - hiddenSince;
136142
hiddenSince = null;
137-
if (wasHiddenFor > RECONNECT_HIDDEN_THRESHOLD_MS) {
143+
if (wasHiddenFor > RECONNECT_HIDDEN_THRESHOLD_MS && isIOS()) {
138144
forceReconnect();
139145
}
140146
});
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
/**
2+
* Detect whether the current browser is running on iOS / iPadOS.
3+
*
4+
* All iOS browsers (Safari, Chrome, Firefox, etc.) use WebKit under the hood
5+
* and inherit Safari's WebSocket-backgrounding pathology — so this needs to
6+
* be broader than `isSafariMobile()` (which excludes Chrome/Firefox on iOS).
7+
*
8+
* Detects iPadOS 13+ via the MacIntel + maxTouchPoints quirk: iPadOS 13+
9+
* reports `navigator.platform === 'MacIntel'` but `maxTouchPoints > 1`,
10+
* which desktop macOS never does.
11+
*/
12+
export default function isIOS(): boolean {
13+
if (/iPad|iPhone|iPod/.test(navigator.userAgent)) {
14+
return true;
15+
}
16+
17+
if (navigator.platform === 'MacIntel' && navigator.maxTouchPoints > 1) {
18+
return true;
19+
}
20+
21+
return false;
22+
}

0 commit comments

Comments
 (0)