From bf2de0dd9b899b79b345a989cdddd2b8bea2e825 Mon Sep 17 00:00:00 2001 From: Jinwoo-H Date: Thu, 3 Sep 2026 05:52:11 -0400 Subject: [PATCH] fix(mobile): keep the hosted list loading through the bridge warm-up MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The hosted page reads `connected` from the shell's relayed snapshot, so its first worktree.ps can be issued before that socket serves one. Page storage is disabled, so the hosted list always starts at zero rows — the precondition the list state machine needs to render `catalog-error` — while the native list is seeded from its persisted cache and hides the same failure behind stale rows. Let a relayed transport spend one silent catalog retry per binding: the first failure leaves catalogError null, so the list stays in `loading` and the existing refresh retries. A direct socket omits the flag and is unchanged. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb --- .../use-hybrid-host-screen-catalog.test.tsx | 184 ++++++++++++++++++ .../use-hybrid-host-screen-catalog.ts | 12 +- .../use-hybrid-host-screen-state.ts | 4 + .../src/worktree/host-workspace-operations.ts | 3 + .../native-host-workspace-operations.test.ts | 8 + .../web-host-workspace-operations.test.ts | 8 + .../worktree/web-host-workspace-operations.ts | 1 + 7 files changed, 219 insertions(+), 1 deletion(-) create mode 100644 mobile/src/host-screen/use-hybrid-host-screen-catalog.test.tsx diff --git a/mobile/src/host-screen/use-hybrid-host-screen-catalog.test.tsx b/mobile/src/host-screen/use-hybrid-host-screen-catalog.test.tsx new file mode 100644 index 00000000000..782896a75d7 --- /dev/null +++ b/mobile/src/host-screen/use-hybrid-host-screen-catalog.test.tsx @@ -0,0 +1,184 @@ +import { createElement } from 'react' +import { act, create, type ReactTestRenderer } from 'react-test-renderer' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { ConnectionState } from '../transport/types' +import { selectHostWorkspaceListState } from '../worktree/host-workspace-list-state' +import type { HostScreenHostState } from '../worktree/host-screen-host-state' +import type { HostWorkspaceOperations } from '../worktree/host-workspace-operations' +import { useHybridHostScreenCatalog } from './use-hybrid-host-screen-catalog' +import { useHybridHostScreenState } from './use-hybrid-host-screen-state' + +vi.mock('expo-router', async () => { + const { useEffect } = await import('react') + return { + useFocusEffect(effect: () => void | (() => void)) { + useEffect(effect, [effect]) + } + } +}) +vi.mock('react-native', () => ({ + AppState: { + currentState: 'active', + addEventListener: vi.fn(() => ({ remove: vi.fn() })) + } +})) + +const HOST_ID = 'paired-orca-desktop' + +const hostState: HostScreenHostState = { + cachedWorkspaces: () => null, + cacheWorkspaces: () => {}, + cacheRepositories: () => {}, + loadPinnedWorkspaceIds: async () => new Set(), + savePinnedWorkspaceIds: async () => {}, + loadIdentity: async () => ({ name: 'Desktop', publicKeyB64: '' }), + recordConnected: async () => {} +} + +const noopRepoMetadata = async (): Promise => {} +const noopViewSettings = async (): Promise => {} + +function operations(args: { + relayed: boolean + listWorkspaces: HostWorkspaceOperations['listWorkspaces'] +}): HostWorkspaceOperations { + return { + ...(args.relayed ? { connectionStateIsRelayed: true } : {}), + getViewSettings: async () => null, + setViewSettings: async () => {}, + listRepos: async () => [], + listWorkspaces: args.listWorkspaces, + setPinned: async () => {}, + removeWorkspace: async () => true, + activateWorkspace: async () => {}, + sleepWorkspace: async () => {}, + notifyForeground: () => {}, + subscribeChanges: () => () => {} + } +} + +type Probe = { + catalogError: string | null + worktreesLoaded: boolean + displayCount: number + fetchWorktrees: () => Promise +} + +describe('hybrid host catalog first-load failure', () => { + let renderer: ReactTestRenderer | null = null + let probe: Probe | null = null + + beforeEach(() => { + globalThis.IS_REACT_ACT_ENVIRONMENT = true + probe = null + }) + + afterEach(() => { + act(() => renderer?.unmount()) + renderer = null + }) + + function render(ops: HostWorkspaceOperations, connState: ConnectionState = 'connected'): void { + function CatalogProbe(): null { + const state = useHybridHostScreenState(HOST_ID, undefined, hostState) + state.workspaceOperationsRef.current = ops + const catalog = useHybridHostScreenCatalog({ + operations: ops, + connState, + embedded: false, + fetchRepoMetadata: noopRepoMetadata, + hostId: HOST_ID, + hostState, + state, + syncViewSettingsFromDesktop: noopViewSettings + }) + probe = { + catalogError: state.catalogError, + worktreesLoaded: state.worktreesLoaded, + displayCount: state.worktrees.length, + fetchWorktrees: catalog.fetchWorktrees + } + return null + } + act(() => { + renderer = create(createElement(CatalogProbe)) + }) + } + + function listState(): ReturnType { + const current = probe + if (!current) { + throw new Error('probe missing') + } + return selectHostWorkspaceListState({ + connState: 'connected', + worktreesLoaded: current.worktreesLoaded, + displayCount: current.displayCount, + sectionCount: 0, + catalogError: current.catalogError + }) + } + + async function refetch(): Promise { + await act(async () => { + await probe?.fetchWorktrees() + }) + } + + it('keeps the never-loaded list in the loading state when the relayed first attempt fails', async () => { + const listWorkspaces = vi.fn(async () => { + throw new Error('network_error') + }) + render(operations({ relayed: true, listWorkspaces })) + await act(async () => {}) + + expect(listWorkspaces).toHaveBeenCalledOnce() + expect(probe?.catalogError).toBeNull() + expect(listState()).toBe('loading') + }) + + it('reports the failure once the retry after the channel is up also fails', async () => { + const listWorkspaces = vi.fn(async () => { + throw new Error('network_error') + }) + render(operations({ relayed: true, listWorkspaces })) + await act(async () => {}) + expect(probe?.catalogError).toBeNull() + + await refetch() + + expect(listWorkspaces).toHaveBeenCalledTimes(2) + expect(probe?.catalogError).toBe('network_error') + expect(listState()).toBe('catalog-error') + }) + + it('reports a relayed failure that follows a successful load', async () => { + let fail = false + const listWorkspaces = vi.fn(async () => { + if (fail) { + throw new Error('network_error') + } + return [] + }) + render(operations({ relayed: true, listWorkspaces })) + await act(async () => {}) + expect(probe?.worktreesLoaded).toBe(true) + + fail = true + await refetch() + + expect(probe?.catalogError).toBe('network_error') + }) + + it('leaves the direct-socket build reporting its first failure immediately', async () => { + const listWorkspaces = vi.fn(async () => { + throw new Error('network_error') + }) + render(operations({ relayed: false, listWorkspaces })) + await act(async () => {}) + + expect(listWorkspaces).toHaveBeenCalledOnce() + expect(probe?.catalogError).toBe('network_error') + expect(listState()).toBe('catalog-error') + }) +}) diff --git a/mobile/src/host-screen/use-hybrid-host-screen-catalog.ts b/mobile/src/host-screen/use-hybrid-host-screen-catalog.ts index 54a8a1ea778..7342c7b9df9 100644 --- a/mobile/src/host-screen/use-hybrid-host-screen-catalog.ts +++ b/mobile/src/host-screen/use-hybrid-host-screen-catalog.ts @@ -33,6 +33,7 @@ export function useHybridHostScreenCatalog(args: { syncViewSettingsFromDesktop } = args const { + catalogWarmupSpentRef, fetchWorktreesInFlightRef, newWorktreeModalVisibleRef, setCatalogError, @@ -57,6 +58,11 @@ export function useHybridHostScreenCatalog(args: { fetchWorktreesInFlightRef.current = true const request = operations, requestHostId = hostId + // Why: `connected` on a relayed transport is the shell's snapshot, not proof its socket + // serves yet; forgive the binding's first failure so the list keeps loading and retries. + const warmupForgiven = + request.connectionStateIsRelayed === true && !catalogWarmupSpentRef.current.has(request) + catalogWarmupSpentRef.current.add(request) try { const next = await request.listWorkspaces(10000) if (state.workspaceOperationsRef.current !== request || hostId !== requestHostId) { @@ -87,7 +93,11 @@ export function useHybridHostScreenCatalog(args: { return serverPinned }) } catch { - if (state.workspaceOperationsRef.current === request && hostId === requestHostId) { + if ( + !warmupForgiven && + state.workspaceOperationsRef.current === request && + hostId === requestHostId + ) { setCatalogError('network_error') } } finally { diff --git a/mobile/src/host-screen/use-hybrid-host-screen-state.ts b/mobile/src/host-screen/use-hybrid-host-screen-state.ts index 88744801e00..4bd7509a594 100644 --- a/mobile/src/host-screen/use-hybrid-host-screen-state.ts +++ b/mobile/src/host-screen/use-hybrid-host-screen-state.ts @@ -23,6 +23,9 @@ export function useHybridHostScreenState( const fetchWorktreesInFlightRef = useRef(false) const fetchRepoMetadataInFlightRef = useRef(new WeakSet()) const fetchRepoMetadataPendingRef = useRef(new WeakSet()) + // Why: one silent catalog retry per relayed-transport binding, so a first attempt that raced + // the shell's socket stays in the loading state instead of rendering a failure. + const catalogWarmupSpentRef = useRef(new WeakSet()) const repoMetadataFetchedAtRef = useRef(0) const newWorktreeModalRef = useRef<{ open: () => void }>(null) const newWorktreeModalVisibleRef = useRef(false) @@ -86,6 +89,7 @@ export function useHybridHostScreenState( return { actionTarget, catalogError, + catalogWarmupSpentRef, collapsedGroups, confirmDelete, confirmRemoveHost, diff --git a/mobile/src/worktree/host-workspace-operations.ts b/mobile/src/worktree/host-workspace-operations.ts index ddcf7c14433..a03f5b5d12c 100644 --- a/mobile/src/worktree/host-workspace-operations.ts +++ b/mobile/src/worktree/host-workspace-operations.ts @@ -17,4 +17,7 @@ export type HostWorkspaceOperations = { sleepWorkspace(workspaceId: string): Promise notifyForeground(): void subscribeChanges(listener: (event: HostWorkspaceChange) => void): () => void + // Why: a hosted page reads `connected` from the shell's relayed snapshot, so it can issue its + // first catalog request a beat before that socket serves one. A direct socket omits this. + readonly connectionStateIsRelayed?: boolean } diff --git a/mobile/src/worktree/native-host-workspace-operations.test.ts b/mobile/src/worktree/native-host-workspace-operations.test.ts index d5ee766dea7..6f7263b13a4 100644 --- a/mobile/src/worktree/native-host-workspace-operations.test.ts +++ b/mobile/src/worktree/native-host-workspace-operations.test.ts @@ -3,6 +3,14 @@ import type { RpcClient } from '../transport/rpc-client' import { nativeHostWorkspaceOperations } from './native-host-workspace-operations' describe('native host workspace operations', () => { + it('does not relay its connection state, so a first failure stays a failure', () => { + const client = { sendRequest: vi.fn(), notifyForeground: vi.fn(), subscribe: vi.fn() } + + expect( + nativeHostWorkspaceOperations(client as unknown as RpcClient).connectionStateIsRelayed + ).toBeUndefined() + }) + it('maps named reads and mutations to the existing RPC authority', async () => { const sendRequest = vi .fn() diff --git a/mobile/src/worktree/web-host-workspace-operations.test.ts b/mobile/src/worktree/web-host-workspace-operations.test.ts index d7d325d8430..496a552165a 100644 --- a/mobile/src/worktree/web-host-workspace-operations.test.ts +++ b/mobile/src/worktree/web-host-workspace-operations.test.ts @@ -20,6 +20,14 @@ describe('webHostWorkspaceOperations', () => { expect(client.workspaceSnapshot).toHaveBeenCalledWith({ limit: 200 }) }) + it('declares its connection state as a relayed shell snapshot', () => { + const operations = webHostWorkspaceOperations( + createClient() as unknown as MobileWebBridgeClient + ) + + expect(operations.connectionStateIsRelayed).toBe(true) + }) + it('routes mutations and settings through named bridge operations', async () => { const client = createClient() const operations = webHostWorkspaceOperations(client as unknown as MobileWebBridgeClient) diff --git a/mobile/src/worktree/web-host-workspace-operations.ts b/mobile/src/worktree/web-host-workspace-operations.ts index e8cc1f1ff91..3a4d07fcac0 100644 --- a/mobile/src/worktree/web-host-workspace-operations.ts +++ b/mobile/src/worktree/web-host-workspace-operations.ts @@ -8,6 +8,7 @@ import type { HostWorkspaceOperations } from './host-workspace-operations' export function webHostWorkspaceOperations(client: MobileWebBridgeClient): HostWorkspaceOperations { return { + connectionStateIsRelayed: true, async getViewSettings() { return (await client.workspaceSettingsSnapshot()).settings },