From 3f9b751ee3fed44d3e6b9366d0fd9eb8718eb352 Mon Sep 17 00:00:00 2001 From: Maurice Date: Wed, 7 Oct 2026 16:25:00 +0200 Subject: [PATCH] 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. --- client/src/api/websocket.extra.test.ts | 35 +++++++++++++++++++-- client/src/api/websocket.ts | 19 +++++++++++ client/src/sync/syncTriggers.ts | 4 ++- client/tests/setup.ts | 1 + client/tests/unit/sync/syncTriggers.test.ts | 16 +++++++++- 5 files changed, 71 insertions(+), 4 deletions(-) diff --git a/client/src/api/websocket.extra.test.ts b/client/src/api/websocket.extra.test.ts index 3522c6a87..5142b4217 100644 --- a/client/src/api/websocket.extra.test.ts +++ b/client/src/api/websocket.extra.test.ts @@ -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) + }) +}) + diff --git a/client/src/api/websocket.ts b/client/src/api/websocket.ts index 6a27abe5c..511fc5bca 100644 --- a/client/src/api/websocket.ts +++ b/client/src/api/websocket.ts @@ -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) | null = null @@ -106,8 +109,12 @@ async function connectInternal(_isReconnect = false): Promise { } 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 { } } +/** + * 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 diff --git a/client/src/sync/syncTriggers.ts b/client/src/sync/syncTriggers.ts index 60867ee0d..76ec882b5 100644 --- a/client/src/sync/syncTriggers.ts +++ b/client/src/sync/syncTriggers.ts @@ -15,7 +15,7 @@ import { mutationQueue } from './mutationQueue' import { tripSyncManager } from './tripSyncManager' import { isEffectivelyOnline, onNetworkModeChange } from './networkMode' -import { setPreReconnectHook, setRefetchCallback, getActiveTrips } from '../api/websocket' +import { setPreReconnectHook, setRefetchCallback, getActiveTrips, reconnectNow } from '../api/websocket' import { useTripStore } from '../store/tripStore' import { useSettingsStore } from '../store/settingsStore' import { useAuthStore } from '../store/authStore' @@ -61,6 +61,7 @@ function onOnline() { // routes through onNetworkMode → here with the force flag already cleared). if (!isEffectivelyOnline()) return ensureSettingsLoaded() + reconnectNow() mutationQueue.flush() .catch(console.error) .finally(() => { @@ -73,6 +74,7 @@ function onOnline() { function onVisibility() { if (!document.hidden && isEffectivelyOnline()) { ensureSettingsLoaded() + reconnectNow() mutationQueue.flush().catch(console.error) } } diff --git a/client/tests/setup.ts b/client/tests/setup.ts index 441298247..93676722e 100644 --- a/client/tests/setup.ts +++ b/client/tests/setup.ts @@ -17,6 +17,7 @@ vi.mock('../src/api/websocket', () => ({ getSocketId: vi.fn(() => null), setRefetchCallback: vi.fn(), setPreReconnectHook: vi.fn(), + reconnectNow: vi.fn(), addListener: vi.fn(), removeListener: vi.fn(), })); diff --git a/client/tests/unit/sync/syncTriggers.test.ts b/client/tests/unit/sync/syncTriggers.test.ts index b2826fca2..ed579928f 100644 --- a/client/tests/unit/sync/syncTriggers.test.ts +++ b/client/tests/unit/sync/syncTriggers.test.ts @@ -22,10 +22,12 @@ vi.mock('../../../src/sync/mutationQueue', () => ({ vi.mock('../../../src/sync/tripSyncManager', () => ({ tripSyncManager: { syncAll: () => syncAll() }, })); +let reconnects = 0; vi.mock('../../../src/api/websocket', () => ({ setPreReconnectHook: (fn: (() => Promise) | null) => { preReconnect = fn; }, setRefetchCallback: (fn: ((tripId: string) => void) | null) => { refetchCb = fn; }, getActiveTrips: () => ['7'], + reconnectNow: () => { reconnects++; }, })); vi.mock('../../../src/store/tripStore', () => ({ useTripStore: { getState: () => ({ hydrateActiveTrip: hydrate }) }, @@ -45,7 +47,7 @@ const flushMicrotasks = async () => { beforeEach(() => { flush.mockClear(); syncAll.mockClear(); hydrate.mockClear(); loadSettings.mockClear(); - refetchCb = null; preReconnect = null; + refetchCb = null; preReconnect = null; reconnects = 0; settingsLoaded = false; authenticated = true; Object.defineProperty(navigator, 'onLine', { value: true, writable: true, configurable: true }); }); @@ -88,6 +90,18 @@ describe('syncTriggers', () => { expect(flush.mock.invocationCallOrder[0]).toBeLessThan(hydrate.mock.invocationCallOrder[0]); }); + it('online event and a tab coming back reconnect the socket right away instead of waiting out its backoff', async () => { + registerSyncTriggers(); + window.dispatchEvent(new Event('online')); + await flushMicrotasks(); + expect(reconnects).toBe(1); + + Object.defineProperty(document, 'hidden', { value: false, configurable: true }); + document.dispatchEvent(new Event('visibilitychange')); + await flushMicrotasks(); + expect(reconnects).toBe(2); + }); + it('online event retries the settings load when it has not yet succeeded (#1618)', async () => { registerSyncTriggers(); window.dispatchEvent(new Event('online'));