From bfe925d696aa5f7c93dc49568dce2c9f24b49aad Mon Sep 17 00:00:00 2001 From: "IM.codes" Date: Thu, 9 Apr 2026 23:05:22 +0800 Subject: [PATCH] Fix stale server connecting state --- web/src/app.tsx | 50 +++++++++++++++++++++++++++---- web/src/server-selection.ts | 27 +++++++++++++++++ web/test/server-selection.test.ts | 47 ++++++++++++++++++++++++++++- 3 files changed, 117 insertions(+), 7 deletions(-) diff --git a/web/src/app.tsx b/web/src/app.tsx index fb652b59e..128b1791f 100644 --- a/web/src/app.tsx +++ b/web/src/app.tsx @@ -64,7 +64,11 @@ import { extractTransportPendingMessages } from './transport-queue.js'; import { ingestTimelineEventForCache } from './hooks/useTimeline.js'; import { getMobileKeyboardState } from './mobile-keyboard.js'; import { pickReadableSessionDisplay } from '@shared/session-display.js'; -import { getSelectedServerName } from './server-selection.js'; +import { + getSelectedServerName, + shouldResetSelectedServer, + shouldShowInitialConnectingGate, +} from './server-selection.js'; // On web: if opened by the native app for passkey auth, render the bridge page. const nativeCallback = typeof window !== 'undefined' @@ -145,6 +149,8 @@ export function App() { const [splashDone, setSplashDone] = useState(false); const [servers, setServers] = useState([]); + const [serversLoaded, setServersLoaded] = useState(false); + const [serversSynced, setServersSynced] = useState(false); const [selectedServerId, setSelectedServerId] = useState( () => localStorage.getItem('rcc_server'), ); @@ -174,6 +180,8 @@ export function App() { useEffect(() => { if (!auth) { + setServersLoaded(false); + setServersSynced(false); watchProjectionStore.setApiKey(null); watchProjectionStore.setSnapshotStatus('switching'); watchProjectionStore.setServers([]); @@ -220,6 +228,18 @@ export function App() { localStorage.removeItem('rcc_server_name'); }, [resolvedSelectedServerName, selectedServerId, selectedServerName, servers.length]); + useEffect(() => { + if (!serversSynced) return; + if (!shouldResetSelectedServer(selectedServerId, servers, serversLoaded)) return; + setSelectedServerId(null); + setSelectedServerName(null); + setSessionsLoaded(true); + setConnecting(false); + localStorage.removeItem('rcc_server'); + localStorage.removeItem('rcc_server_name'); + localStorage.removeItem('rcc_session'); + }, [selectedServerId, servers, serversLoaded]); + useEffect(() => { let cleanup = () => {}; void onWatchCommand((command) => { @@ -504,10 +524,20 @@ export function App() { try { const data = await apiFetch<{ servers: ServerInfo[] }>('/api/server'); setServers(data.servers); - } catch { /* ignore */ } + setServersSynced(true); + } catch { + // Preserve the last known list on refresh failures. The request is still + // considered resolved so the UI can escape the initial connecting gate. + } finally { + setServersLoaded(true); + } }, [auth]); - useEffect(() => { loadServers(); }, [loadServers]); + useEffect(() => { + setServersLoaded(false); + setServersSynced(false); + void loadServers(); + }, [loadServers]); // Periodically refresh server list so lastHeartbeatAt stays current useEffect(() => { @@ -2207,16 +2237,24 @@ export function App() { // Show full-screen connecting indicator while waiting for initial WS + session data. // After 8s, show escape buttons so the user is never stuck. const [connectTimeout, setConnectTimeout] = useState(false); + const showInitialConnectingGate = shouldShowInitialConnectingGate( + Boolean(auth), + selectedServerId, + connected, + sessionsLoaded, + serversLoaded, + ); + useEffect(() => { - if (auth && selectedServerId && !connected && servers.length === 0) { + if (showInitialConnectingGate) { const t = setTimeout(() => setConnectTimeout(true), 5000); return () => { clearTimeout(t); setConnectTimeout(false); }; } setConnectTimeout(false); return undefined; - }, [auth, selectedServerId, connected, servers.length]); + }, [showInitialConnectingGate]); - if (auth && selectedServerId && !sessionsLoaded && !connected && servers.length === 0) { + if (showInitialConnectingGate) { return (
diff --git a/web/src/server-selection.ts b/web/src/server-selection.ts index cdf59a668..2adeacbac 100644 --- a/web/src/server-selection.ts +++ b/web/src/server-selection.ts @@ -3,6 +3,14 @@ export interface SelectableServerInfo { name: string; } +export function hasSelectedServer( + selectedServerId: string | null, + servers: readonly SelectableServerInfo[], +): boolean { + if (!selectedServerId) return false; + return servers.some((server) => server.id === selectedServerId); +} + export function getSelectedServerName( selectedServerId: string | null, servers: readonly SelectableServerInfo[], @@ -12,3 +20,22 @@ export function getSelectedServerName( if (servers.length === 0) return fallbackName; return servers.find((server) => server.id === selectedServerId)?.name ?? null; } + +export function shouldResetSelectedServer( + selectedServerId: string | null, + servers: readonly SelectableServerInfo[], + serversLoaded: boolean, +): boolean { + if (!selectedServerId || !serversLoaded) return false; + return !hasSelectedServer(selectedServerId, servers); +} + +export function shouldShowInitialConnectingGate( + authReady: boolean, + selectedServerId: string | null, + connected: boolean, + sessionsLoaded: boolean, + serversLoaded: boolean, +): boolean { + return Boolean(authReady && selectedServerId && !sessionsLoaded && !connected && !serversLoaded); +} diff --git a/web/test/server-selection.test.ts b/web/test/server-selection.test.ts index 6d99b772c..d20f54cd8 100644 --- a/web/test/server-selection.test.ts +++ b/web/test/server-selection.test.ts @@ -1,6 +1,11 @@ import { describe, expect, it } from 'vitest'; -import { getSelectedServerName } from '../src/server-selection.js'; +import { + getSelectedServerName, + hasSelectedServer, + shouldResetSelectedServer, + shouldShowInitialConnectingGate, +} from '../src/server-selection.js'; describe('getSelectedServerName', () => { it('uses the persisted fallback before the server list is loaded', () => { @@ -26,3 +31,43 @@ describe('getSelectedServerName', () => { )).toBeNull(); }); }); + +describe('hasSelectedServer', () => { + it('returns true when the selected server exists in the loaded list', () => { + expect(hasSelectedServer('srv-2', [ + { id: 'srv-1', name: 'Server One' }, + { id: 'srv-2', name: 'Server Two' }, + ])).toBe(true); + }); + + it('returns false when the selected server is missing', () => { + expect(hasSelectedServer('srv-2', [{ id: 'srv-1', name: 'Server One' }])).toBe(false); + }); +}); + +describe('shouldResetSelectedServer', () => { + it('does not clear the selection before the server list has loaded', () => { + expect(shouldResetSelectedServer('srv-2', [], false)).toBe(false); + }); + + it('clears a stale selected server once the server list has loaded', () => { + expect(shouldResetSelectedServer('srv-2', [{ id: 'srv-1', name: 'Server One' }], true)).toBe(true); + }); + + it('clears the selection when there are no servers after loading', () => { + expect(shouldResetSelectedServer('srv-2', [], true)).toBe(true); + }); +}); + +describe('shouldShowInitialConnectingGate', () => { + it('shows the gate only while the server list is still loading', () => { + expect(shouldShowInitialConnectingGate(true, 'srv-1', false, false, false)).toBe(true); + expect(shouldShowInitialConnectingGate(true, 'srv-1', false, false, true)).toBe(false); + }); + + it('does not show the gate without a selected server or after a connection is established', () => { + expect(shouldShowInitialConnectingGate(true, null, false, false, false)).toBe(false); + expect(shouldShowInitialConnectingGate(true, 'srv-1', true, false, false)).toBe(false); + expect(shouldShowInitialConnectingGate(true, 'srv-1', false, true, false)).toBe(false); + }); +});