diff --git a/mobile/app/index.tsx b/mobile/app/index.tsx index 3c8c18f7ebc..47034eb8abf 100644 --- a/mobile/app/index.tsx +++ b/mobile/app/index.tsx @@ -32,7 +32,10 @@ import type { RpcClient } from '../src/transport/rpc-client' import { sendSingleFlightRequest } from '../src/transport/request-single-flight' import { useCloseHost, useForceReconnect, usePrimeHosts } from '../src/transport/client-context' import { useAllHostClients } from '../src/transport/all-host-client-connections' -import { selectHomeAutoConnectHostIds } from '../src/transport/home-host-auto-connect' +import { + resolveHomeHostConnectionState, + selectHomeAutoConnectHostIds +} from '../src/transport/home-host-auto-connect' import { classifyConnection } from '../src/transport/connection-health' import { subscribeToDesktopNotifications } from '../src/notifications/mobile-notifications' import { @@ -777,7 +780,11 @@ export default function HomeScreen() { } ItemSeparatorComponent={CardGap} renderItem={({ item }) => { - const state = hostStates[item.id] ?? 'connecting' + const state = resolveHomeHostConnectionState( + item.id, + hostStates[item.id], + autoConnectHostIds + ) const attempts = hostAttempts[item.id] ?? 0 const lastConnectedAt = hostLastConnected[item.id] ?? null const info = worktreeInfo[item.id] @@ -975,7 +982,11 @@ export default function HomeScreen() { if (!host) { return [] } - const state = hostStates[host.id] ?? 'connecting' + const state = resolveHomeHostConnectionState( + host.id, + hostStates[host.id], + autoConnectHostIds + ) const isLive = state === 'connected' || state === 'connecting' || diff --git a/mobile/src/transport/all-host-client-connections.ts b/mobile/src/transport/all-host-client-connections.ts index e06ed1e62a4..ae64b46ad05 100644 --- a/mobile/src/transport/all-host-client-connections.ts +++ b/mobile/src/transport/all-host-client-connections.ts @@ -38,7 +38,6 @@ export function useAllHostClients(hostIds: string[], options?: UseAllHostClients ctx.release(id) } } - // eslint-disable-next-line react-hooks/exhaustive-deps }, [key]) return useMemo(() => { @@ -56,6 +55,5 @@ export function useAllHostClients(hostIds: string[], options?: UseAllHostClients ? [{ hostId, client, state: ctx.getState(hostId), path: ctx.getActivePath(hostId) }] : [] }) - // eslint-disable-next-line react-hooks/exhaustive-deps }, [key, tick]) } diff --git a/mobile/src/transport/client-context.test.ts b/mobile/src/transport/client-context.test.ts index 86dbbcdb816..d697015a622 100644 --- a/mobile/src/transport/client-context.test.ts +++ b/mobile/src/transport/client-context.test.ts @@ -20,8 +20,9 @@ vi.mock('./connection-revival-triggers', () => ({ subscribeConnectionRevivalTriggers: () => () => {} })) -import { RpcClientProvider, useCloseHost, useHostClient } from './client-context' +import { RpcClientProvider, useCloseHost, useForceReconnect, useHostClient } from './client-context' import { useAllHostClients } from './all-host-client-connections' +import { selectHomeAutoConnectHostIds } from './home-host-auto-connect' type FakeClient = RpcClient & { emitState: (state: ConnectionState) => void @@ -344,4 +345,95 @@ describe('useAllHostClients', () => { act(() => renderer?.unmount()) } }) + + it('keeps startup connection fanout constant for a large saved-host list', async () => { + const hosts = Array.from({ length: 1_000 }, (_, index) => ({ + ...HOST, + id: `host-${index}`, + name: `Host ${index}`, + lastConnected: index + })) + const hostIds = hosts.map((host) => host.id) + const autoConnectHostIds = selectHomeAutoConnectHostIds(hosts) + connectMock.mockReturnValue(makeFakeClient('connected')) + loadHostsMock.mockResolvedValue(hosts) + + let renderer: ReactTestRenderer | null = null + function Probe(): null { + useAllHostClients(hostIds, { autoConnectHostIds }) + return null + } + + const restore = suppressReactTestRendererDeprecationWarning() + try { + await act(async () => { + renderer = create(createElement(RpcClientProvider, null, createElement(Probe))) + await Promise.resolve() + }) + expect(connectMock).toHaveBeenCalledTimes(3) + expect(connectMock.mock.calls.map(([host]) => host.id)).toEqual([ + 'host-999', + 'host-998', + 'host-997' + ]) + } finally { + restore() + act(() => renderer?.unmount()) + } + }) + + it('retains connect-all behavior when no startup subset is provided', async () => { + const host2 = { ...HOST, id: 'host-2', name: 'Host 2' } + connectMock.mockReturnValue(makeFakeClient('connected')) + loadHostsMock.mockResolvedValue([HOST, host2]) + + let renderer: ReactTestRenderer | null = null + function Probe(): null { + useAllHostClients([HOST.id, host2.id]) + return null + } + + const restore = suppressReactTestRendererDeprecationWarning() + try { + await act(async () => { + renderer = create(createElement(RpcClientProvider, null, createElement(Probe))) + await Promise.resolve() + }) + expect(connectMock).toHaveBeenCalledTimes(2) + } finally { + restore() + act(() => renderer?.unmount()) + } + }) + + it('allows an excluded host to connect manually', async () => { + connectMock.mockReturnValue(makeFakeClient('connected')) + loadHostsMock.mockResolvedValue([HOST]) + + let reconnect: ((hostId: string) => Promise) | null = null + let renderer: ReactTestRenderer | null = null + function Probe(): null { + useAllHostClients([HOST.id], { autoConnectHostIds: [] }) + reconnect = useForceReconnect() + return null + } + + const restore = suppressReactTestRendererDeprecationWarning() + try { + await act(async () => { + renderer = create(createElement(RpcClientProvider, null, createElement(Probe))) + }) + expect(connectMock).not.toHaveBeenCalled() + if (!reconnect) { + throw new Error('reconnect harness did not initialize') + } + await act(async () => { + await reconnect?.(HOST.id) + }) + expect(connectMock).toHaveBeenCalledOnce() + } finally { + restore() + act(() => renderer?.unmount()) + } + }) }) diff --git a/mobile/src/transport/home-host-auto-connect.test.ts b/mobile/src/transport/home-host-auto-connect.test.ts index 53f0bafce1d..ecf04934809 100644 --- a/mobile/src/transport/home-host-auto-connect.test.ts +++ b/mobile/src/transport/home-host-auto-connect.test.ts @@ -1,6 +1,10 @@ import { describe, expect, it } from 'vitest' import type { HostProfile } from './types' -import { HOME_AUTO_CONNECT_LIMIT, selectHomeAutoConnectHostIds } from './home-host-auto-connect' +import { + HOME_AUTO_CONNECT_LIMIT, + resolveHomeHostConnectionState, + selectHomeAutoConnectHostIds +} from './home-host-auto-connect' function host(id: string, lastConnected: number, credentials = true): HostProfile { return { @@ -13,7 +17,7 @@ function host(id: string, lastConnected: number, credentials = true): HostProfil } } -describe('selectHomeAutoConnectHostIds', () => { +describe('home host auto-connect', () => { it('limits startup connections to the most recently used credentialed hosts', () => { const hosts = [ host('old', 1), @@ -35,4 +39,18 @@ describe('selectHomeAutoConnectHostIds', () => { expect(hosts.map((item) => item.id)).toEqual(['old', 'new']) }) + + it('only presents hosts in the startup subset as connecting before clients open', () => { + const autoConnectHostIds = ['recent'] + + expect(resolveHomeHostConnectionState('recent', undefined, autoConnectHostIds)).toBe( + 'connecting' + ) + expect(resolveHomeHostConnectionState('stale', undefined, autoConnectHostIds)).toBe( + 'disconnected' + ) + expect(resolveHomeHostConnectionState('stale', 'connected', autoConnectHostIds)).toBe( + 'connected' + ) + }) }) diff --git a/mobile/src/transport/home-host-auto-connect.ts b/mobile/src/transport/home-host-auto-connect.ts index 94bb8ab2bd1..995d653a0df 100644 --- a/mobile/src/transport/home-host-auto-connect.ts +++ b/mobile/src/transport/home-host-auto-connect.ts @@ -1,4 +1,4 @@ -import type { HostProfile } from './types' +import type { ConnectionState, HostProfile } from './types' export const HOME_AUTO_CONNECT_LIMIT = 3 @@ -14,3 +14,11 @@ export function selectHomeAutoConnectHostIds( .slice(0, Math.max(0, limit)) .map((host) => host.id) } + +export function resolveHomeHostConnectionState( + hostId: string, + state: ConnectionState | undefined, + autoConnectHostIds: readonly string[] +): ConnectionState { + return state ?? (autoConnectHostIds.includes(hostId) ? 'connecting' : 'disconnected') +}