Revert "fix(mobile): bound home host auto-connect fanout (#11642)"

This reverts commit 89f00658d2 for the 1.4.176 release.
This commit is contained in:
Jinwoo-H 2026-08-07 00:53:19 -04:00
parent d09863e068
commit d2e8bbe109
12 changed files with 137 additions and 1372 deletions

View File

@ -27,10 +27,6 @@ import { createHostConnectRefetchGate } from '../src/transport/host-connect-refe
import { sendSingleFlightRequest } from '../src/transport/request-single-flight'
import { useCloseHost, useForceReconnect, usePrimeHosts } from '../src/transport/client-context'
import { useAllHostClients } from '../src/transport/use-all-host-clients'
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 {
@ -245,11 +241,7 @@ export default function HomeScreen() {
const hostIds = useMemo(() => hosts.map((h) => h.id), [hosts])
// Why: scoped to the paired hosts so an unpaired desktop's cached reply leaves the header total.
const stats = useMemo(() => totalHomeStats(statsByHost, hostIds), [statsByHost, hostIds])
const autoConnectHostIds = useMemo(() => selectHomeAutoConnectHostIds(hosts), [hosts])
const allClients = useAllHostClients(hostIds, {
autoConnectHostIds,
closeUnusedOnRelease: true
})
const allClients = useAllHostClients(hostIds)
const hostPaths = useMemo(
() => Object.fromEntries(allClients.map(({ hostId, path }) => [hostId, path])),
[allClients]
@ -748,11 +740,7 @@ export default function HomeScreen() {
}
ItemSeparatorComponent={CardGap}
renderItem={({ item }) => {
const state = resolveHomeHostConnectionState(
item.id,
hostStates[item.id],
autoConnectHostIds
)
const state = hostStates[item.id] ?? 'connecting'
const attempts = hostAttempts[item.id] ?? 0
const lastConnectedAt = hostLastConnected[item.id] ?? null
const verdict = classifyConnection({
@ -937,13 +925,7 @@ export default function HomeScreen() {
message={actionTarget ? hostEndpointLabel(actionTarget.endpoint) : undefined}
actions={getHostListActionSheetActions({
host: actionTarget,
state: actionTarget
? resolveHomeHostConnectionState(
actionTarget.id,
hostStates[actionTarget.id],
autoConnectHostIds
)
: 'disconnected',
state: actionTarget ? (hostStates[actionTarget.id] ?? 'connecting') : 'disconnected',
hasEverConnected: actionTarget
? (hostLastConnected[actionTarget.id] ?? null) != null
: false,

View File

@ -12,7 +12,7 @@ import { ChevronLeft, ChevronRight, Smartphone, Type } from 'lucide-react-native
import { colors, radii, spacing, typography } from '../src/theme/mobile-theme'
import { loadHosts } from '../src/transport/host-store'
import type { HostProfile } from '../src/transport/types'
import { useFocusedSettingsHostClients } from '../src/transport/settings-host-client-connections'
import { useAllHostClients } from '../src/transport/use-all-host-clients'
import type { RpcClient } from '../src/transport/rpc-client'
import { PickerModal, type PickerOption } from '../src/components/PickerModal'
import { TerminalShortcutSettings } from '../src/components/TerminalShortcutSettings'
@ -127,7 +127,7 @@ export default function TerminalSettingsScreen() {
void loadHosts().then(setHosts)
}, [])
const hostIds = useMemo(() => hosts.map((h) => h.id), [hosts])
const { clients: hostClients } = useFocusedSettingsHostClients(hostIds)
const hostClients = useAllHostClients(hostIds)
const hostClientsById = useMemo(
() => new Map(hostClients.map((entry) => [entry.hostId, entry.client])),
[hostClients]

View File

@ -9,12 +9,12 @@ import {
View
} from 'react-native'
import { useSafeAreaInsets } from 'react-native-safe-area-context'
import { useRouter } from 'expo-router'
import { useFocusEffect, useRouter } from 'expo-router'
import { ChevronLeft, ChevronRight } from 'lucide-react-native'
import { colors, radii, spacing, typography } from '../src/theme/mobile-theme'
import { loadHosts } from '../src/transport/host-store'
import type { HostProfile } from '../src/transport/types'
import { useFocusedSettingsHostClients } from '../src/transport/settings-host-client-connections'
import { useAllHostClients } from '../src/transport/use-all-host-clients'
import type { RpcClient } from '../src/transport/rpc-client'
import { BottomDrawer } from '../src/components/BottomDrawer'
import { VoiceModelList } from '../src/components/VoiceModelList'
@ -47,7 +47,7 @@ export default function VoiceSettingsScreen(): React.JSX.Element {
void loadHosts().then(setHosts)
}, [])
const hostIds = useMemo(() => hosts.map((h) => h.id), [hosts])
const { clients: hostClients, focused: routeFocused } = useFocusedSettingsHostClients(hostIds)
const hostClients = useAllHostClients(hostIds)
// Voice dictation runs on the paired desktop, so pick the first connected host.
const client: RpcClient | null = useMemo(
() => hostClients.find((entry) => entry.state === 'connected')?.client ?? null,
@ -59,6 +59,15 @@ export default function VoiceSettingsScreen(): React.JSX.Element {
const [error, setError] = useState<string | null>(null)
const [busyAction, setBusyAction] = useState<ModelBusyAction | null>(null)
const [modelDrawerOpen, setModelDrawerOpen] = useState(false)
const [routeFocused, setRouteFocused] = useState(false)
useFocusEffect(
useCallback(() => {
setRouteFocused(true)
return () => setRouteFocused(false)
}, [])
)
const refresh = useCallback(async (): Promise<boolean | undefined> => {
if (!client) {
return false

View File

@ -21,8 +21,6 @@ vi.mock('./connection-revival-triggers', () => ({
}))
import { RpcClientProvider, useCloseHost, useForceReconnect, useHostClient } from './client-context'
import { useAllHostClients } from './use-all-host-clients'
import { selectHomeAutoConnectHostIds } from './home-host-auto-connect'
type FakeClient = RpcClient & {
emitState: (state: ConnectionState) => void
@ -388,179 +386,3 @@ describe('useHostClient', () => {
expect(connectMock).not.toHaveBeenCalled()
})
})
describe('useAllHostClients', () => {
it('only opens the requested startup subset', 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], { autoConnectHostIds: [host2.id] })
return null
}
const restore = suppressReactTestRendererDeprecationWarning()
try {
await act(async () => {
renderer = create(createElement(RpcClientProvider, null, createElement(Probe)))
await Promise.resolve()
})
expect(connectMock).toHaveBeenCalledOnce()
expect(connectMock).toHaveBeenCalledWith(host2, expect.any(Function))
} finally {
restore()
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('closes a demoted Home client when the recent-host set rotates', async () => {
const hosts = [
{ ...HOST, id: 'host-a', lastConnected: 4 },
{ ...HOST, id: 'host-b', lastConnected: 3 },
{ ...HOST, id: 'host-c', lastConnected: 2 },
{ ...HOST, id: 'host-d', lastConnected: 1 }
]
const clients = new Map<string, FakeClient>()
connectMock.mockImplementation((profile: typeof HOST) => {
const client = makeFakeClient('connected')
clients.set(profile.id, client)
return client
})
loadHostsMock.mockResolvedValue(hosts)
let activeHostIds: string[] = []
let renderer: ReactTestRenderer | null = null
function Probe({ profiles }: { profiles: typeof hosts }): null {
const hostIds = profiles.map((host) => host.id)
activeHostIds = useAllHostClients(hostIds, {
autoConnectHostIds: selectHomeAutoConnectHostIds(profiles),
closeUnusedOnRelease: true
}).map(({ hostId }) => hostId)
return null
}
const restore = suppressReactTestRendererDeprecationWarning()
try {
await act(async () => {
renderer = create(
createElement(RpcClientProvider, null, createElement(Probe, { profiles: hosts }))
)
await Promise.resolve()
})
expect(activeHostIds.sort()).toEqual(['host-a', 'host-b', 'host-c'])
const rotatedHosts = hosts.map((host) =>
host.id === 'host-d' ? { ...host, lastConnected: 5 } : host
)
await act(async () => {
renderer?.update(
createElement(RpcClientProvider, null, createElement(Probe, { profiles: rotatedHosts }))
)
await Promise.resolve()
})
expect(connectMock).toHaveBeenCalledTimes(4)
expect(activeHostIds.sort()).toEqual(['host-a', 'host-b', 'host-d'])
expect(clients.get('host-a')?.closeMock).not.toHaveBeenCalled()
expect(clients.get('host-b')?.closeMock).not.toHaveBeenCalled()
expect(clients.get('host-c')?.closeMock).toHaveBeenCalledOnce()
expect(clients.get('host-d')?.closeMock).not.toHaveBeenCalled()
} 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<void>) | 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())
}
})
})

View File

@ -14,20 +14,9 @@ import type { RpcClient } from './rpc-client'
import { connectionLogStore } from './connection-log-buffer'
import { subscribeConnectionRevivalTriggers } from './connection-revival-triggers'
import { HostClientOpenRegistry } from './host-client-open-registry'
import { decrementPendingAcquisition } from './host-client-acquisition-count'
import {
clientActivePath,
listHostClients,
notifyAllHostListeners,
notifyHostStateListeners,
primeHostProfiles,
subscribeAllHostListener,
subscribeHostStateListener,
type CloseEntryOptions
} from './host-client-context-state'
import { loadHosts } from './host-store'
import { openHostLogicalClient } from './host-logical-client'
import type { MobileConnectionPath } from './stable-logical-rpc-client'
import type { MobileConnectionPath, StableLogicalRpcClient } from './stable-logical-rpc-client'
import type { ConnectionState, HostProfile } from './types'
type StoreEntry = {
@ -40,8 +29,6 @@ type StoreEntry = {
export type RpcClientContextValue = {
acquire: (hostId: string, host?: HostProfile) => RpcClient | null
release: (hostId: string) => void
releaseAndCloseIfUnused: (hostId: string) => void
closeIfUnused: (hostId: string) => void
forceReconnect: (hostId: string) => Promise<void>
closeHost: (hostId: string) => void
getState: (hostId: string) => ConnectionState
@ -68,27 +55,30 @@ export function RpcClientProvider({ children }: { children: ReactNode }) {
// Pending opens keyed by hostId so two acquire() callers in the same render don't race the host lookup.
const pendingOpensRef = useRef(new HostClientOpenRegistry())
const pendingAcquisitionsRef = useRef<Map<string, number>>(new Map())
// Why: cache of already-loaded HostProfiles so openEntry can skip a second loadHosts()/Keychain pass on cold start.
const primedHostsRef = useRef<Map<string, HostProfile>>(new Map())
const notifyHostState = (hostId: string, state: ConnectionState) =>
notifyHostStateListeners(stateListenersRef.current, hostId, state)
const notifyAllHosts = () => notifyAllHostListeners(allHostsListenersRef.current)
function notifyHostState(hostId: string, state: ConnectionState) {
const set = stateListenersRef.current.get(hostId)
if (!set) {
return
}
for (const listener of set) {
listener(state)
}
}
const closeEntry = useCallback((hostId: string, options: CloseEntryOptions) => {
const entry = storeRef.current.get(hostId)
const acquisitionCount = entry?.refCount ?? pendingAcquisitionsRef.current.get(hostId) ?? 0
function notifyAllHosts() {
for (const listener of allHostsListenersRef.current) {
listener()
}
}
const closeEntry = useCallback((hostId: string) => {
pendingOpensRef.current.cancel(hostId)
if (options.preserveAcquisitions && acquisitionCount > 0) {
pendingAcquisitionsRef.current.set(hostId, acquisitionCount)
} else {
pendingAcquisitionsRef.current.delete(hostId)
}
if (options.forgetPrimedHost) {
primedHostsRef.current.delete(hostId)
}
primedHostsRef.current.delete(hostId)
const entry = storeRef.current.get(hostId)
entry?.unsubState()
storeRef.current.delete(hostId)
entry?.client.close()
@ -96,13 +86,6 @@ export function RpcClientProvider({ children }: { children: ReactNode }) {
notifyAllHosts()
}, [])
const closeHost = useCallback(
(hostId: string) => {
closeEntry(hostId, { forgetPrimedHost: true, preserveAcquisitions: true })
},
[closeEntry]
)
const openEntry = useCallback(async (hostId: string): Promise<StoreEntry | null> => {
const existing = pendingOpensRef.current.getActivePromise(hostId)
if (existing) {
@ -168,10 +151,9 @@ export function RpcClientProvider({ children }: { children: ReactNode }) {
const entry: StoreEntry = {
client,
state: client.getState(),
refCount: pendingAcquisitionsRef.current.get(hostId) ?? 0,
refCount: 0,
unsubState
}
pendingAcquisitionsRef.current.delete(hostId)
storeRef.current.set(hostId, entry)
notifyHostState(hostId, entry.state)
notifyAllHosts()
@ -193,83 +175,50 @@ export function RpcClientProvider({ children }: { children: ReactNode }) {
existing.refCount += 1
return existing.client
}
pendingAcquisitionsRef.current.set(
hostId,
(pendingAcquisitionsRef.current.get(hostId) ?? 0) + 1
)
// Trigger async open; returns null this tick — consumers re-call acquire() from an effect that re-runs on state changes.
void openEntry(hostId)
void openEntry(hostId).then((entry) => {
if (!entry) {
return
}
entry.refCount += 1
})
return null
},
[openEntry]
)
const primeHosts = useCallback(
(hosts: HostProfile[]) => primeHostProfiles(primedHostsRef.current, hosts),
[]
)
const primeHosts = useCallback((hosts: HostProfile[]) => {
for (const host of hosts) {
primedHostsRef.current.set(host.id, host)
}
}, [])
// Why: no idle-close on refcount→0 — transient nav gaps flashed false 'disconnected', so keep sockets alive while foregrounded.
const release = useCallback((hostId: string) => {
const entry = storeRef.current.get(hostId)
if (entry) {
entry.refCount = Math.max(0, entry.refCount - 1)
if (!entry) {
return
}
decrementPendingAcquisition(pendingAcquisitionsRef.current, hostId)
entry.refCount = Math.max(0, entry.refCount - 1)
}, [])
const releaseAndCloseIfUnused = useCallback(
(hostId: string) => {
const entry = storeRef.current.get(hostId)
if (entry) {
entry.refCount = Math.max(0, entry.refCount - 1)
if (entry.refCount === 0) {
closeEntry(hostId, { forgetPrimedHost: false, preserveAcquisitions: false })
}
return
}
if (decrementPendingAcquisition(pendingAcquisitionsRef.current, hostId) === 0) {
closeEntry(hostId, { forgetPrimedHost: false, preserveAcquisitions: false })
}
},
[closeEntry]
)
const closeIfUnused = useCallback(
(hostId: string) => {
const entry = storeRef.current.get(hostId)
const pendingCount = pendingAcquisitionsRef.current.get(hostId)
const hasPendingOpen = pendingOpensRef.current.getActivePromise(hostId) !== null
if (!entry && pendingCount === undefined && !hasPendingOpen) {
return
}
if ((entry?.refCount ?? pendingCount ?? 0) === 0) {
closeEntry(hostId, { forgetPrimedHost: false, preserveAcquisitions: false })
}
},
[closeEntry]
)
const forceReconnect = useCallback(
async (hostId: string) => {
const entry = storeRef.current.get(hostId)
// Why: ownership survives explicit close/re-pair while observers never become synthetic owners.
const savedRefCount = entry?.refCount ?? pendingAcquisitionsRef.current.get(hostId) ?? 0
// Why: preserve refcount across the swap; via Disconnect→Reconnect the entry is already gone, so fall back to listener count.
const listenerCount = stateListenersRef.current.get(hostId)?.size ?? 0
const savedRefCount = entry?.refCount ?? Math.max(1, listenerCount)
if (entry) {
entry.unsubState()
entry.client.close()
storeRef.current.delete(hostId)
}
if (savedRefCount > 0) {
pendingAcquisitionsRef.current.set(
hostId,
Math.max(savedRefCount, pendingAcquisitionsRef.current.get(hostId) ?? 0)
)
}
// Why: Retry must read amber for the whole reopen, not grey-then-amber.
notifyHostState(hostId, 'connecting')
await openEntry(hostId)
const fresh = await openEntry(hostId)
if (fresh) {
fresh.refCount = savedRefCount
}
},
[openEntry]
)
@ -303,17 +252,41 @@ export function RpcClientProvider({ children }: { children: ReactNode }) {
}, [])
const subscribeHostState = useCallback(
(hostId: string, listener: (state: ConnectionState) => void) =>
subscribeHostStateListener(stateListenersRef.current, hostId, listener),
(hostId: string, listener: (state: ConnectionState) => void) => {
let set = stateListenersRef.current.get(hostId)
if (!set) {
set = new Set()
stateListenersRef.current.set(hostId, set)
}
set.add(listener)
return () => {
const s = stateListenersRef.current.get(hostId)
if (!s) {
return
}
s.delete(listener)
if (s.size === 0) {
stateListenersRef.current.delete(hostId)
}
}
},
[]
)
const getAllClients = useCallback(() => listHostClients(storeRef.current), [])
const getAllClients = useCallback((): Array<{ hostId: string; client: RpcClient }> => {
const out: Array<{ hostId: string; client: RpcClient }> = []
for (const [hostId, entry] of storeRef.current) {
out.push({ hostId, client: entry.client })
}
return out
}, [])
const subscribeAllHosts = useCallback(
(listener: () => void) => subscribeAllHostListener(allHostsListenersRef.current, listener),
[]
)
const subscribeAllHosts = useCallback((listener: () => void) => {
allHostsListenersRef.current.add(listener)
return () => {
allHostsListenersRef.current.delete(listener)
}
}, [])
// Close all clients on provider unmount. Empty deps: [closeEntry] would let Fast Refresh tear down all live sockets.
// eslint-disable-next-line react-hooks/exhaustive-deps
@ -321,9 +294,8 @@ export function RpcClientProvider({ children }: { children: ReactNode }) {
const store = storeRef.current
return () => {
pendingOpensRef.current.cancelAll()
pendingAcquisitionsRef.current.clear()
for (const [hostId] of store) {
closeEntry(hostId, { forgetPrimedHost: true, preserveAcquisitions: false })
closeEntry(hostId)
}
}
}, [])
@ -341,10 +313,8 @@ export function RpcClientProvider({ children }: { children: ReactNode }) {
() => ({
acquire,
release,
releaseAndCloseIfUnused,
closeIfUnused,
forceReconnect,
closeHost,
closeHost: closeEntry,
getState,
getKnownState,
getReconnectAttempt,
@ -358,10 +328,8 @@ export function RpcClientProvider({ children }: { children: ReactNode }) {
[
acquire,
release,
releaseAndCloseIfUnused,
closeIfUnused,
forceReconnect,
closeHost,
closeEntry,
getState,
getKnownState,
getReconnectAttempt,
@ -469,3 +437,13 @@ export function usePrimeHosts(): (hosts: HostProfile[]) => void {
const ctx = useRpcClientContext()
return ctx.primeHosts
}
function clientActivePath(client: RpcClient | undefined): MobileConnectionPath {
const logical = client as Partial<StableLogicalRpcClient> | undefined
if (typeof logical?.getActivePath !== 'function') {
return 'lan'
}
// Why: mid-migration the active path still names the session being replaced; the
// pending one is what the user is actually waiting on (F5).
return logical.getPendingPath?.() ?? logical.getActivePath()
}

View File

@ -1,56 +0,0 @@
import { describe, expect, it } from 'vitest'
import type { HostProfile } from './types'
import {
HOME_AUTO_CONNECT_LIMIT,
resolveHomeHostConnectionState,
selectHomeAutoConnectHostIds
} from './home-host-auto-connect'
function host(id: string, lastConnected: number, credentials = true): HostProfile {
return {
id,
name: id,
endpoint: `ws://${id}`,
deviceToken: credentials ? `token-${id}` : '',
publicKeyB64: credentials ? `key-${id}` : '',
lastConnected
}
}
describe('home host auto-connect', () => {
it('limits startup connections to the most recently used credentialed hosts', () => {
const hosts = [
host('old', 1),
host('newest', 5),
host('second', 4),
host('third', 3),
host('fourth', 2),
host('missing-credentials', 6, false)
]
expect(selectHomeAutoConnectHostIds(hosts)).toEqual(['newest', 'second', 'third'])
expect(selectHomeAutoConnectHostIds(hosts)).toHaveLength(HOME_AUTO_CONNECT_LIMIT)
})
it('does not mutate the host card order', () => {
const hosts = [host('old', 1), host('new', 2)]
selectHomeAutoConnectHostIds(hosts)
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'
)
})
})

View File

@ -1,24 +0,0 @@
import type { ConnectionState, HostProfile } from './types'
export const HOME_AUTO_CONNECT_LIMIT = 3
export function selectHomeAutoConnectHostIds(
hosts: readonly HostProfile[],
limit = HOME_AUTO_CONNECT_LIMIT
): string[] {
return [...hosts]
.filter((host) => host.deviceToken.length > 0 && host.publicKeyB64.length > 0)
.sort(
(left, right) => right.lastConnected - left.lastConnected || left.id.localeCompare(right.id)
)
.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')
}

View File

@ -1,9 +0,0 @@
export function decrementPendingAcquisition(pending: Map<string, number>, hostId: string): number {
const next = Math.max(0, (pending.get(hostId) ?? 0) - 1)
if (next === 0) {
pending.delete(hostId)
} else {
pending.set(hostId, next)
}
return next
}

View File

@ -1,72 +0,0 @@
import type { RpcClient } from './rpc-client'
import type { MobileConnectionPath, StableLogicalRpcClient } from './stable-logical-rpc-client'
import type { ConnectionState, HostProfile } from './types'
export type CloseEntryOptions = {
forgetPrimedHost: boolean
preserveAcquisitions: boolean
}
export function notifyHostStateListeners(
listeners: Map<string, Set<(state: ConnectionState) => void>>,
hostId: string,
state: ConnectionState
): void {
for (const listener of listeners.get(hostId) ?? []) {
listener(state)
}
}
export function notifyAllHostListeners(listeners: Set<() => void>): void {
for (const listener of listeners) {
listener()
}
}
export function subscribeHostStateListener(
listeners: Map<string, Set<(state: ConnectionState) => void>>,
hostId: string,
listener: (state: ConnectionState) => void
): () => void {
let hostListeners = listeners.get(hostId)
if (!hostListeners) {
hostListeners = new Set()
listeners.set(hostId, hostListeners)
}
hostListeners.add(listener)
return () => {
hostListeners.delete(listener)
if (hostListeners.size === 0) {
listeners.delete(hostId)
}
}
}
export function subscribeAllHostListener(
listeners: Set<() => void>,
listener: () => void
): () => void {
listeners.add(listener)
return () => listeners.delete(listener)
}
export function listHostClients(
entries: ReadonlyMap<string, { client: RpcClient }>
): { hostId: string; client: RpcClient }[] {
return [...entries].map(([hostId, entry]) => ({ hostId, client: entry.client }))
}
export function primeHostProfiles(cache: Map<string, HostProfile>, hosts: HostProfile[]): void {
for (const host of hosts) {
cache.set(host.id, host)
}
}
export function clientActivePath(client: RpcClient | undefined): MobileConnectionPath {
const logical = client as Partial<StableLogicalRpcClient> | undefined
if (typeof logical?.getActivePath !== 'function') {
return 'lan'
}
// Why: during migration the pending path is what the user is waiting on.
return logical.getPendingPath?.() ?? logical.getActivePath()
}

View File

@ -1,19 +0,0 @@
import { useCallback, useState } from 'react'
import { useFocusEffect } from 'expo-router'
import { useAllHostClients } from './use-all-host-clients'
export function useFocusedSettingsHostClients(hostIds: string[]) {
const [focused, setFocused] = useState(false)
useFocusEffect(
useCallback(() => {
setFocused(true)
return () => setFocused(false)
}, [])
)
const clients = useAllHostClients(focused ? hostIds : [], {
closeUnusedOnRelease: true
})
return { clients, focused }
}

View File

@ -1,776 +0,0 @@
import { createElement, Fragment, useEffect } from 'react'
import { act, create, type ReactTestRenderer } from 'react-test-renderer'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { useAllHostClients } from './use-all-host-clients'
import { useFocusedSettingsHostClients } from './settings-host-client-connections'
import {
RpcClientProvider,
useHostClient,
usePrimeHosts,
useRpcClientContext,
type RpcClientContextValue
} from './client-context'
import { selectHomeAutoConnectHostIds } from './home-host-auto-connect'
import type { RpcClient } from './rpc-client'
import type { ConnectionState, HostProfile } from './types'
const connectMock = vi.fn()
const loadHostsMock = vi.fn()
const routeFocus = vi.hoisted(() => ({
effect: null as null | (() => void | (() => void))
}))
vi.mock('expo-router', () => ({
useFocusEffect: (effect: () => void | (() => void)) => {
routeFocus.effect = effect
}
}))
vi.mock('./host-logical-client', () => ({
openHostLogicalClient: (...args: unknown[]) => connectMock(...args)
}))
vi.mock('./host-store', () => ({
loadHosts: () => loadHostsMock()
}))
vi.mock('./connection-revival-triggers', () => ({
subscribeConnectionRevivalTriggers: () => () => {}
}))
type FakeClient = RpcClient & {
closeMock: ReturnType<typeof vi.fn>
}
function makeFakeClient(initialState: ConnectionState): FakeClient {
const listeners = new Set<(state: ConnectionState) => void>()
const closeMock = vi.fn()
return {
sendRequest: vi.fn(),
subscribe: vi.fn(() => () => {}),
updateTerminalSubscriptionViewport: vi.fn(),
getState: () => initialState,
getReconnectAttempt: () => (initialState === 'reconnecting' ? 4 : 0),
getLastConnectedAt: () => null,
onStateChange: (listener) => {
listeners.add(listener)
return () => listeners.delete(listener)
},
notifyForeground: vi.fn(),
close: closeMock,
closeMock
} as FakeClient
}
function host(id: string, lastConnected: number, extra: Partial<HostProfile> = {}): HostProfile {
return {
id,
name: id,
endpoint: `ws://${id}.internal:8787`,
deviceToken: `token-${id}`,
publicKeyB64: `key-${id}`,
lastConnected,
...extra
}
}
const HOSTS = [
host('direct-recent', 50),
host('relay-recent', 40, { relayHostId: 'AbCdEf0123_-xyZ9' }),
host('ssh-provider-recent', 30),
host('folder-workspace-host', 20),
host('offline-host', 10)
]
const HOST_IDS = HOSTS.map((profile) => profile.id)
const HOME_HOST_IDS = selectHomeAutoConnectHostIds(HOSTS)
let context: RpcClientContextValue | null = null
function ContextProbe(): null {
context = useRpcClientContext()
return null
}
function HomeProbe(): null {
useAllHostClients(HOST_IDS, {
autoConnectHostIds: HOME_HOST_IDS,
closeUnusedOnRelease: true
})
const primeHosts = usePrimeHosts()
useEffect(() => {
primeHosts(HOSTS)
}, [primeHosts])
return null
}
function SettingsProbe(): null {
useAllHostClients(HOST_IDS, {
closeUnusedOnRelease: true
})
return null
}
function FocusedSettingsProbe(): null {
useFocusedSettingsHostClients(HOST_IDS)
return null
}
function DynamicSettingsProbe({ hostIds }: { hostIds: string[] }): null {
useAllHostClients(hostIds, { closeUnusedOnRelease: true })
return null
}
function DetailProbe({ hostId }: { hostId: string }): null {
useHostClient(hostId)
return null
}
type Screen = 'empty' | 'home' | 'settings'
function TestApp({
screen,
detailHostId
}: {
screen: Screen
detailHostId?: string
}): React.JSX.Element {
const screenProbe =
screen === 'home'
? createElement(HomeProbe)
: screen === 'settings'
? createElement(SettingsProbe)
: null
return createElement(
RpcClientProvider,
null,
createElement(
Fragment,
null,
createElement(ContextProbe),
detailHostId ? createElement(DetailProbe, { hostId: detailHostId }) : null,
screenProbe
)
)
}
function suppressRendererWarning(): () => void {
const originalConsoleError = console.error
const spy = vi.spyOn(console, 'error').mockImplementation((...args) => {
if (typeof args[0] === 'string' && args[0].includes('react-test-renderer is deprecated')) {
return
}
originalConsoleError(...args)
})
return () => spy.mockRestore()
}
async function renderScreen(screen: Screen, detailHostId?: string): Promise<ReactTestRenderer> {
let renderer: ReactTestRenderer | null = null
const restore = suppressRendererWarning()
try {
await act(async () => {
renderer = create(createElement(TestApp, { screen, detailHostId }))
await Promise.resolve()
await Promise.resolve()
})
} finally {
restore()
}
if (!renderer) {
throw new Error('settings lifecycle harness did not render')
}
return renderer
}
async function navigate(
renderer: ReactTestRenderer,
screen: Screen,
detailHostId?: string
): Promise<void> {
await act(async () => {
renderer.update(createElement(TestApp, { screen, detailHostId }))
await Promise.resolve()
await Promise.resolve()
})
}
function activeHostIds(): string[] {
if (!context) {
throw new Error('client context was not captured')
}
return context
.getAllClients()
.map(({ hostId }) => hostId)
.sort()
}
beforeEach(() => {
globalThis.IS_REACT_ACT_ENVIRONMENT = true
context = null
routeFocus.effect = null
connectMock.mockReset()
loadHostsMock.mockReset()
})
describe('settings host client lifecycle', () => {
it('closes settings-only clients while a mounted Home owner keeps its bounded set', async () => {
const clients = new Map<string, FakeClient[]>()
connectMock.mockImplementation((profile: HostProfile) => {
const client = makeFakeClient(profile.id === 'offline-host' ? 'reconnecting' : 'connected')
clients.set(profile.id, [...(clients.get(profile.id) ?? []), client])
return client
})
loadHostsMock.mockResolvedValue(HOSTS)
function NavigationStack({ settingsVisible }: { settingsVisible: boolean }) {
return createElement(
RpcClientProvider,
null,
createElement(
Fragment,
null,
createElement(ContextProbe),
createElement(HomeProbe),
settingsVisible ? createElement(SettingsProbe) : null
)
)
}
let renderer: ReactTestRenderer | null = null
const restore = suppressRendererWarning()
try {
await act(async () => {
renderer = create(createElement(NavigationStack, { settingsVisible: false }))
await Promise.resolve()
await Promise.resolve()
})
} finally {
restore()
}
if (!renderer) {
throw new Error('navigation stack harness did not render')
}
expect(activeHostIds()).toEqual([...HOME_HOST_IDS].sort())
expect(connectMock).toHaveBeenCalledTimes(3)
await act(async () => {
renderer?.update(createElement(NavigationStack, { settingsVisible: true }))
await Promise.resolve()
await Promise.resolve()
})
expect(activeHostIds()).toEqual([...HOST_IDS].sort())
expect(connectMock).toHaveBeenCalledTimes(HOSTS.length)
expect(loadHostsMock).toHaveBeenCalledTimes(HOME_HOST_IDS.length)
await act(async () => {
renderer?.update(createElement(NavigationStack, { settingsVisible: false }))
})
expect(activeHostIds()).toEqual([...HOME_HOST_IDS].sort())
for (const hostId of HOME_HOST_IDS) {
expect(clients.get(hostId)).toHaveLength(1)
expect(clients.get(hostId)?.[0]?.closeMock).not.toHaveBeenCalled()
}
for (const hostId of ['folder-workspace-host', 'offline-host']) {
expect(clients.get(hostId)?.[0]?.closeMock).toHaveBeenCalledOnce()
}
await act(async () => {
renderer?.update(createElement(NavigationStack, { settingsVisible: true }))
await Promise.resolve()
await Promise.resolve()
})
expect(loadHostsMock).toHaveBeenCalledTimes(HOME_HOST_IDS.length)
await act(async () => {
renderer?.update(createElement(NavigationStack, { settingsVisible: false }))
})
expect(activeHostIds()).toEqual([...HOME_HOST_IDS].sort())
for (const hostId of HOME_HOST_IDS) {
expect(clients.get(hostId)).toHaveLength(1)
}
for (const hostId of ['folder-workspace-host', 'offline-host']) {
expect(clients.get(hostId)).toHaveLength(2)
expect(clients.get(hostId)?.every((client) => client.closeMock.mock.calls.length === 1)).toBe(
true
)
}
act(() => renderer.unmount())
})
it('leaves no zero-reference clients after repeated settings navigation', async () => {
const clients = new Map<string, FakeClient[]>()
connectMock.mockImplementation((profile: HostProfile) => {
const client = makeFakeClient('reconnecting')
clients.set(profile.id, [...(clients.get(profile.id) ?? []), client])
return client
})
loadHostsMock.mockResolvedValue(HOSTS)
const renderer = await renderScreen('settings')
expect(activeHostIds()).toEqual([...HOST_IDS].sort())
await navigate(renderer, 'empty')
expect(activeHostIds()).toEqual([])
for (const hostClients of clients.values()) {
expect(hostClients).toHaveLength(1)
expect(hostClients[0]?.closeMock).toHaveBeenCalledOnce()
}
await navigate(renderer, 'settings')
await navigate(renderer, 'empty')
expect(activeHostIds()).toEqual([])
expect(connectMock).toHaveBeenCalledTimes(HOSTS.length * 2)
for (const hostClients of clients.values()) {
expect(hostClients).toHaveLength(2)
expect(hostClients.every((client) => client.closeMock.mock.calls.length === 1)).toBe(true)
}
act(() => renderer.unmount())
})
it('reconciles host-list changes without restarting retained or shared clients', async () => {
const clients = new Map<string, FakeClient>()
connectMock.mockImplementation((profile: HostProfile) => {
const client = makeFakeClient('reconnecting')
clients.set(profile.id, client)
return client
})
loadHostsMock.mockResolvedValue(HOSTS)
const retainedHostId = 'direct-recent'
const sharedHostId = 'relay-recent'
const removedHostId = 'folder-workspace-host'
const addedHostId = 'offline-host'
function HostListApp({ settingsHostIds }: { settingsHostIds: string[] }) {
return createElement(
RpcClientProvider,
null,
createElement(
Fragment,
null,
createElement(ContextProbe),
createElement(DetailProbe, { hostId: sharedHostId }),
createElement(DynamicSettingsProbe, { hostIds: settingsHostIds })
)
)
}
let renderer: ReactTestRenderer | null = null
const restore = suppressRendererWarning()
try {
await act(async () => {
renderer = create(
createElement(HostListApp, {
settingsHostIds: [retainedHostId, sharedHostId, removedHostId]
})
)
await Promise.resolve()
await Promise.resolve()
})
} finally {
restore()
}
if (!renderer) {
throw new Error('host-list lifecycle harness did not render')
}
await act(async () => {
renderer?.update(
createElement(HostListApp, {
settingsHostIds: [retainedHostId, addedHostId]
})
)
await Promise.resolve()
await Promise.resolve()
})
expect(connectMock).toHaveBeenCalledTimes(4)
expect(activeHostIds()).toEqual([addedHostId, retainedHostId, sharedHostId].sort())
expect(clients.get(retainedHostId)?.closeMock).not.toHaveBeenCalled()
expect(clients.get(sharedHostId)?.closeMock).not.toHaveBeenCalled()
expect(clients.get(removedHostId)?.closeMock).toHaveBeenCalledOnce()
expect(clients.get(addedHostId)?.closeMock).not.toHaveBeenCalled()
act(() => renderer.unmount())
expect(clients.get(retainedHostId)?.closeMock).toHaveBeenCalledOnce()
expect(clients.get(sharedHostId)?.closeMock).toHaveBeenCalledOnce()
expect(clients.get(addedHostId)?.closeMock).toHaveBeenCalledOnce()
})
it('does not close a settings client still held by an active consumer', async () => {
const clients = new Map<string, FakeClient>()
connectMock.mockImplementation((profile: HostProfile) => {
const client = makeFakeClient('connected')
clients.set(profile.id, client)
return client
})
loadHostsMock.mockResolvedValue(HOSTS)
const detailHostId = 'folder-workspace-host'
const renderer = await renderScreen('settings', detailHostId)
await navigate(renderer, 'home', detailHostId)
expect(activeHostIds()).toEqual([...HOME_HOST_IDS, detailHostId].sort())
expect(clients.get(detailHostId)?.closeMock).not.toHaveBeenCalled()
act(() => renderer.unmount())
expect(clients.get(detailHostId)?.closeMock).toHaveBeenCalledOnce()
})
it('keeps a reconnect alive when another consumer remains after settings leaves', async () => {
const retryHost = host('ssh-retry-host', 1)
let resolveInitial: ((hosts: HostProfile[]) => void) | null = null
let resolveRetry: ((hosts: HostProfile[]) => void) | null = null
const initialLookup = new Promise<HostProfile[]>((resolve) => {
resolveInitial = resolve
})
const retryLookup = new Promise<HostProfile[]>((resolve) => {
resolveRetry = resolve
})
loadHostsMock.mockReturnValueOnce(initialLookup).mockReturnValueOnce(retryLookup)
const initialClient = makeFakeClient('connected')
const retryClient = makeFakeClient('reconnecting')
connectMock.mockReturnValueOnce(initialClient).mockReturnValueOnce(retryClient)
function RetrySettingsProbe(): null {
useAllHostClients([retryHost.id], {
closeUnusedOnRelease: true
})
return null
}
function RetryApp({ settingsVisible }: { settingsVisible: boolean }): React.JSX.Element {
return createElement(
RpcClientProvider,
null,
createElement(
Fragment,
null,
createElement(ContextProbe),
createElement(DetailProbe, { hostId: retryHost.id }),
settingsVisible ? createElement(RetrySettingsProbe) : null
)
)
}
let renderer: ReactTestRenderer | null = null
const restore = suppressRendererWarning()
try {
act(() => {
renderer = create(createElement(RetryApp, { settingsVisible: true }))
})
} finally {
restore()
}
if (!renderer || !resolveInitial || !resolveRetry) {
throw new Error('retry lifecycle harness did not initialize')
}
await act(async () => {
resolveInitial?.([retryHost])
await initialLookup
})
if (!context) {
throw new Error('client context was not captured')
}
const reconnect = context.forceReconnect(retryHost.id)
expect(initialClient.closeMock).toHaveBeenCalledOnce()
act(() => renderer?.update(createElement(RetryApp, { settingsVisible: false })))
await act(async () => {
resolveRetry?.([retryHost])
await retryLookup
await reconnect
})
expect(activeHostIds()).toEqual([retryHost.id])
expect(retryClient.closeMock).not.toHaveBeenCalled()
act(() => renderer?.unmount())
expect(retryClient.closeMock).toHaveBeenCalledOnce()
})
it('cancels a released open without cancelling a rapid replacement acquisition', async () => {
const settingsOnlyHost = host('settings-only-offline', 0, {
deviceToken: '',
publicKeyB64: ''
})
let resolveFirst: ((hosts: HostProfile[]) => void) | null = null
let resolveSecond: ((hosts: HostProfile[]) => void) | null = null
const firstLookup = new Promise<HostProfile[]>((resolve) => {
resolveFirst = resolve
})
const secondLookup = new Promise<HostProfile[]>((resolve) => {
resolveSecond = resolve
})
loadHostsMock.mockReturnValueOnce(firstLookup).mockReturnValueOnce(secondLookup)
const client = makeFakeClient('reconnecting')
connectMock.mockReturnValue(client)
function PendingSettingsProbe(): null {
useAllHostClients([settingsOnlyHost.id], {
closeUnusedOnRelease: true
})
return null
}
function PendingApp({ visible }: { visible: boolean }): React.JSX.Element {
return createElement(
RpcClientProvider,
null,
visible ? createElement(PendingSettingsProbe) : null
)
}
let renderer: ReactTestRenderer | null = null
const restore = suppressRendererWarning()
try {
act(() => {
renderer = create(createElement(PendingApp, { visible: true }))
})
} finally {
restore()
}
if (!renderer || !resolveFirst || !resolveSecond) {
throw new Error('pending settings harness did not initialize')
}
act(() => renderer?.update(createElement(PendingApp, { visible: false })))
act(() => renderer?.update(createElement(PendingApp, { visible: true })))
await act(async () => {
resolveFirst?.([settingsOnlyHost])
await firstLookup
})
expect(connectMock).not.toHaveBeenCalled()
await act(async () => {
resolveSecond?.([settingsOnlyHost])
await secondLookup
})
expect(connectMock).toHaveBeenCalledOnce()
act(() => renderer?.update(createElement(PendingApp, { visible: false })))
expect(client.closeMock).toHaveBeenCalledOnce()
act(() => renderer?.unmount())
})
it('preserves Home ownership when re-pairing replaces a selected host client', async () => {
const clients = new Map<string, FakeClient[]>()
connectMock.mockImplementation((profile: HostProfile) => {
const client = makeFakeClient('connected')
clients.set(profile.id, [...(clients.get(profile.id) ?? []), client])
return client
})
loadHostsMock.mockResolvedValue(HOSTS)
const replacedHostId = HOME_HOST_IDS[0]!
function ReplacementApp({
detailVisible,
settingsVisible
}: {
detailVisible: boolean
settingsVisible: boolean
}): React.JSX.Element {
return createElement(
RpcClientProvider,
null,
createElement(
Fragment,
null,
createElement(ContextProbe),
createElement(HomeProbe),
detailVisible ? createElement(DetailProbe, { hostId: replacedHostId }) : null,
settingsVisible ? createElement(SettingsProbe) : null
)
)
}
let renderer: ReactTestRenderer | null = null
const restore = suppressRendererWarning()
try {
await act(async () => {
renderer = create(
createElement(ReplacementApp, { detailVisible: false, settingsVisible: false })
)
await Promise.resolve()
})
} finally {
restore()
}
if (!renderer || !context) {
throw new Error('replacement lifecycle harness did not initialize')
}
const originalClient = clients.get(replacedHostId)?.[0]
act(() => context?.closeHost(replacedHostId))
expect(originalClient?.closeMock).toHaveBeenCalledOnce()
await act(async () => {
renderer?.update(
createElement(ReplacementApp, { detailVisible: true, settingsVisible: false })
)
await Promise.resolve()
await Promise.resolve()
})
const replacementClient = clients.get(replacedHostId)?.[1]
expect(replacementClient).toBeDefined()
await act(async () => {
renderer?.update(
createElement(ReplacementApp, { detailVisible: false, settingsVisible: true })
)
await Promise.resolve()
await Promise.resolve()
})
act(() =>
renderer?.update(
createElement(ReplacementApp, { detailVisible: false, settingsVisible: false })
)
)
expect(activeHostIds()).toEqual([...HOME_HOST_IDS].sort())
expect(replacementClient?.closeMock).not.toHaveBeenCalled()
act(() => renderer?.unmount())
expect(replacementClient?.closeMock).toHaveBeenCalledOnce()
})
it('releases a manual host after Home demotes or stops tracking it', async () => {
const manualHost = host('manual-relay-host', 1, { relayHostId: 'AbCdEf0123_-xyZ9' })
const clients: FakeClient[] = []
connectMock.mockImplementation(() => {
const client = makeFakeClient('reconnecting')
clients.push(client)
return client
})
loadHostsMock.mockResolvedValue([manualHost])
function ManualHomeProbe({ selected }: { selected: boolean }): null {
useAllHostClients([manualHost.id], {
autoConnectHostIds: selected ? [manualHost.id] : [],
closeUnusedOnRelease: true
})
return null
}
function ManualApp({
selected,
homeVisible = true
}: {
selected: boolean
homeVisible?: boolean
}) {
return createElement(
RpcClientProvider,
null,
createElement(
Fragment,
null,
createElement(ContextProbe),
homeVisible ? createElement(ManualHomeProbe, { selected }) : null
)
)
}
let renderer: ReactTestRenderer | null = null
const restore = suppressRendererWarning()
try {
act(() => {
renderer = create(createElement(ManualApp, { selected: false }))
})
} finally {
restore()
}
if (!renderer || !context) {
throw new Error('manual connection harness did not initialize')
}
await act(async () => {
await context?.forceReconnect(manualHost.id)
})
expect(activeHostIds()).toEqual([manualHost.id])
await act(async () => {
renderer?.update(createElement(ManualApp, { selected: true }))
await Promise.resolve()
})
expect(activeHostIds()).toEqual([manualHost.id])
await act(async () => {
renderer?.update(createElement(ManualApp, { selected: false }))
await Promise.resolve()
})
expect(activeHostIds()).toEqual([])
expect(clients[0]?.closeMock).toHaveBeenCalledOnce()
await act(async () => {
await context?.forceReconnect(manualHost.id)
})
expect(activeHostIds()).toEqual([manualHost.id])
expect(clients).toHaveLength(2)
act(() => renderer?.update(createElement(ManualApp, { selected: false, homeVisible: false })))
expect(activeHostIds()).toEqual([])
expect(clients[1]?.closeMock).toHaveBeenCalledOnce()
act(() => renderer?.unmount())
expect(clients.every((client) => client.closeMock.mock.calls.length === 1)).toBe(true)
})
it('releases settings-only clients on blur while shared owners stay mounted', async () => {
const clients = new Map<string, FakeClient>()
connectMock.mockImplementation((profile: HostProfile) => {
const client = makeFakeClient('reconnecting')
clients.set(profile.id, client)
return client
})
loadHostsMock.mockResolvedValue(HOSTS)
const detailHostId = 'folder-workspace-host'
function FocusStack(): React.JSX.Element {
return createElement(
RpcClientProvider,
null,
createElement(
Fragment,
null,
createElement(ContextProbe),
createElement(HomeProbe),
createElement(DetailProbe, { hostId: detailHostId }),
createElement(FocusedSettingsProbe)
)
)
}
let renderer: ReactTestRenderer | null = null
const restore = suppressRendererWarning()
try {
await act(async () => {
renderer = create(createElement(FocusStack))
await Promise.resolve()
await Promise.resolve()
})
} finally {
restore()
}
if (!renderer || !routeFocus.effect) {
throw new Error('settings focus harness did not initialize')
}
expect(activeHostIds()).toEqual([...HOME_HOST_IDS, detailHostId].sort())
let blur: (() => void) | undefined
await act(async () => {
const cleanup = routeFocus.effect?.()
if (typeof cleanup === 'function') {
blur = cleanup
}
await Promise.resolve()
await Promise.resolve()
})
expect(activeHostIds()).toEqual([...HOST_IDS].sort())
await act(async () => {
blur?.()
await Promise.resolve()
})
expect(activeHostIds()).toEqual([...HOME_HOST_IDS, detailHostId].sort())
for (const hostId of [...HOME_HOST_IDS, detailHostId]) {
expect(clients.get(hostId)?.closeMock).not.toHaveBeenCalled()
}
expect(clients.get('offline-host')?.closeMock).toHaveBeenCalledOnce()
act(() => renderer?.unmount())
})
})

View File

@ -1,126 +1,56 @@
import { useEffect, useMemo, useRef, useState } from 'react'
import { useEffect, useMemo, useState } from 'react'
import type { RpcClient } from './rpc-client'
import type { MobileConnectionPath } from './stable-logical-rpc-client'
import type { ConnectionState } from './types'
import { useRpcClientContext } from './client-context'
type UseAllHostClientsOptions = {
autoConnectHostIds?: readonly string[]
closeUnusedOnRelease?: boolean
}
export function useAllHostClients(hostIds: string[], options?: UseAllHostClientsOptions) {
// Why: refcounting prevents a double-open when a host-detail screen shares one of these hosts.
export function useAllHostClients(hostIds: string[]) {
const ctx = useRpcClientContext()
const autoConnectHostIds = options?.autoConnectHostIds ?? hostIds
const closeUnusedOnRelease = options?.closeUnusedOnRelease ?? false
const key = useMemo(
() =>
[
[...hostIds].sort().join(','),
[...autoConnectHostIds].sort().join(','),
closeUnusedOnRelease ? 'close' : 'keep'
].join('|'),
[autoConnectHostIds, closeUnusedOnRelease, hostIds]
)
// Stable key so we don't tear down on every render of the array.
const key = useMemo(() => [...hostIds].sort().join(','), [hostIds])
const [tick, setTick] = useState(0)
const acquiredHostIdsRef = useRef<Set<string>>(new Set())
const hostUnsubscribesRef = useRef<Map<string, () => void>>(new Map())
const closeUnusedRef = useRef(closeUnusedOnRelease)
useEffect(() => {
closeUnusedRef.current = closeUnusedOnRelease
}, [closeUnusedOnRelease])
useEffect(() => {
const unsubscribeAllHosts = ctx.subscribeAllHosts(() => setTick((value) => value + 1))
if (hostIds.length === 0) {
return
}
for (const id of hostIds) {
ctx.acquire(id)
}
const unsubs: (() => void)[] = []
for (const id of hostIds) {
unsubs.push(ctx.subscribeHostState(id, () => setTick((n) => n + 1)))
}
unsubs.push(ctx.subscribeAllHosts(() => setTick((n) => n + 1)))
return () => {
unsubscribeAllHosts()
const trackedHostIds = [...hostUnsubscribesRef.current.keys()]
const acquiredHostIds = new Set(acquiredHostIdsRef.current)
for (const unsubscribe of hostUnsubscribesRef.current.values()) {
unsubscribe()
for (const u of unsubs) {
u()
}
hostUnsubscribesRef.current.clear()
for (const id of acquiredHostIds) {
if (closeUnusedRef.current) {
ctx.releaseAndCloseIfUnused(id)
} else {
ctx.release(id)
}
}
if (closeUnusedRef.current) {
for (const id of trackedHostIds) {
if (!acquiredHostIds.has(id)) {
ctx.closeIfUnused(id)
}
}
}
acquiredHostIdsRef.current.clear()
}
}, [ctx])
useEffect(() => {
const trackedHostIds = new Set(hostIds)
const nextAcquiredHostIds = new Set(autoConnectHostIds.filter((id) => trackedHostIds.has(id)))
const removedTrackedHostIds: string[] = []
for (const [id, unsubscribe] of hostUnsubscribesRef.current) {
if (!trackedHostIds.has(id)) {
unsubscribe()
hostUnsubscribesRef.current.delete(id)
removedTrackedHostIds.push(id)
for (const id of hostIds) {
ctx.release(id)
}
}
for (const id of trackedHostIds) {
if (!hostUnsubscribesRef.current.has(id)) {
hostUnsubscribesRef.current.set(
id,
ctx.subscribeHostState(id, () => setTick((value) => value + 1))
)
}
}
for (const id of acquiredHostIdsRef.current) {
if (!nextAcquiredHostIds.has(id)) {
if (closeUnusedOnRelease) {
ctx.releaseAndCloseIfUnused(id)
} else {
ctx.release(id)
}
}
}
for (const id of nextAcquiredHostIds) {
if (!acquiredHostIdsRef.current.has(id)) {
ctx.acquire(id)
}
}
if (closeUnusedOnRelease) {
for (const id of removedTrackedHostIds) {
ctx.closeIfUnused(id)
}
for (const id of trackedHostIds) {
if (!nextAcquiredHostIds.has(id)) {
ctx.closeIfUnused(id)
}
}
}
acquiredHostIdsRef.current = nextAcquiredHostIds
}, [ctx, key])
}, [key])
return useMemo(() => {
const clientsByHostId = new Map(
ctx.getAllClients().map((entry) => [entry.hostId, entry.client])
)
return hostIds.flatMap<{
const out: {
hostId: string
client: RpcClient
state: ConnectionState
path: MobileConnectionPath
}>((hostId) => {
const client = clientsByHostId.get(hostId)
return client
? [{ hostId, client, state: ctx.getState(hostId), path: ctx.getActivePath(hostId) }]
: []
})
}, [ctx, hostIds, tick])
}[] = []
for (const id of hostIds) {
const all = ctx.getAllClients().find((entry) => entry.hostId === id)
if (all) {
out.push({
hostId: id,
client: all.client,
state: ctx.getState(id),
path: ctx.getActivePath(id)
})
}
}
return out
}, [key, tick])
}