Skip to content

Commit fcecd89

Browse files
authored
Merge pull request #3021 from bobleer/bob/fix-device-list-initialization
fix(web): stop My Devices initialization loop after sign-in
2 parents d51de66 + d5301e4 commit fcecd89

2 files changed

Lines changed: 208 additions & 26 deletions

File tree

Lines changed: 177 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,177 @@
1+
/** @vitest-environment jsdom */
2+
import React, { act } from 'react';
3+
import { createRoot, type Root } from 'react-dom/client';
4+
import { afterEach, beforeEach, expect, it, vi } from 'vitest';
5+
import { AccountPanel } from './AccountPanel';
6+
const mocks = vi.hoisted(() => ({
7+
identity: { resolved: true, status: 'signed-in', me: { user: { githubId: 42, login: 'alice' } } } as { resolved: boolean; status: string; me: { user: { githubId: number; login: string } } | null },
8+
getDeviceInfo: vi.fn(), accountStatus: vi.fn(), accountLogin: vi.fn(),
9+
accountConnectDevices: vi.fn(), accountListDevices: vi.fn(),
10+
t: (key: string) => key,
11+
}));
12+
vi.mock('@/infrastructure/account-identity', () => ({ useAccountIdentity: () => mocks.identity, accountIdentityService: {} }));
13+
vi.mock('@/infrastructure/api/service-api/RemoteConnectAPI', () => ({ remoteConnectAPI: mocks }));
14+
vi.mock('@/infrastructure/api/service-api/ApiClient', () => ({ api: { listen: () => () => {} } }));
15+
vi.mock('@/infrastructure/i18n', () => ({ useI18n: () => ({ t: mocks.t, formatRelativeTime: () => '' }) }));
16+
vi.mock('@/infrastructure/peer-device/peerDeviceContextState', () => ({ usePeerDeviceMode: () => ({ peerMode: { active: false } }) }));
17+
vi.mock('@/infrastructure/confirm-dialog', () => ({ confirmDanger: vi.fn() }));
18+
vi.mock('@/shared/notification-system', () => ({ useNotification: () => ({ success: vi.fn() }) }));
19+
vi.mock('@openbitfun/ui', () => {
20+
const Box = ({ children }: { children?: React.ReactNode }) => <div>{children}</div>;
21+
const Button = ({ children, onClick, disabled }: { children?: React.ReactNode; onClick?: React.MouseEventHandler<HTMLButtonElement>; disabled?: boolean }) => <button onClick={onClick} disabled={disabled}>{children}</button>;
22+
return { OverflowText: Box, Alert: Box, Button, Icon: () => null, IconButton: () => null, ScrollArea: Box, StatusPill: Box };
23+
});
24+
let container: HTMLDivElement;
25+
let root: Root;
26+
beforeEach(() => {
27+
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
28+
vi.resetAllMocks();
29+
mocks.identity = { resolved: true, status: 'signed-in', me: { user: { githubId: 42, login: 'alice' } } };
30+
mocks.getDeviceInfo.mockResolvedValueOnce({ device_id: 'local', device_name: 'My computer' })
31+
.mockImplementation(() => new Promise(() => {}));
32+
// A second initialization is deliberately held so the regression fails
33+
// deterministically instead of creating an unbounded render loop.
34+
mocks.accountStatus.mockResolvedValueOnce({ logged_in: true, user_id: '42' })
35+
.mockImplementation(() => new Promise(() => {}));
36+
mocks.accountConnectDevices.mockResolvedValue([{ device_id: 'local', device_name: 'My computer' }]);
37+
mocks.accountListDevices.mockResolvedValue([{ device_id: 'local', device_name: 'My computer', online: true }]);
38+
container = document.createElement('div'); document.body.append(container); root = createRoot(container);
39+
});
40+
afterEach(() => { act(() => root.unmount()); container.remove(); });
41+
it('keeps initialization alive when the local device ID arrives', async () => {
42+
await act(async () => { root.render(<AccountPanel onCloseDialog={() => {}} />); });
43+
expect(mocks.accountStatus).toHaveBeenCalledTimes(1);
44+
expect(mocks.accountConnectDevices).toHaveBeenCalledTimes(1);
45+
expect(container.textContent).toContain('My computer');
46+
expect(container.textContent).not.toContain('accountLogin.loadingDevices');
47+
expect(mocks.accountListDevices).toHaveBeenCalledTimes(1);
48+
});
49+
50+
it('loads the device snapshot and retains it across a normal rerender', async () => {
51+
mocks.getDeviceInfo.mockResolvedValue({ device_id: 'local', device_name: 'My computer' });
52+
mocks.accountListDevices.mockResolvedValue([{ device_id: 'local', device_name: 'My computer', online: true }]);
53+
await act(async () => { root.render(<AccountPanel onCloseDialog={() => {}} />); });
54+
await act(async () => { root.render(<AccountPanel onCloseDialog={() => {}} />); });
55+
expect(mocks.accountStatus).toHaveBeenCalledTimes(1);
56+
expect(mocks.accountListDevices).toHaveBeenCalledTimes(1);
57+
expect(container.textContent).toContain('My computer');
58+
expect(container.textContent).not.toContain('accountLogin.loadingDevices');
59+
});
60+
it('ignores a late device connection after shared identity signs out', async () => {
61+
let resolve!: (devices: Array<{ device_id: string; device_name: string }>) => void;
62+
mocks.accountConnectDevices.mockReturnValue(new Promise(res => { resolve = res; }));
63+
await act(async () => { root.render(<AccountPanel onCloseDialog={() => {}} />); });
64+
mocks.identity = { resolved: true, status: 'signed-out', me: null };
65+
await act(async () => { root.render(<AccountPanel onCloseDialog={() => {}} />); });
66+
await act(async () => { resolve([{ device_id: 'old', device_name: 'Old account device' }]); });
67+
expect(container.textContent).not.toContain('Old account device');
68+
expect(container.textContent).toContain('accountLogin.login');
69+
expect(mocks.accountListDevices).not.toHaveBeenCalled();
70+
});
71+
72+
it('fences a late snapshot when switching accounts', async () => {
73+
let resolve!: (devices: Array<{ device_id: string; device_name: string; online: boolean }>) => void;
74+
mocks.accountListDevices.mockReturnValueOnce(new Promise(res => { resolve = res; }));
75+
await act(async () => { root.render(<AccountPanel onCloseDialog={() => {}} />); });
76+
mocks.identity = { resolved: true, status: 'signed-in', me: { user: { githubId: 7, login: 'bob' } } };
77+
mocks.accountStatus.mockResolvedValue({ logged_in: true, user_id: '7' });
78+
mocks.accountConnectDevices.mockResolvedValue([]);
79+
mocks.accountListDevices.mockResolvedValue([{ device_id: 'new', device_name: 'New account device', online: true }]);
80+
await act(async () => { root.render(<AccountPanel onCloseDialog={() => {}} />); });
81+
await act(async () => { resolve([{ device_id: 'old', device_name: 'Old account device', online: true }]); });
82+
expect(container.textContent).toContain('New account device');
83+
expect(container.textContent).not.toContain('Old account device');
84+
});
85+
it('shows a connection failure while retaining the signed-in account', async () => {
86+
mocks.accountStatus.mockReset().mockRejectedValue(new Error('network unavailable'));
87+
await act(async () => { root.render(<AccountPanel onCloseDialog={() => {}} />); });
88+
expect(container.textContent).toContain('alice');
89+
expect(container.textContent).toContain('accountLogin.retryConnect');
90+
expect(container.textContent).not.toContain('accountLogin.loadingDevices');
91+
});
92+
93+
async function retryConnection() {
94+
const button = Array.from(container.querySelectorAll('button')).find(node => node.textContent === 'accountLogin.retryConnect');
95+
expect(button).toBeDefined();
96+
await act(async () => { button!.click(); });
97+
}
98+
it('retries a failed connection without asking for GitHub authorization again', async () => {
99+
mocks.accountStatus.mockResolvedValue({ logged_in: true, user_id: '42' });
100+
mocks.accountConnectDevices.mockRejectedValueOnce(new Error('socket failure'));
101+
await act(async () => { root.render(<AccountPanel onCloseDialog={() => {}} />); });
102+
await retryConnection();
103+
expect(container.textContent).toContain('My computer');
104+
expect(container.textContent).not.toContain('accountLogin.loadingDevices');
105+
expect(mocks.accountLogin).not.toHaveBeenCalled();
106+
});
107+
it('allows the new account to retry while an old recovery is still pending', async () => {
108+
let finishOld!: (devices: Array<{ device_id: string; device_name: string }>) => void;
109+
mocks.accountStatus.mockResolvedValue({ logged_in: true, user_id: '42' });
110+
mocks.accountConnectDevices.mockRejectedValueOnce(new Error('socket failure'))
111+
.mockReturnValueOnce(new Promise(res => { finishOld = res; }));
112+
await act(async () => { root.render(<AccountPanel onCloseDialog={() => {}} />); });
113+
await retryConnection();
114+
mocks.identity = { resolved: true, status: 'signed-in', me: { user: { githubId: 7, login: 'bob' } } };
115+
mocks.accountStatus.mockResolvedValue({ logged_in: true, user_id: '7' });
116+
mocks.accountConnectDevices.mockRejectedValueOnce(new Error('socket failure'))
117+
.mockResolvedValueOnce([{ device_id: 'new', device_name: 'New account device' }]);
118+
mocks.accountListDevices.mockResolvedValue([{ device_id: 'new', device_name: 'New account device', online: true }]);
119+
await act(async () => { root.render(<AccountPanel onCloseDialog={() => {}} />); });
120+
await retryConnection();
121+
expect(container.textContent).toContain('New account device');
122+
await act(async () => { finishOld([{ device_id: 'old', device_name: 'Old account device' }]); });
123+
expect(container.textContent).not.toContain('Old account device');
124+
expect(mocks.accountConnectDevices).toHaveBeenCalledTimes(4);
125+
});
126+
127+
it('adopts the account-bound local device ID without reconnecting', async () => {
128+
let finishInfo!: (info: { device_id: string }) => void;
129+
mocks.getDeviceInfo.mockReset().mockResolvedValueOnce({ device_id: 'before-auth' })
130+
.mockReturnValueOnce(new Promise(resolve => { finishInfo = resolve; }));
131+
await act(async () => { root.render(<AccountPanel onCloseDialog={() => {}} />); });
132+
expect(container.textContent).not.toContain('accountLogin.thisDevice');
133+
await act(async () => { finishInfo({ device_id: 'local' }); });
134+
expect(container.textContent).toContain('accountLogin.thisDevice');
135+
expect(mocks.accountConnectDevices).toHaveBeenCalledTimes(1);
136+
expect(mocks.accountStatus).toHaveBeenCalledTimes(1);
137+
});
138+
139+
it('finishes loading an empty device list', async () => {
140+
mocks.accountConnectDevices.mockResolvedValue([]);
141+
mocks.accountListDevices.mockResolvedValue([]);
142+
await act(async () => { root.render(<AccountPanel onCloseDialog={() => {}} />); });
143+
expect(container.textContent).not.toContain('accountLogin.loadingDevices');
144+
expect(mocks.accountListDevices).toHaveBeenCalledTimes(1);
145+
});
146+
147+
it('does not restart polling when a recovery snapshot finishes after logout', async () => {
148+
vi.useFakeTimers();
149+
try {
150+
let finishSnapshot!: (devices: []) => void;
151+
mocks.accountStatus.mockResolvedValue({ logged_in: true, user_id: '42' });
152+
mocks.accountConnectDevices.mockRejectedValueOnce(new Error('socket failure'));
153+
mocks.accountListDevices.mockReturnValueOnce(new Promise(resolve => { finishSnapshot = resolve; }));
154+
await act(async () => { root.render(<AccountPanel onCloseDialog={() => {}} />); });
155+
await retryConnection();
156+
mocks.identity = { resolved: true, status: 'signed-out', me: null };
157+
await act(async () => { root.render(<AccountPanel onCloseDialog={() => {}} />); });
158+
await act(async () => { finishSnapshot([]); });
159+
await act(async () => { await vi.advanceTimersByTimeAsync(60_000); });
160+
expect(mocks.accountListDevices).toHaveBeenCalledTimes(1);
161+
expect(container.textContent).toContain('accountLogin.login');
162+
} finally {
163+
vi.useRealTimers();
164+
}
165+
});
166+
167+
it('ignores pre-auth device information that arrives after the adopted ID', async () => {
168+
let finishOldInfo!: (info: { device_id: string }) => void;
169+
mocks.getDeviceInfo.mockReset()
170+
.mockReturnValueOnce(new Promise(resolve => { finishOldInfo = resolve; }))
171+
.mockResolvedValueOnce({ device_id: 'local' });
172+
await act(async () => { root.render(<AccountPanel onCloseDialog={() => {}} />); });
173+
expect(container.textContent).toContain('accountLogin.thisDevice');
174+
await act(async () => { finishOldInfo({ device_id: 'before-auth' }); });
175+
expect(container.textContent).toContain('accountLogin.thisDevice');
176+
expect(mocks.accountConnectDevices).toHaveBeenCalledTimes(1);
177+
});

‎src/web-ui/src/app/components/RemoteConnectDialog/AccountPanel.tsx‎

Lines changed: 31 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,11 @@ export const AccountPanel: React.FC<AccountPanelProps> = ({
8181

8282
const [devices, setDevices] = useState<AccountDeviceInfo[]>([]);
8383
const [localDeviceId, setLocalDeviceId] = useState<string | null>(null);
84+
// Device discovery updates presentation, not the account lifecycle. Keep
85+
// refresh callbacks stable so adopting an ID cannot restart initialization.
86+
const deviceInfoRequestRef = useRef(0);
87+
const localDeviceIdRef = useRef(localDeviceId);
88+
localDeviceIdRef.current = localDeviceId;
8489
/** True after either device presence or a list_devices response is available. */
8590
const [devicesReady, setDevicesReady] = useState(false);
8691
const [relayError, setRelayError] = useState<string | null>(null);
@@ -97,7 +102,7 @@ export const AccountPanel: React.FC<AccountPanelProps> = ({
97102
const deviceRoutingReadyRef = useRef(false);
98103
const deviceListFailureCountRef = useRef(0);
99104
/** Coalesce manual and background recovery so they never replace each other's WS. */
100-
const deviceReconnectInFlightRef = useRef(false);
105+
const deviceReconnectInFlightRef = useRef<number | null>(null);
101106
const invalidateAccountRequests = useCallback(() => {
102107
accountEpochRef.current += 1;
103108
refreshRequestRef.current += 1;
@@ -112,6 +117,15 @@ export const AccountPanel: React.FC<AccountPanelProps> = ({
112117
mountedRef.current && accountEpochRef.current === epoch
113118
), []);
114119

120+
const refreshLocalDeviceId = useCallback((epoch: number) => {
121+
const requestId = ++deviceInfoRequestRef.current;
122+
void remoteConnectAPI.getDeviceInfo().then(info => {
123+
if (isAccountEpochCurrent(epoch) && deviceInfoRequestRef.current === requestId) {
124+
setLocalDeviceId(info.device_id);
125+
}
126+
}).catch(error => { log.warn('getDeviceInfo failed', error); });
127+
}, [isAccountEpochCurrent]);
128+
115129
const sortedDevices = useMemo(() => [...devices].sort((left, right) => {
116130
const leftLocal = left.device_id === localDeviceId;
117131
const rightLocal = right.device_id === localDeviceId;
@@ -168,8 +182,9 @@ export const AccountPanel: React.FC<AccountPanelProps> = ({
168182
try {
169183
let list = await remoteConnectAPI.accountListDevices();
170184
if (!isCurrent()) return;
171-
const localOffline = list.some(d => d.device_id === localDeviceId && !d.online);
172-
if (localOffline && localDeviceId) {
185+
const currentLocalDeviceId = localDeviceIdRef.current;
186+
const localOffline = list.some(d => d.device_id === currentLocalDeviceId && !d.online);
187+
if (localOffline && currentLocalDeviceId) {
173188
await new Promise(r => setTimeout(r, 1500));
174189
if (!isCurrent()) return;
175190
list = await remoteConnectAPI.accountListDevices();
@@ -200,7 +215,7 @@ export const AccountPanel: React.FC<AccountPanelProps> = ({
200215
refreshInFlightRef.current = null;
201216
}
202217
}
203-
}, [localDeviceId, handleSessionExpired, isAccountEpochCurrent, markRelayUnreachable]);
218+
}, [handleSessionExpired, isAccountEpochCurrent, markRelayUnreachable]);
204219

205220
const applyPresenceOnline = useCallback((onlineDevices: Array<{ device_id: string; device_name: string }>) => {
206221
const onlineIds = new Set(onlineDevices.map(d => d.device_id));
@@ -243,12 +258,12 @@ export const AccountPanel: React.FC<AccountPanelProps> = ({
243258
}, []);
244259

245260
const attemptDeviceReconnect = useCallback(async (showLoading: boolean) => {
246-
if (deviceReconnectInFlightRef.current) {
261+
const epoch = accountEpochRef.current;
262+
if (deviceReconnectInFlightRef.current === epoch) {
247263
log.debug('Device routing recovery already in flight; coalescing duplicate request');
248264
return;
249265
}
250-
const epoch = accountEpochRef.current;
251-
deviceReconnectInFlightRef.current = true;
266+
deviceReconnectInFlightRef.current = epoch;
252267
if (showLoading) {
253268
setLoading(true);
254269
setRelayError(null);
@@ -265,15 +280,10 @@ export const AccountPanel: React.FC<AccountPanelProps> = ({
265280
applyPresenceOnline(onlineDevices);
266281
setDevicesReady(true);
267282
setRelayError(null);
268-
try {
269-
const info = await remoteConnectAPI.getDeviceInfo();
270-
if (!isAccountEpochCurrent(epoch)) return;
271-
setLocalDeviceId(info.device_id);
272-
} catch (error) {
273-
log.warn('getDeviceInfo after reconnect failed', error);
274-
}
283+
refreshLocalDeviceId(epoch);
275284
if (!isAccountEpochCurrent(epoch)) return;
276285
await refreshDevices();
286+
if (!isAccountEpochCurrent(epoch)) return;
277287
startDevicePolling();
278288
} catch (err) {
279289
log.warn(
@@ -287,7 +297,7 @@ export const AccountPanel: React.FC<AccountPanelProps> = ({
287297
}
288298
markRelayUnreachable();
289299
} finally {
290-
deviceReconnectInFlightRef.current = false;
300+
if (deviceReconnectInFlightRef.current === epoch) deviceReconnectInFlightRef.current = null;
291301
if (showLoading && isAccountEpochCurrent(epoch)) setLoading(false);
292302
}
293303
}, [
@@ -297,6 +307,7 @@ export const AccountPanel: React.FC<AccountPanelProps> = ({
297307
isAccountEpochCurrent,
298308
markRelayUnreachable,
299309
refreshDevices,
310+
refreshLocalDeviceId,
300311
startDevicePolling,
301312
]);
302313

@@ -330,13 +341,7 @@ export const AccountPanel: React.FC<AccountPanelProps> = ({
330341
setDevicesReady(true);
331342
setRelayError(null);
332343
// Re-read after AuthOk may have adopted the account-bound device_id.
333-
try {
334-
const info = await remoteConnectAPI.getDeviceInfo();
335-
if (!isAccountEpochCurrent(epoch)) return;
336-
setLocalDeviceId(info.device_id);
337-
} catch (e) {
338-
log.warn('getDeviceInfo after connect failed', e);
339-
}
344+
refreshLocalDeviceId(epoch);
340345
} catch (err) {
341346
if (!isAccountEpochCurrent(epoch)) return;
342347
log.warn('accountConnectDevices failed', err);
@@ -356,6 +361,7 @@ export const AccountPanel: React.FC<AccountPanelProps> = ({
356361
isAccountEpochCurrent,
357362
markRelayUnreachable,
358363
refreshDevices,
364+
refreshLocalDeviceId,
359365
startDevicePolling,
360366
]);
361367

@@ -382,11 +388,9 @@ export const AccountPanel: React.FC<AccountPanelProps> = ({
382388
// connection setup step, never a second GitHub login prompt.
383389
setView('devices');
384390
setActiveAccountEpoch(epoch);
385-
remoteConnectAPI.getDeviceInfo().then((info) => {
386-
if (isAccountEpochCurrent(epoch)) setLocalDeviceId(info.device_id);
387-
}).catch((e) => { log.warn('getDeviceInfo failed', e); });
391+
refreshLocalDeviceId(epoch);
388392
ensureAccountSession(remoteConnectAPI, () => isAccountEpochCurrent(epoch), githubId).then(async (ready) => {
389-
if (ready) await initializeDevices();
393+
if (ready && isAccountEpochCurrent(epoch)) await initializeDevices();
390394
}).catch((e) => {
391395
if (!isAccountEpochCurrent(epoch)) return;
392396
log.warn('account connection initialization failed', e);
@@ -405,6 +409,7 @@ export const AccountPanel: React.FC<AccountPanelProps> = ({
405409
invalidateAccountRequests,
406410
isAccountEpochCurrent,
407411
markRelayUnreachable,
412+
refreshLocalDeviceId,
408413
resetState,
409414
]);
410415

0 commit comments

Comments
 (0)