From 5a10305f6447db334a2f879c081c1d39994efcf9 Mon Sep 17 00:00:00 2001 From: Amolith Date: Sat, 8 Nov 2025 20:17:16 -0700 Subject: [PATCH] fix(client): graceful websocket reconnect - replace disconnect alert with automatic retry/backoff while UI stays disabled - pause keyboard shortcuts when the list is offline so input waits for reconnection Closes: bug-fab8338 Assisted-by: GPT-5 via Crush --- static/app.js | 60 ++++++++++++++++++++++++++++++++++++++++----------- static/ui.js | 3 +++ 2 files changed, 51 insertions(+), 12 deletions(-) diff --git a/static/app.js b/static/app.js index 380a93195ec021292e651e5d8386cd62ae7d3a1c..fe237ae73fe64a8e3c0ed340923eb4a44f5d574e 100644 --- a/static/app.js +++ b/static/app.js @@ -18,6 +18,12 @@ let selectedPosition = null; // Position to restore after voting let lastSyncTime = null; // For connection status let shouldScrollSelectedIntoView = false; // Only scroll on local actions +const INITIAL_RECONNECT_DELAY = 1000; +const MAX_RECONNECT_DELAY = 30000; +let reconnectAttempts = 0; +let reconnectTimer = null; +let manualDisconnect = false; + const CHECK_ICON_SVG = ``; const startScreen = document.getElementById('start-screen'); @@ -49,7 +55,16 @@ document.getElementById('join-code').addEventListener('keydown', (e) => { }); document.getElementById('leave-btn').addEventListener('click', () => { - if (ws) ws.close(); + manualDisconnect = true; + if (reconnectTimer) { + clearTimeout(reconnectTimer); + reconnectTimer = null; + } + reconnectAttempts = 0; + if (ws) { + ws.close(); + ws = null; + } startScreen.classList.remove('hidden'); listScreen.classList.add('hidden'); currentRoom = null; @@ -172,7 +187,15 @@ document.getElementById('add-bulk-btn').addEventListener('click', () => { // Core functions let messageQueue = []; -function joinRoom(code) { +function joinRoom(code, { isReconnect = false } = {}) { + manualDisconnect = false; + if (!isReconnect) { + reconnectAttempts = 0; + } + if (reconnectTimer) { + clearTimeout(reconnectTimer); + reconnectTimer = null; + } currentRoom = code; setUIEnabled(false, listScreen, listContainer); @@ -182,6 +205,11 @@ function joinRoom(code) { ws = new WebSocket(`${wsScheme}//${location.host}/ws?room=${code}&user=${userId}`); ws.onopen = () => { + reconnectAttempts = 0; + if (reconnectTimer) { + clearTimeout(reconnectTimer); + reconnectTimer = null; + } updateConnectionStatus('connected'); // Flush queued messages while (messageQueue.length > 0) { @@ -198,28 +226,36 @@ function joinRoom(code) { ws.onerror = (error) => { console.error('WebSocket error:', error); + setUIEnabled(false, listScreen, listContainer); updateConnectionStatus('disconnected'); }; - ws.onclose = (event) => { + ws.onclose = () => { + ws = null; setUIEnabled(false, listScreen, listContainer); updateConnectionStatus('disconnected'); - if (!event.wasClean) { - console.error('Connection closed unexpectedly'); - alert('Connection lost. Please try rejoining.'); - startScreen.classList.remove('hidden'); - listScreen.classList.add('hidden'); - currentRoom = null; - items = []; - messageQueue = []; - listContainer.innerHTML = ''; + if (manualDisconnect || !currentRoom) { + return; } + scheduleReconnect(); }; startScreen.classList.add('hidden'); listScreen.classList.remove('hidden'); } +function scheduleReconnect() { + if (reconnectTimer || manualDisconnect || !currentRoom) { + return; + } + const delay = Math.min(INITIAL_RECONNECT_DELAY * (2 ** reconnectAttempts), MAX_RECONNECT_DELAY); + reconnectTimer = setTimeout(() => { + reconnectTimer = null; + joinRoom(currentRoom, { isReconnect: true }); + }, delay); + reconnectAttempts += 1; +} + function sendMessage(msg) { if (ws && ws.readyState === WebSocket.OPEN) { ws.send(JSON.stringify(msg)); diff --git a/static/ui.js b/static/ui.js index edd98664a9819c25a4aa3598441c228f3982ca20..d5b47857bcd97205647e2361b6088cb906d3fe41 100644 --- a/static/ui.js +++ b/static/ui.js @@ -6,6 +6,7 @@ import { getSortedItems } from './render.js'; export function setupKeyboardShortcuts(options) { const { items, vote, deleteItem, editItem, undo, render, getSelectedItemId, setSelectedItemId, setShouldScroll } = options; + const listContainer = document.getElementById('list-container'); document.addEventListener('keydown', (e) => { // Open help modal on '?' @@ -28,6 +29,8 @@ export function setupKeyboardShortcuts(options) { // Ignore if typing in input/textarea if (e.target.matches('input, textarea')) return; + if (listContainer && listContainer.classList.contains('disabled')) return; + // Undo if ((e.ctrlKey || e.metaKey) && e.key === 'z') { e.preventDefault();