fix(mobile): keep the hosted list loading through the bridge warm-up

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
This commit is contained in:
Jinwoo-H
2026-09-03 05:52:11 -04:00
parent 543b51f7e8
commit bf2de0dd9b
7 changed files with 219 additions and 1 deletions
@@ -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<void> => {}
const noopViewSettings = async (): Promise<void> => {}
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<void>
}
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<typeof selectHostWorkspaceListState> {
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<void> {
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')
})
})
@@ -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 {
@@ -23,6 +23,9 @@ export function useHybridHostScreenState(
const fetchWorktreesInFlightRef = useRef(false)
const fetchRepoMetadataInFlightRef = useRef(new WeakSet<HostWorkspaceOperations>())
const fetchRepoMetadataPendingRef = useRef(new WeakSet<HostWorkspaceOperations>())
// 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<HostWorkspaceOperations>())
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,
@@ -17,4 +17,7 @@ export type HostWorkspaceOperations = {
sleepWorkspace(workspaceId: string): Promise<void>
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
}
@@ -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<RpcClient['sendRequest']>()
@@ -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)
@@ -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
},