fix(client): graceful websocket reconnect

Amolith created

- 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

Change summary

static/app.js | 60 ++++++++++++++++++++++++++++++++++++++++++----------
static/ui.js  |  3 ++
2 files changed, 51 insertions(+), 12 deletions(-)

Detailed changes

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 = `<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="feather feather-check"><polyline points="20 6 9 17 4 12"></polyline></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));

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();