fix(realtime): open no socket after a logout, and reconnect as soon as the network is back

A connect still waiting for its ws token when the user logged out went on
to open a socket authenticated as that user, which the next login on the
same browser then reused. And a socket in its reconnect backoff waited up
to 30 s after the network came back or the tab became visible. Both
events now reconnect right away.
This commit is contained in:
Maurice
2026-10-07 16:25:00 +02:00
parent f02253a44c
commit 3f9b751ee3
5 changed files with 71 additions and 4 deletions
+33 -2
View File
@@ -1,13 +1,13 @@
// vi.unmock must run before the module is imported (tests/setup.ts mocks it globally)
vi.unmock('./websocket')
// FE-WSCORE-001 to FE-WSCORE-014
// FE-WSCORE-001 to FE-WSCORE-016
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { http, HttpResponse } from 'msw'
import { server } from '../../tests/helpers/msw/server'
import {
connect, disconnect, joinTrip, leaveTrip, getActiveTrips,
setRefetchCallback, setPreReconnectHook,
setRefetchCallback, setPreReconnectHook, reconnectNow,
} from './websocket'
class MockWebSocket {
@@ -265,3 +265,34 @@ describe('websocket > connection lifecycle', () => {
expect(MockWebSocket.instances).toHaveLength(2)
})
})
describe('websocket > logout and reconnect', () => {
it('FE-WSCORE-015: a logout while the token is on its way opens no socket afterwards', async () => {
server.use(http.post('/api/auth/ws-token', async () => {
await new Promise((r) => setTimeout(r, 50))
return HttpResponse.json({ token: 'previous-user' })
}))
connect()
disconnect()
await vi.advanceTimersByTimeAsync(100)
expect(MockWebSocket.instances).toHaveLength(0)
})
it('FE-WSCORE-016: reconnectNow skips the backoff, and does nothing while logged out or connected', async () => {
reconnectNow()
await vi.advanceTimersByTimeAsync(0)
expect(MockWebSocket.instances).toHaveLength(0)
const first = await openSocket()
reconnectNow()
await vi.advanceTimersByTimeAsync(0)
expect(MockWebSocket.instances).toHaveLength(1)
first.readyState = MockWebSocket.CLOSED
first.onclose?.()
reconnectNow()
await vi.advanceTimersByTimeAsync(0)
expect(MockWebSocket.instances).toHaveLength(2)
})
})
+19
View File
@@ -22,6 +22,9 @@ let shouldReconnect = false
let refetchCallback: RefetchCallback | null = null
let mySocketId: string | null = null
let connecting = false
// Bumped by disconnect(): a connect that was still waiting for its token when
// the user logged out must not open a socket afterwards.
let generation = 0
/** Hook run before refetchCallback on reconnect. Awaited so mutations land first. */
let preReconnectHook: (() => Promise<void>) | null = null
@@ -106,8 +109,12 @@ async function connectInternal(_isReconnect = false): Promise<void> {
}
connecting = true
const started = generation
const wsToken = await fetchWsToken()
connecting = false
// Logged out while the token was on its way: opening now would leave a socket
// authenticated as the previous user, which the next login would reuse.
if (started !== generation || !shouldReconnect) return
if (!wsToken) {
if (shouldReconnect) scheduleReconnect()
@@ -163,6 +170,17 @@ async function connectInternal(_isReconnect = false): Promise<void> {
}
}
/**
* The network came back or the tab became visible: a socket waiting out its
* backoff (up to 30 s) reconnects now. Does nothing while logged out or while
* a socket is open or opening.
*/
export function reconnectNow(): void {
if (!shouldReconnect) return
if (socket && (socket.readyState === WebSocket.OPEN || socket.readyState === WebSocket.CONNECTING)) return
connect()
}
export function connect(): void {
shouldReconnect = true
reconnectDelay = 1000
@@ -175,6 +193,7 @@ export function connect(): void {
export function disconnect(): void {
shouldReconnect = false
generation++
if (reconnectTimer) {
clearTimeout(reconnectTimer)
reconnectTimer = null