Skip to content

Commit e4435df

Browse files
authored
Merge pull request #6 from im4codes/dev
Fix stale server connecting state
2 parents d015aac + bfe925d commit e4435df

3 files changed

Lines changed: 117 additions & 7 deletions

File tree

web/src/app.tsx

Lines changed: 44 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -64,7 +64,11 @@ import { extractTransportPendingMessages } from './transport-queue.js';
6464
import { ingestTimelineEventForCache } from './hooks/useTimeline.js';
6565
import { getMobileKeyboardState } from './mobile-keyboard.js';
6666
import { pickReadableSessionDisplay } from '@shared/session-display.js';
67-
import { getSelectedServerName } from './server-selection.js';
67+
import {
68+
getSelectedServerName,
69+
shouldResetSelectedServer,
70+
shouldShowInitialConnectingGate,
71+
} from './server-selection.js';
6872

6973
// On web: if opened by the native app for passkey auth, render the bridge page.
7074
const nativeCallback = typeof window !== 'undefined'
@@ -145,6 +149,8 @@ export function App() {
145149
const [splashDone, setSplashDone] = useState(false);
146150

147151
const [servers, setServers] = useState<ServerInfo[]>([]);
152+
const [serversLoaded, setServersLoaded] = useState(false);
153+
const [serversSynced, setServersSynced] = useState(false);
148154
const [selectedServerId, setSelectedServerId] = useState<string | null>(
149155
() => localStorage.getItem('rcc_server'),
150156
);
@@ -174,6 +180,8 @@ export function App() {
174180

175181
useEffect(() => {
176182
if (!auth) {
183+
setServersLoaded(false);
184+
setServersSynced(false);
177185
watchProjectionStore.setApiKey(null);
178186
watchProjectionStore.setSnapshotStatus('switching');
179187
watchProjectionStore.setServers([]);
@@ -220,6 +228,18 @@ export function App() {
220228
localStorage.removeItem('rcc_server_name');
221229
}, [resolvedSelectedServerName, selectedServerId, selectedServerName, servers.length]);
222230

231+
useEffect(() => {
232+
if (!serversSynced) return;
233+
if (!shouldResetSelectedServer(selectedServerId, servers, serversLoaded)) return;
234+
setSelectedServerId(null);
235+
setSelectedServerName(null);
236+
setSessionsLoaded(true);
237+
setConnecting(false);
238+
localStorage.removeItem('rcc_server');
239+
localStorage.removeItem('rcc_server_name');
240+
localStorage.removeItem('rcc_session');
241+
}, [selectedServerId, servers, serversLoaded]);
242+
223243
useEffect(() => {
224244
let cleanup = () => {};
225245
void onWatchCommand((command) => {
@@ -504,10 +524,20 @@ export function App() {
504524
try {
505525
const data = await apiFetch<{ servers: ServerInfo[] }>('/api/server');
506526
setServers(data.servers);
507-
} catch { /* ignore */ }
527+
setServersSynced(true);
528+
} catch {
529+
// Preserve the last known list on refresh failures. The request is still
530+
// considered resolved so the UI can escape the initial connecting gate.
531+
} finally {
532+
setServersLoaded(true);
533+
}
508534
}, [auth]);
509535

510-
useEffect(() => { loadServers(); }, [loadServers]);
536+
useEffect(() => {
537+
setServersLoaded(false);
538+
setServersSynced(false);
539+
void loadServers();
540+
}, [loadServers]);
511541

512542
// Periodically refresh server list so lastHeartbeatAt stays current
513543
useEffect(() => {
@@ -2207,16 +2237,24 @@ export function App() {
22072237
// Show full-screen connecting indicator while waiting for initial WS + session data.
22082238
// After 8s, show escape buttons so the user is never stuck.
22092239
const [connectTimeout, setConnectTimeout] = useState(false);
2240+
const showInitialConnectingGate = shouldShowInitialConnectingGate(
2241+
Boolean(auth),
2242+
selectedServerId,
2243+
connected,
2244+
sessionsLoaded,
2245+
serversLoaded,
2246+
);
2247+
22102248
useEffect(() => {
2211-
if (auth && selectedServerId && !connected && servers.length === 0) {
2249+
if (showInitialConnectingGate) {
22122250
const t = setTimeout(() => setConnectTimeout(true), 5000);
22132251
return () => { clearTimeout(t); setConnectTimeout(false); };
22142252
}
22152253
setConnectTimeout(false);
22162254
return undefined;
2217-
}, [auth, selectedServerId, connected, servers.length]);
2255+
}, [showInitialConnectingGate]);
22182256

2219-
if (auth && selectedServerId && !sessionsLoaded && !connected && servers.length === 0) {
2257+
if (showInitialConnectingGate) {
22202258
return (
22212259
<div style={{ position: 'fixed', inset: 0, display: 'flex', alignItems: 'center', justifyContent: 'center', background: '#0a0e1a', flexDirection: 'column', gap: 16 }}>
22222260
<div class="spinner" style={{ width: 32, height: 32 }} />

web/src/server-selection.ts

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,14 @@ export interface SelectableServerInfo {
33
name: string;
44
}
55

6+
export function hasSelectedServer(
7+
selectedServerId: string | null,
8+
servers: readonly SelectableServerInfo[],
9+
): boolean {
10+
if (!selectedServerId) return false;
11+
return servers.some((server) => server.id === selectedServerId);
12+
}
13+
614
export function getSelectedServerName(
715
selectedServerId: string | null,
816
servers: readonly SelectableServerInfo[],
@@ -12,3 +20,22 @@ export function getSelectedServerName(
1220
if (servers.length === 0) return fallbackName;
1321
return servers.find((server) => server.id === selectedServerId)?.name ?? null;
1422
}
23+
24+
export function shouldResetSelectedServer(
25+
selectedServerId: string | null,
26+
servers: readonly SelectableServerInfo[],
27+
serversLoaded: boolean,
28+
): boolean {
29+
if (!selectedServerId || !serversLoaded) return false;
30+
return !hasSelectedServer(selectedServerId, servers);
31+
}
32+
33+
export function shouldShowInitialConnectingGate(
34+
authReady: boolean,
35+
selectedServerId: string | null,
36+
connected: boolean,
37+
sessionsLoaded: boolean,
38+
serversLoaded: boolean,
39+
): boolean {
40+
return Boolean(authReady && selectedServerId && !sessionsLoaded && !connected && !serversLoaded);
41+
}

web/test/server-selection.test.ts

Lines changed: 46 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,11 @@
11
import { describe, expect, it } from 'vitest';
22

3-
import { getSelectedServerName } from '../src/server-selection.js';
3+
import {
4+
getSelectedServerName,
5+
hasSelectedServer,
6+
shouldResetSelectedServer,
7+
shouldShowInitialConnectingGate,
8+
} from '../src/server-selection.js';
49

510
describe('getSelectedServerName', () => {
611
it('uses the persisted fallback before the server list is loaded', () => {
@@ -26,3 +31,43 @@ describe('getSelectedServerName', () => {
2631
)).toBeNull();
2732
});
2833
});
34+
35+
describe('hasSelectedServer', () => {
36+
it('returns true when the selected server exists in the loaded list', () => {
37+
expect(hasSelectedServer('srv-2', [
38+
{ id: 'srv-1', name: 'Server One' },
39+
{ id: 'srv-2', name: 'Server Two' },
40+
])).toBe(true);
41+
});
42+
43+
it('returns false when the selected server is missing', () => {
44+
expect(hasSelectedServer('srv-2', [{ id: 'srv-1', name: 'Server One' }])).toBe(false);
45+
});
46+
});
47+
48+
describe('shouldResetSelectedServer', () => {
49+
it('does not clear the selection before the server list has loaded', () => {
50+
expect(shouldResetSelectedServer('srv-2', [], false)).toBe(false);
51+
});
52+
53+
it('clears a stale selected server once the server list has loaded', () => {
54+
expect(shouldResetSelectedServer('srv-2', [{ id: 'srv-1', name: 'Server One' }], true)).toBe(true);
55+
});
56+
57+
it('clears the selection when there are no servers after loading', () => {
58+
expect(shouldResetSelectedServer('srv-2', [], true)).toBe(true);
59+
});
60+
});
61+
62+
describe('shouldShowInitialConnectingGate', () => {
63+
it('shows the gate only while the server list is still loading', () => {
64+
expect(shouldShowInitialConnectingGate(true, 'srv-1', false, false, false)).toBe(true);
65+
expect(shouldShowInitialConnectingGate(true, 'srv-1', false, false, true)).toBe(false);
66+
});
67+
68+
it('does not show the gate without a selected server or after a connection is established', () => {
69+
expect(shouldShowInitialConnectingGate(true, null, false, false, false)).toBe(false);
70+
expect(shouldShowInitialConnectingGate(true, 'srv-1', true, false, false)).toBe(false);
71+
expect(shouldShowInitialConnectingGate(true, 'srv-1', false, true, false)).toBe(false);
72+
});
73+
});

0 commit comments

Comments
 (0)