Repository navigation
[2.x] fix(realtime): reconstruct Pusher on iOS reconnect to bypass lives:2 budget - #4654
Merged
Merged
Conversation
…s lives:2 kill-switch PR flarum#4590 fixed the first iOS Safari backgrounding cycle, but the second cycle silently dies because pusher-js 7.6's default strategy enforces a `lives: 2` budget on its WebSocket transport. Each iOS-initiated 1006 close decrements the budget; after two cycles the strategy reports unsupported and every subsequent `connect()` transitions straight to `'failed'`. Realtime stops working until the page is reloaded. Replace the disconnect+connect cycle in `forceReconnect` with constructing a fresh `Pusher` instance. Its strategy tree starts with full `livesLeft`, so the same recovery survives arbitrarily many backgrounding cycles. Supporting refactor: - Channel subscription and the inline `notification` binding are extracted into `setupChannels(websocket)` so they can be re-invoked against the new instance. - `RealtimeState` retains channel-ready callbacks across calls so that the second `notifyUserChannelReady`/`notifyPublicChannelReady` (fired after reconstruction) re-runs every registered subscriber against the new channel. The previous channels are discarded with the previous Pusher, so re-firing produces no duplicate bindings. - Lifecycle-bound consumers (`Discussion/NewActivity`, `DiscussionList/NewActivity`) register a rebind callback via the new `RealtimeState.onChannelsReconnected(...)` hook and release it in `onremove`. Closes flarum#4597.
Includes transpiled JS/TS.
imorland
approved these changes
May 12, 2026
3 tasks done
imorland
added a commit
that referenced
this pull request
May 13, 2026
) 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.
ekumanov
added a commit
to ekumanov/framework
that referenced
this pull request
Jun 12, 2026
…h up missed events WebKit suspends hidden pages on desktop Safari just like on iOS, so the WebSocket dies silently (often without `close`) while pusher-js's foreground-only activity timers cannot notice. The visibilitychange recovery from flarum#4590/flarum#4654 was gated to isIOS() by flarum#4662 and never runs there, and pusher-js's own reconnects perform no catch-up — so posts that fired while the socket was down never appear, and the open discussion silently stops live-updating (a >=2-post gap makes PostStreamState.update()'s viewingEnd() guard refuse forever). - Reconnect on visibility-restore when the connection is demonstrably unhealthy (state not 'connected', or no protocol frame within the activity window), in addition to the unconditional iOS path. Healthy desktop tabs keep receiving pongs while hidden, so plain tab switches still trigger no refetch (flarum#4662 intact). - Catch up after every effective reconnect (pusher-internal or forced): refresh the discussion list and re-sync an open DiscussionPage, capturing viewingEnd() before the refetch grows postIds. - Add PostStreamState.syncEnd() — update() without the 1-post drift bound — and capture end-ness in NewActivity before pushing event payloads, so a gap no longer permanently disables live-append. Fixes flarum#4717. Co-Authored-By: Claude Fable 5 <[email protected]>
ekumanov
added a commit
to ekumanov/framework
that referenced
this pull request
Jun 18, 2026
…h up missed events WebKit suspends hidden pages on desktop Safari just like on iOS, so the WebSocket dies silently (often without `close`) while pusher-js's foreground-only activity timers cannot notice. The visibilitychange recovery from flarum#4590/flarum#4654 was gated to isIOS() by flarum#4662 and never runs there, and pusher-js's own reconnects perform no catch-up — so posts that fired while the socket was down never appear, and the open discussion silently stops live-updating (a >=2-post gap makes PostStreamState.update()'s viewingEnd() guard refuse forever). - Reconnect on visibility-restore when the connection is demonstrably unhealthy (state not 'connected', or no protocol frame within the activity window), in addition to the unconditional iOS path. Healthy desktop tabs keep receiving pongs while hidden, so plain tab switches still trigger no refetch (flarum#4662 intact). - Catch up after every effective reconnect (pusher-internal or forced): refresh the discussion list and re-sync an open DiscussionPage, capturing viewingEnd() before the refetch grows postIds. - Add PostStreamState.syncEnd() — update() without the 1-post drift bound — and capture end-ness in NewActivity before pushing event payloads, so a gap no longer permanently disables live-append. Fixes flarum#4717. Co-Authored-By: Claude Opus 4.8 <[email protected]>
imorland
pushed a commit
that referenced
this pull request
Jul 7, 2026
…h up missed events (#4718) WebKit suspends hidden pages on desktop Safari just like on iOS, so the WebSocket dies silently (often without `close`) while pusher-js's foreground-only activity timers cannot notice. The visibilitychange recovery from #4590/#4654 was gated to isIOS() by #4662 and never runs there, and pusher-js's own reconnects perform no catch-up — so posts that fired while the socket was down never appear, and the open discussion silently stops live-updating (a >=2-post gap makes PostStreamState.update()'s viewingEnd() guard refuse forever). - Reconnect on visibility-restore when the connection is demonstrably unhealthy (state not 'connected', or no protocol frame within the activity window), in addition to the unconditional iOS path. Healthy desktop tabs keep receiving pongs while hidden, so plain tab switches still trigger no refetch (#4662 intact). - Catch up after every effective reconnect (pusher-internal or forced): refresh the discussion list and re-sync an open DiscussionPage, capturing viewingEnd() before the refetch grows postIds. - Add PostStreamState.syncEnd() — update() without the 1-post drift bound — and capture end-ness in NewActivity before pushing event payloads, so a gap no longer permanently disables live-append. Fixes #4717. Co-authored-by: Claude Opus 4.8 <[email protected]>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Background
#4588 + #4590 covered iOS Safari backgrounding by forcing a WebSocket reconnect via
visibilitychange/pageshow. That fix handles the first backgrounding cycle, but as raised during the #4590 review and tracked in #4597, the second (and subsequent) cycle silently dies: events stop arriving, the connection looks alive but reports'failed'and never recovers without a hard reload.This PR closes #4597.
Root cause
pusher-js 7.6's
default_strategy.tswraps the WebSocket transport in aCachedStrategywithlives: 2. The budget is enforced viaTransportManager.reportDeath()— every "unclean" close (code 1006) decrementslivesLeft. iOS Safari issues a 1006 on every backgrounding over ~30 s regardless of network health, so after two cycleslivesLefthits 0,isAlive()flips false, the strategy reports unsupported, and from that point everyconnect()transitions straight to'failed'. The only recovery is a brand-newPusherinstance with a fresh strategy tree.Fix
forceReconnectnow constructs a freshPusherinstead of callingdisconnect()+connect()on the existing one:A fresh strategy tree comes with full
livesLeft, so the same recovery path survives arbitrarily many backgrounding cycles.Supporting refactor
Channel subscription and the inline
notificationbinding move into a sharedsetupChannels(websocket)so they can run again against the new instance. The previous code path was straight-line insideApplication.mountand not reusable.RealtimeStatenow retains channel-ready callbacks across calls instead of clearing them after the first fire:notifyUserChannelReady/notifyPublicChannelReadyre-invoke every registered subscriber against the new channel. Because the previous channels are GC'd with the previous Pusher instance, re-firing produces no duplicate bindings.onChannelsReconnected(callback): () => voidhook serves lifecycle-bound consumers that bind handlers in a Mithriloncreate(Discussion/NewActivity,DiscussionList/NewActivity). They register a rebind callback and release it inonremove.Verification
Deployed to a production Flarum 2.0 forum running this exact bundle on
vendor/flarum/realtime/js/dist/. Tested on iPhone Safari against a separate desktop browser session, both logged in to a Byobu private discussion (so push events fire on the user private channel without polluting public visibility).The visible signal is the realtime notification toast that appears on iPhone whenever the desktop user replies. Procedure:
lives: 2kill-switch #4597 is reproducible on the current2.xHEAD. With this PR applied, toast appears. ✅Independent regression checks:
DiscussionPage/IndexPageonremovecleanly releases the reconnect callback (verified by inspectingRealtimeState's internalreconnectCallbackslist before and after page navigation).pageshow(persisted=true)path (Safari tab-switch rather than app-switch) also produces a working reconnect across multiple cycles, mirroring thevisibilitychangepath.Files
extensions/realtime/js/src/forum/RealtimeState.ts— retain callbacks, addonChannelsReconnected+notifyChannelsReconnected.extensions/realtime/js/src/forum/extend/Application.ts— extractsetupChannels, rewriteforceReconnectto construct a freshPusher.extensions/realtime/js/src/forum/extend/Discussion/NewActivity.ts— rebind on reconnect.extensions/realtime/js/src/forum/extend/DiscussionList/NewActivity.ts— rebind on reconnect.Closes #4597.