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:
parent
d09863e068
commit
d2e8bbe109
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
}
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
)
|
||||
})
|
||||
})
|
||||
|
|
@ -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')
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
|
|
@ -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 }
|
||||
}
|
||||
|
|
@ -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())
|
||||
})
|
||||
})
|
||||
|
|
@ -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])
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue