diff --git a/mobile/src/home/home-host-connection-projection.test.ts b/mobile/src/home/home-host-connection-projection.test.ts new file mode 100644 index 00000000000..71d608a2fa0 --- /dev/null +++ b/mobile/src/home/home-host-connection-projection.test.ts @@ -0,0 +1,78 @@ +import { describe, expect, it } from 'vitest' +import type { MobileConnectionPath } from '../transport/stable-logical-rpc-client' +import { + projectHomeHostConnections, + type HomeHostConnectionProjectionEntry +} from './home-host-connection-projection' + +describe('projectHomeHostConnections', () => { + it('reads each connection once while preserving all lookup values', () => { + const entryCount = 1_000 + const reads = { + hostId: 0, + path: 0, + pendingPath: 0, + pairingRejected: 0 + } + const entries = Array.from({ length: entryCount }, (_, index) => { + const hostId = index === entryCount - 1 ? '__proto__' : `host-${index}` + const path: MobileConnectionPath = index % 2 === 0 ? 'lan' : 'relay' + const pendingPath = index === 1 ? undefined : index % 2 === 0 ? null : 'tailscale' + const pairingRejected = index % 3 === 0 + const entry = {} as HomeHostConnectionProjectionEntry + Object.defineProperties(entry, { + hostId: { + enumerable: true, + get: () => { + reads.hostId += 1 + return hostId + } + }, + path: { + enumerable: true, + get: () => { + reads.path += 1 + return path + } + }, + pendingPath: { + enumerable: true, + get: () => { + reads.pendingPath += 1 + return pendingPath + } + }, + pairingRejected: { + enumerable: true, + get: () => { + reads.pairingRejected += 1 + return pairingRejected + } + } + }) + return entry + }) + + const projection = projectHomeHostConnections(entries) + + expect(reads).toEqual({ + hostId: entryCount, + path: entryCount, + pendingPath: entryCount, + pairingRejected: entryCount + }) + expect(Object.keys(projection.hostPaths)).toHaveLength(entryCount) + expect(Object.keys(projection.hostPendingPaths)).toHaveLength(entryCount) + expect(Object.keys(projection.hostPairingRejected)).toHaveLength(entryCount) + expect(projection.hostPaths['host-0']).toBe('lan') + expect(projection.hostPaths['host-1']).toBe('relay') + expect(projection.hostPendingPaths['host-0']).toBeNull() + expect(projection.hostPendingPaths['host-1']).toBeUndefined() + expect(projection.hostPendingPaths['host-3']).toBe('tailscale') + expect(projection.hostPairingRejected['host-0']).toBe(true) + expect(projection.hostPairingRejected['host-1']).toBe(false) + expect(Object.hasOwn(projection.hostPaths, '__proto__')).toBe(true) + expect(projection.hostPaths['__proto__']).toBe('relay') + expect(Object.getPrototypeOf(projection.hostPaths)).toBe(Object.prototype) + }) +}) diff --git a/mobile/src/home/home-host-connection-projection.ts b/mobile/src/home/home-host-connection-projection.ts new file mode 100644 index 00000000000..f8fdfd4bcdf --- /dev/null +++ b/mobile/src/home/home-host-connection-projection.ts @@ -0,0 +1,37 @@ +import type { MobileConnectionPath } from '../transport/stable-logical-rpc-client' + +export type HomeHostConnectionProjectionEntry = { + hostId: string + path: MobileConnectionPath + pendingPath: MobileConnectionPath | null + pairingRejected: boolean +} + +export type HomeHostConnectionProjection = { + hostPaths: Record + hostPendingPaths: Record + hostPairingRejected: Record +} + +/** Build all host lookup maps while reading each connection entry once. */ +export function projectHomeHostConnections( + entries: readonly HomeHostConnectionProjectionEntry[] +): HomeHostConnectionProjection { + // Null-prototype records avoid the __proto__ setter; restore the usual prototype for parity + // with Object.fromEntries once the projection is complete. + const hostPaths = Object.create(null) as Record + const hostPendingPaths = Object.create(null) as Record + const hostPairingRejected = Object.create(null) as Record + + for (const { hostId, path, pendingPath, pairingRejected } of entries) { + hostPaths[hostId] = path + hostPendingPaths[hostId] = pendingPath + hostPairingRejected[hostId] = pairingRejected + } + + Object.setPrototypeOf(hostPaths, Object.prototype) + Object.setPrototypeOf(hostPendingPaths, Object.prototype) + Object.setPrototypeOf(hostPairingRejected, Object.prototype) + + return { hostPaths, hostPendingPaths, hostPairingRejected } +} diff --git a/mobile/src/home/use-mobile-home-data.ts b/mobile/src/home/use-mobile-home-data.ts index 706b54499d4..c77d024158e 100644 --- a/mobile/src/home/use-mobile-home-data.ts +++ b/mobile/src/home/use-mobile-home-data.ts @@ -29,6 +29,7 @@ import { fetchMobileHomeStats, fetchMobileHomeTaskProviders } from './mobile-home-host-requests' +import { projectHomeHostConnections } from './home-host-connection-projection' import { useMobileHomeHostConnections } from './use-mobile-home-host-connections' export function useMobileHomeData() { @@ -167,14 +168,9 @@ export function useMobileHomeData() { const primaryTaskProviders = primaryHost ? (taskProvidersByHost[primaryHost.id] ?? ['github']) : [] - const hostPaths = Object.fromEntries( - connections.allClients.map(({ hostId, path }) => [hostId, path]) - ) - const hostPendingPaths = Object.fromEntries( - connections.allClients.map(({ hostId, pendingPath }) => [hostId, pendingPath]) - ) - const hostPairingRejected = Object.fromEntries( - connections.allClients.map(({ hostId, pairingRejected }) => [hostId, pairingRejected]) + const hostConnectionProjection = useMemo( + () => projectHomeHostConnections(connections.allClients), + [connections.allClients] ) return { @@ -182,9 +178,9 @@ export function useMobileHomeData() { accountsHosts, connectedHosts, hostCatalog, - hostPairingRejected, - hostPaths, - hostPendingPaths, + hostPairingRejected: hostConnectionProjection.hostPairingRejected, + hostPaths: hostConnectionProjection.hostPaths, + hostPendingPaths: hostConnectionProjection.hostPendingPaths, primaryHost, primaryTaskProviders, resumeCard,