mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-18 21:16:47 +00:00
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:
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
}));
|
||||
|
||||
@@ -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<void>) | 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'));
|
||||
|
||||
Reference in New Issue
Block a user