Skip to content

Commit 2575a6b

Browse files
authored
Stop reconnecting when page is hidden (#6534)
* Stop reconnecting when page is hidden This tries to address the problem of seeing the "attempting to reconnect" flash lingering around for a very long time when resuming a LiveView tab on mobile. In my tests, this mostly happens when fallback to longpoll is disabled. In this case, the following happens: 1. User puts their phone to sleep. 2. After a couple of seconds, the browser kills the websocket connection and Phoenix registers this as an error. 3. Phoenix tries to establish a new WebSocket connection. This hangs. 4. User opens the page again. Instead of immediately establishing the connection, the WebSocket is stalled for a while. The exact amount seems a bit random, but I've seen 10+ seconds. During that time, the WebSocket in the devtools doesn't show any activity, but then suddenly the connection works again. If LongPoll is active, at point 3, Phoenix instead tries a LongPoll connection. In mobile Chrome, the HTTP request fails and Phoenix registers this as 500 and tries again, backing off. If the user resumes the page - depending on their luck - they might need to wait for the next backoff attempt to fire, then the connection is established quickly. This PR addresses this problem by checking the visibility state using the `visibilitychange` event, which is recommended for registering a tab going in the background (for example by a phone going to sleep). We set a flag that we're hidden and in the case that there's an error, we don't try to reconnect immediately (since we're hidden). Instead, we reconnect as soon as the visibilitychange fires again with the page not hidden any more. With this change, the connection is immediately established in both mobile Chrome (Android) and Mobile Safari (iOS), most of the time not seeing the "trying to reconnect" flash at all. Also, this PR addresses a problem where the `connectWithFallback` function would accumulate `onOpen` listeners, causing duplicate callbacks. Hopefully closes phoenixframework/phoenix_live_view#3896. Hopefully closes #6149. * make tests happy
1 parent 15b4477 commit 2575a6b

File tree

2 files changed

+28
-6
lines changed

2 files changed

+28
-6
lines changed

assets/js/phoenix/socket.js

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -116,6 +116,7 @@ export default class Socket {
116116
this.channels = []
117117
this.sendBuffer = []
118118
this.ref = 0
119+
this.fallbackRef = null
119120
this.timeout = opts.timeout || DEFAULT_TIMEOUT
120121
this.transport = opts.transport || global.WebSocket || LongPoll
121122
this.primaryPassedHealthCheck = false
@@ -129,6 +130,7 @@ export default class Socket {
129130
this.disconnecting = false
130131
this.binaryType = opts.binaryType || "arraybuffer"
131132
this.connectClock = 1
133+
this.pageHidden = false
132134
if(this.transport !== LongPoll){
133135
this.encode = opts.encode || this.defaultEncoder
134136
this.decode = opts.decode || this.defaultDecoder
@@ -150,6 +152,17 @@ export default class Socket {
150152
this.connect()
151153
}
152154
})
155+
phxWindow.addEventListener("visibilitychange", () => {
156+
if(document.visibilityState === "hidden"){
157+
this.pageHidden = true
158+
} else {
159+
this.pageHidden = false
160+
// reconnect immediately
161+
if(!this.isConnected()){
162+
this.teardown(() => this.connect())
163+
}
164+
}
165+
})
153166
}
154167
this.heartbeatIntervalMs = opts.heartbeatIntervalMs || 30000
155168
this.rejoinAfterMs = (tries) => {
@@ -178,6 +191,11 @@ export default class Socket {
178191
this.heartbeatTimer = null
179192
this.pendingHeartbeatRef = null
180193
this.reconnectTimer = new Timer(() => {
194+
if(this.pageHidden){
195+
this.log("Not reconnecting as page is hidden!")
196+
this.teardown()
197+
return
198+
}
181199
this.teardown(() => this.connect())
182200
}, this.reconnectAfterMs)
183201
this.authToken = opts.authToken
@@ -396,7 +414,10 @@ export default class Socket {
396414
fallback(reason)
397415
}
398416
})
399-
this.onOpen(() => {
417+
if(this.fallbackRef){
418+
this.off([this.fallbackRef])
419+
}
420+
this.fallbackRef = this.onOpen(() => {
400421
established = true
401422
if(!primaryTransport){
402423
// only memorize LP if we never connected to primary
@@ -502,6 +523,7 @@ export default class Socket {
502523
}
503524

504525
onConnClose(event){
526+
if(this.conn) this.conn.onclose = () => {} // noop to prevent recursive calls in teardown
505527
let closeCode = event && event.code
506528
if(this.hasLogger()) this.log("transport", "close", event)
507529
this.triggerChanError()

assets/test/socket_http_test.js

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,19 +2,19 @@
22
* @jest-environment jsdom
33
* @jest-environment-options {"url": "http://example.com/"}
44
*/
5-
import { Socket } from "../js/phoenix"
5+
import {Socket} from "../js/phoenix"
66

77
// sadly, jsdom can only be configured globally for a file
88

9-
describe("protocol", function () {
10-
it("returns ws when location.protocol is http", function () {
9+
describe("protocol", function (){
10+
it("returns ws when location.protocol is http", function (){
1111
const socket = new Socket("/socket")
1212
expect(socket.protocol()).toBe("ws")
1313
})
1414
})
1515

16-
describe("endpointURL", function () {
17-
it("returns endpoint for given path on http host", function () {
16+
describe("endpointURL", function (){
17+
it("returns endpoint for given path on http host", function (){
1818
const socket = new Socket("/socket")
1919
expect(socket.endPointURL()).toBe(
2020
"ws://example.com/socket/websocket?vsn=2.0.0",

0 commit comments

Comments
 (0)