mirror of
https://github.com/stablyai/orca.git
synced 2026-09-29 08:03:20 +00:00
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:
@@ -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
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user