diff --git a/apps/mobile/src/app/(app)/(tabs)/(0_home)/_layout.tsx b/apps/mobile/src/app/(app)/(tabs)/(0_home)/_layout.tsx index 74598f7148..7c93bb9743 100644 --- a/apps/mobile/src/app/(app)/(tabs)/(0_home)/_layout.tsx +++ b/apps/mobile/src/app/(app)/(tabs)/(0_home)/_layout.tsx @@ -1,9 +1,12 @@ import { Stack } from 'expo-router'; +import { useRouteForegroundRefresh } from '@/lib/hooks/use-route-foreground-refresh'; + export const unstable_settings = { initialRouteName: 'index', }; export default function HomeLayout() { + useRouteForegroundRefresh([[['activeSessions']]]); return ; } diff --git a/apps/mobile/src/app/(app)/(tabs)/(3_profile)/_layout.tsx b/apps/mobile/src/app/(app)/(tabs)/(3_profile)/_layout.tsx index a452d1e276..e77c1584b5 100644 --- a/apps/mobile/src/app/(app)/(tabs)/(3_profile)/_layout.tsx +++ b/apps/mobile/src/app/(app)/(tabs)/(3_profile)/_layout.tsx @@ -1,9 +1,18 @@ import { Stack } from 'expo-router'; +import { useRouteForegroundRefresh } from '@/lib/hooks/use-route-foreground-refresh'; + export const unstable_settings = { initialRouteName: 'index', }; export default function ProfileLayout() { + useRouteForegroundRefresh([ + [['user']], + [['organizations']], + [['personalReviewAgent']], + [['securityAgent']], + [['kiloPass']], + ]); return ; } diff --git a/apps/mobile/src/app/(app)/_layout.tsx b/apps/mobile/src/app/(app)/_layout.tsx index 449437e0f9..caa1580303 100644 --- a/apps/mobile/src/app/(app)/_layout.tsx +++ b/apps/mobile/src/app/(app)/_layout.tsx @@ -15,7 +15,9 @@ import { import { useFormSheetDetents } from '@/lib/form-sheet'; import { useThemeColors } from '@/lib/hooks/use-theme-colors'; import { useCurrentUserId } from '@/lib/hooks/use-current-user-id'; +import { useRouteForegroundRefresh } from '@/lib/hooks/use-route-foreground-refresh'; import { CachePersistenceMount } from '@/lib/persist/cache-persistence-mount'; +import { useTRPC } from '@/lib/trpc'; /** * Attempts failed logout cleanup on every "next authenticated opportunity": @@ -82,6 +84,24 @@ function PushRegistrationMount() { return null; } +/** + * Refreshes app-wide freshness on foreground regain: the signed-in user, + * their organizations, and kilo-chat conversations. The root `(app)` layout + * is always focused, so the hook's focus gate never blocks this mount. + */ +function AppWideFreshnessMount() { + const trpc = useTRPC(); + useRouteForegroundRefresh([ + trpc.user.getMe.queryKey(), + trpc.organizations.list.queryKey(), + // Kilo-chat keys are FLAT (['kilo-chat', 'conversations', …]), so the + // partial key is the flat ['kilo-chat']; the nested tRPC form + // [['kilo-chat']] does not prefix-match flat keys. + ['kilo-chat'], + ]); + return null; +} + export default function AppLayout() { const colors = useThemeColors(); const { fullSheetDetent } = useFormSheetDetents(); @@ -92,6 +112,7 @@ export default function AppLayout() { + diff --git a/apps/mobile/src/app/(app)/agent-chat/[session-id].tsx b/apps/mobile/src/app/(app)/agent-chat/[session-id].tsx index 3785289d63..b989c2b13a 100644 --- a/apps/mobile/src/app/(app)/agent-chat/[session-id].tsx +++ b/apps/mobile/src/app/(app)/agent-chat/[session-id].tsx @@ -16,6 +16,7 @@ import { QueryError } from '@/components/query-error'; import { ScreenHeader } from '@/components/screen-header'; import { Button } from '@/components/ui/button'; import { Text } from '@/components/ui/text'; +import { useRouteForegroundRefresh } from '@/lib/hooks/use-route-foreground-refresh'; import { shouldRetryNotFoundOnSpawnedRoute } from '@/lib/spawned-not-found-retry'; import { useTRPC } from '@/lib/trpc'; @@ -54,6 +55,7 @@ export default function SessionDetailScreen() { const spawnedMode = Array.isArray(modeParam) ? modeParam[0] : modeParam; const trpc = useTRPC(); const router = useRouter(); + useRouteForegroundRefresh([[['cliSessionsV2']], [['modelPreferences']]]); const sessionQuery = useQuery({ ...trpc.cliSessionsV2.get.queryOptions( { session_id: sessionId }, diff --git a/apps/mobile/src/app/(app)/agent-chat/model-picker.tsx b/apps/mobile/src/app/(app)/agent-chat/model-picker.tsx index 392a6fd043..00397247c8 100644 --- a/apps/mobile/src/app/(app)/agent-chat/model-picker.tsx +++ b/apps/mobile/src/app/(app)/agent-chat/model-picker.tsx @@ -1,5 +1,7 @@ import { ModelPickerContent } from '@/components/agents/model-picker-content'; +import { useRouteForegroundRefresh } from '@/lib/hooks/use-route-foreground-refresh'; export default function ModelPickerScreen() { + useRouteForegroundRefresh([[['modelPreferences']]]); return ; } diff --git a/apps/mobile/src/app/(app)/device-sessions.tsx b/apps/mobile/src/app/(app)/device-sessions.tsx index 04bd86e9d5..daf269c140 100644 --- a/apps/mobile/src/app/(app)/device-sessions.tsx +++ b/apps/mobile/src/app/(app)/device-sessions.tsx @@ -1,5 +1,7 @@ import { DeviceSessionsScreen } from '@/components/device-sessions-screen'; +import { useRouteForegroundRefresh } from '@/lib/hooks/use-route-foreground-refresh'; export default function DeviceSessionsRoute() { + useRouteForegroundRefresh([[['user']]]); return ; } diff --git a/apps/mobile/src/app/(app)/kilo-pass.tsx b/apps/mobile/src/app/(app)/kilo-pass.tsx index 0612882c4e..e645ec78a8 100644 --- a/apps/mobile/src/app/(app)/kilo-pass.tsx +++ b/apps/mobile/src/app/(app)/kilo-pass.tsx @@ -1,5 +1,7 @@ import { KiloPassSubscriptionScreen } from '@/components/kilo-pass/kilo-pass-subscription-screen'; +import { useRouteForegroundRefresh } from '@/lib/hooks/use-route-foreground-refresh'; export default function KiloPassRoute() { + useRouteForegroundRefresh([[['kiloPass']]]); return ; } diff --git a/apps/mobile/src/app/(app)/pr-review/[owner]/[repo]/[number]/_layout.tsx b/apps/mobile/src/app/(app)/pr-review/[owner]/[repo]/[number]/_layout.tsx index 3d8b4cc16d..99ff431b52 100644 --- a/apps/mobile/src/app/(app)/pr-review/[owner]/[repo]/[number]/_layout.tsx +++ b/apps/mobile/src/app/(app)/pr-review/[owner]/[repo]/[number]/_layout.tsx @@ -3,6 +3,7 @@ import { type Href, Stack, useLocalSearchParams } from 'expo-router'; import { InvalidRouteState } from '@/components/invalid-route-state'; import { PrReviewConnectGate } from '@/components/pr-review/pr-review-connect-gate'; import { useCurrentUserId } from '@/lib/hooks/use-current-user-id'; +import { useRouteForegroundRefresh } from '@/lib/hooks/use-route-foreground-refresh'; import { pendingReviewDraftKey, PendingReviewProvider, @@ -39,6 +40,7 @@ export default function PrReviewNumberLayout() { const number = rawNumber ? Number.parseInt(rawNumber, 10) : Number.NaN; const { fullSheetDetent } = useFormSheetDetents(); const { userId } = useCurrentUserId(); + useRouteForegroundRefresh([[['githubPrReview']]]); if (!owner || !repo || !Number.isInteger(number) || number <= 0) { return ; diff --git a/apps/mobile/src/app/(app)/pr-review/index.tsx b/apps/mobile/src/app/(app)/pr-review/index.tsx index 2514d0c877..737b6e45e0 100644 --- a/apps/mobile/src/app/(app)/pr-review/index.tsx +++ b/apps/mobile/src/app/(app)/pr-review/index.tsx @@ -1,7 +1,9 @@ import { PrReviewConnectGate } from '@/components/pr-review/pr-review-connect-gate'; import { PrReviewEntryScreen } from '@/components/pr-review/pr-review-entry-screen'; +import { useRouteForegroundRefresh } from '@/lib/hooks/use-route-foreground-refresh'; export default function PrReviewIndexRoute() { + useRouteForegroundRefresh([[['githubPrReview']]]); return ( diff --git a/apps/mobile/src/components/agents/session-connection-indicator-state.test.ts b/apps/mobile/src/components/agents/session-connection-indicator-state.test.ts index 4f5e62e11f..849c1e967b 100644 --- a/apps/mobile/src/components/agents/session-connection-indicator-state.test.ts +++ b/apps/mobile/src/components/agents/session-connection-indicator-state.test.ts @@ -14,8 +14,14 @@ function resolve(input: { activeSessionType: 'remote' | 'cloud-agent' | 'read-only' | null; agentStatusType: StatusType; userWebConnected: boolean; + reconnectExhausted?: boolean; }): SessionConnectionState { - return resolveSessionConnectionState(input); + return resolveSessionConnectionState({ + activeSessionType: input.activeSessionType, + agentStatusType: input.agentStatusType, + userWebConnected: input.userWebConnected, + reconnectExhausted: input.reconnectExhausted ?? false, + }); } describe('resolveSessionConnectionState - remote', () => { @@ -91,3 +97,98 @@ describe('resolveSessionConnectionState - no transport', () => { } }); }); + +describe('resolveSessionConnectionState - exhausted', () => { + it('reports exhausted for a remote session when the user-web leg is down and reconnects are exhausted', () => { + expect( + resolve({ + activeSessionType: 'remote', + agentStatusType: 'idle', + userWebConnected: false, + reconnectExhausted: true, + }) + ).toBe('exhausted'); + }); + + it('reports exhausted for a remote disconnected agent status while reconnects are exhausted', () => { + expect( + resolve({ + activeSessionType: 'remote', + agentStatusType: 'disconnected', + userWebConnected: true, + reconnectExhausted: true, + }) + ).toBe('exhausted'); + }); + + it('reports down (not exhausted) for a remote session while the user-web leg is down and reconnects remain', () => { + expect( + resolve({ + activeSessionType: 'remote', + agentStatusType: 'idle', + userWebConnected: false, + reconnectExhausted: false, + }) + ).toBe('down'); + }); + + it('reports up for a connected remote session even while reconnects are exhausted', () => { + for (const status of STATUSES.filter(item => item !== 'disconnected')) { + expect( + resolve({ + activeSessionType: 'remote', + agentStatusType: status, + userWebConnected: true, + reconnectExhausted: true, + }) + ).toBe('up'); + } + }); +}); + +describe('resolveSessionConnectionState - exhausted precedence', () => { + it('never overrides none for read-only sessions while reconnects are exhausted', () => { + for (const status of STATUSES) { + for (const userWebConnected of CONNECTION_VALUES) { + expect( + resolve({ + activeSessionType: 'read-only', + agentStatusType: status, + userWebConnected, + reconnectExhausted: true, + }) + ).toBe('none'); + } + } + }); + + it('never overrides none for unresolved session types while reconnects are exhausted', () => { + for (const status of STATUSES) { + for (const userWebConnected of CONNECTION_VALUES) { + expect( + resolve({ + activeSessionType: null, + agentStatusType: status, + userWebConnected, + reconnectExhausted: true, + }) + ).toBe('none'); + } + } + }); + + it('never applies to cloud-agent sessions', () => { + for (const status of STATUSES) { + for (const userWebConnected of CONNECTION_VALUES) { + expect( + resolve({ + activeSessionType: 'cloud-agent', + agentStatusType: status, + userWebConnected, + reconnectExhausted: true, + }) + ).toBe(status === 'disconnected' ? 'down' : 'up'); + } + } + }); +}); diff --git a/apps/mobile/src/components/agents/session-connection-indicator-state.ts b/apps/mobile/src/components/agents/session-connection-indicator-state.ts index 56a4c2f34a..44822d27f0 100644 --- a/apps/mobile/src/components/agents/session-connection-indicator-state.ts +++ b/apps/mobile/src/components/agents/session-connection-indicator-state.ts @@ -1,14 +1,18 @@ import { type AgentStatus, type ResolvedSession } from '@kilocode/cloud-agent-sdk'; -export type SessionConnectionState = 'up' | 'down' | 'none'; +export type SessionConnectionState = 'up' | 'down' | 'exhausted' | 'none'; export function resolveSessionConnectionState(input: { activeSessionType: ResolvedSession['type'] | null; agentStatusType: AgentStatus['type']; userWebConnected: boolean; + reconnectExhausted: boolean; }): SessionConnectionState { if (input.activeSessionType === 'remote') { - return !input.userWebConnected || input.agentStatusType === 'disconnected' ? 'down' : 'up'; + if (!input.userWebConnected || input.agentStatusType === 'disconnected') { + return input.reconnectExhausted ? 'exhausted' : 'down'; + } + return 'up'; } if (input.activeSessionType === 'cloud-agent') { return input.agentStatusType === 'disconnected' ? 'down' : 'up'; diff --git a/apps/mobile/src/components/agents/session-connection-indicator.mounted.test.tsx b/apps/mobile/src/components/agents/session-connection-indicator.mounted.test.tsx index f487e11317..0099e70c97 100644 --- a/apps/mobile/src/components/agents/session-connection-indicator.mounted.test.tsx +++ b/apps/mobile/src/components/agents/session-connection-indicator.mounted.test.tsx @@ -5,10 +5,15 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; import { SessionConnectionIndicator } from './session-connection-indicator'; -const connection = vi.hoisted(() => ({ connected: true })); +const connection = vi.hoisted(() => ({ + connected: true, + exhausted: false, + retryConnection: vi.fn(), +})); vi.mock('react-native', () => ({ View: 'View', + Pressable: 'Pressable', })); vi.mock('@/components/ui/icons', () => ({ WifiOff: 'WifiOff', @@ -20,7 +25,13 @@ vi.mock('@/lib/hooks/use-theme-colors', () => ({ useThemeColors: () => ({ mutedForeground: '#666666' }), })); vi.mock('@/lib/hooks/use-user-web-connection-state', () => ({ - useUserWebConnectionState: () => connection.connected, + useUserWebConnectionHealth: () => ({ + isConnected: connection.connected, + reconnectExhausted: connection.exhausted, + }), +})); +vi.mock('@/components/agents/user-web-connection-provider', () => ({ + useUserWebConnection: () => ({ retryConnection: connection.retryConnection }), })); type IndicatorProps = Parameters[0]; @@ -59,6 +70,8 @@ function findHost( describe('SessionConnectionIndicator mounted', () => { beforeEach(() => { connection.connected = true; + connection.exhausted = false; + connection.retryConnection.mockClear(); }); it('renders a blank fixed row with no text for default (pending/error) props', async () => { @@ -146,4 +159,60 @@ describe('SessionConnectionIndicator mounted', () => { expect(findHost(renderer.root, 'Text')).toHaveLength(0); expect(findHost(renderer.root, 'WifiOff')).toHaveLength(0); }); + + it('renders Connection lost with a Retry action when reconnects are exhausted', async () => { + connection.connected = false; + connection.exhausted = true; + const renderer = await mount({ activeSessionType: 'remote', agentStatusType: 'idle' }); + + const view = findHost(renderer.root, 'View')[0]; + expect(view).toBeDefined(); + if (!view) { + throw new Error('view not found'); + } + expect(view.props.accessibilityElementsHidden).toBe(false); + expect(findHost(renderer.root, 'WifiOff')).toHaveLength(1); + const texts = findHost(renderer.root, 'Text'); + expect(texts.some(node => node.props.children === 'Connection lost')).toBe(true); + expect(texts.some(node => node.props.children === 'Retry')).toBe(true); + expect(findHost(renderer.root, 'Pressable')).toHaveLength(1); + }); + + it('calls retryConnection when the Retry action is pressed', async () => { + connection.connected = false; + connection.exhausted = true; + const renderer = await mount({ activeSessionType: 'remote', agentStatusType: 'idle' }); + + const pressables = findHost(renderer.root, 'Pressable'); + expect(pressables).toHaveLength(1); + const pressable = pressables[0]; + expect(pressable).toBeDefined(); + if (!pressable) { + throw new Error('pressable not found'); + } + + await act(async () => { + await Promise.resolve(); + (pressable.props.onPress as () => void)(); + }); + + expect(connection.retryConnection).toHaveBeenCalledTimes(1); + }); + + it('clears the label when the exhaustion edge flips false and the transport recovers', async () => { + connection.connected = false; + connection.exhausted = true; + const renderer = await mount({ activeSessionType: 'remote', agentStatusType: 'idle' }); + expect( + findHost(renderer.root, 'Text').some(node => node.props.children === 'Connection lost') + ).toBe(true); + + connection.exhausted = false; + connection.connected = true; + await update(renderer, { activeSessionType: 'remote', agentStatusType: 'idle' }); + + expect(findHost(renderer.root, 'Text')).toHaveLength(0); + expect(findHost(renderer.root, 'WifiOff')).toHaveLength(0); + expect(findHost(renderer.root, 'Pressable')).toHaveLength(0); + }); }); diff --git a/apps/mobile/src/components/agents/session-connection-indicator.tsx b/apps/mobile/src/components/agents/session-connection-indicator.tsx index c74170b7c2..3e4923c9cf 100644 --- a/apps/mobile/src/components/agents/session-connection-indicator.tsx +++ b/apps/mobile/src/components/agents/session-connection-indicator.tsx @@ -1,10 +1,11 @@ import { type AgentStatus, type ResolvedSession } from '@kilocode/cloud-agent-sdk'; import { WifiOff } from '@/components/ui/icons'; import { useEffect, useRef } from 'react'; -import { View } from 'react-native'; +import { Pressable, View } from 'react-native'; import { Text } from '@/components/ui/text'; -import { useUserWebConnectionState } from '@/lib/hooks/use-user-web-connection-state'; +import { useUserWebConnection } from '@/components/agents/user-web-connection-provider'; +import { useUserWebConnectionHealth } from '@/lib/hooks/use-user-web-connection-state'; import { useThemeColors } from '@/lib/hooks/use-theme-colors'; import { resolveSessionConnectionState } from './session-connection-indicator-state'; @@ -18,12 +19,14 @@ export function SessionConnectionIndicator({ activeSessionType = null, agentStatusType = 'idle', }: Readonly) { - const userWebConnected = useUserWebConnectionState(); + const { isConnected: userWebConnected, reconnectExhausted } = useUserWebConnectionHealth(); + const connection = useUserWebConnection(); const colors = useThemeColors(); const state = resolveSessionConnectionState({ activeSessionType, agentStatusType, userWebConnected, + reconnectExhausted, }); // "Ever up" is a committed-state ref (written in an effect), so a drop // after the first committed up reads "Reconnecting…" while a cold start @@ -38,19 +41,39 @@ export function SessionConnectionIndicator({ let label: string | null = null; if (state === 'down') { label = wasUpRef.current ? 'Reconnecting…' : 'Connecting…'; + } else if (state === 'exhausted') { + label = 'Connection lost'; } + // The exhausted state adds an interactive `Retry` action. The row stays a + // single accessibility element for the non-interactive labels only; with a + // pressable child the label text and the action must stay separately + // reachable for assistive technology. + const interactive = state === 'exhausted'; return ( {label !== null ? ( <> {label} + {interactive ? ( + { + connection.retryConnection(); + }} + hitSlop={8} + className="active:opacity-70" + accessibilityRole="button" + accessibilityLabel="Retry connection" + > + Retry + + ) : null} ) : null} diff --git a/apps/mobile/src/components/agents/session-list-screen.mounted.test.tsx b/apps/mobile/src/components/agents/session-list-screen.mounted.test.tsx new file mode 100644 index 0000000000..3e9ff1d5d4 --- /dev/null +++ b/apps/mobile/src/components/agents/session-list-screen.mounted.test.tsx @@ -0,0 +1,232 @@ +/* eslint-disable typescript-eslint/no-deprecated -- react-test-renderer is the DOM-free renderer used to mount React/RN trees under vitest (same pattern as session-list-body-empty.mounted.test.tsx) */ +import { createElement } from 'react'; +import TestRenderer, { act } from 'react-test-renderer'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { AgentSessionListScreen } from './session-list-screen'; + +const appState = vi.hoisted(() => { + const listeners = new Set<(state: string) => void>(); + return { + listeners, + addEventListener: (_event: string, listener: (state: string) => void) => { + listeners.add(listener); + return { + remove: () => { + listeners.delete(listener); + }, + }; + }, + emit: (state: string): void => { + for (const listener of listeners) { + listener(state); + } + }, + }; +}); + +const focusState = vi.hoisted(() => ({ current: true as boolean })); +const focusCallback = vi.hoisted(() => ({ + current: undefined as (() => void) | undefined, +})); +const refetchSpy = vi.hoisted(() => vi.fn()); +const invalidateQueries = vi.hoisted(() => vi.fn()); + +vi.mock('react-native', () => ({ + Platform: { OS: 'ios' }, + Pressable: 'Pressable', + View: 'View', + useWindowDimensions: () => ({ fontScale: 1 }), + AppState: { addEventListener: appState.addEventListener }, +})); +vi.mock('react-native-reanimated', () => ({ + __esModule: true, + default: { View: 'AnimatedView' }, + LinearTransition: 'LinearTransition', +})); +vi.mock('react-native-safe-area-context', () => ({ + useSafeAreaInsets: () => ({ bottom: 0 }), +})); +vi.mock('expo-router', () => ({ + useNavigation: () => ({ isFocused: () => focusState.current }), + useFocusEffect: (effect: () => void) => { + focusCallback.current = effect; + }, + useRouter: () => ({ push: vi.fn() }), +})); +vi.mock('@tanstack/react-query', () => ({ + useQueryClient: () => ({ invalidateQueries }), +})); + +vi.mock('@/components/ui/icons', () => ({ + Plus: 'Plus', +})); +vi.mock('@/components/agents/active-now-section', () => ({ + ActiveNowSection: 'ActiveNowSection', +})); +vi.mock('@/components/agents/session-list-content', () => ({ + AgentSessionListContent: 'AgentSessionListContent', + FAB_MARGIN: 0, + FAB_SIZE: 0, +})); +vi.mock('@/components/agents/session-list-header-actions', () => ({ + SessionListHeaderActions: 'SessionListHeaderActions', +})); +vi.mock('@/components/agents/session-list-search-header', () => ({ + SessionListSearchHeader: 'SessionListSearchHeader', +})); +vi.mock('@/components/agents/platform-filter-modal', () => ({ + SessionFilterChips: 'SessionFilterChips', + SessionFilterModal: 'SessionFilterModal', +})); +vi.mock('@/components/screen-header', () => ({ + ScreenHeader: 'ScreenHeader', +})); +vi.mock('@/components/agents/use-session-search-input', () => ({ + useSessionSearchInput: () => ({ + searchQuery: '', + searchInputRef: { current: null }, + hasText: false, + awaitingCommit: false, + handleSearchInputChange: vi.fn(), + handleClearSearchInput: vi.fn(), + clearSearchInput: vi.fn(), + searchController: { clearBroadly: vi.fn() }, + }), +})); +vi.mock('@/components/agents/use-agent-session-navigator', () => ({ + useAgentSessionNavigator: () => vi.fn(), +})); +vi.mock('@/components/agents/use-agent-session-list-data', () => ({ + useAgentSessionListData: () => ({ + storedSessions: [], + activeSessions: [], + activeIsError: false, + isLoading: false, + paging: {}, + refetch: refetchSpy, + handleRetry: vi.fn(), + handleRefetch: vi.fn(), + isSearching: false, + search: { isFetching: false }, + projectOptions: [], + contentIsError: false, + pinnedActive: [], + sections: [], + }), +})); +vi.mock('@/lib/hooks/use-persisted-agent-session-filters', () => ({ + usePersistedAgentSessionFilters: () => ({ + platformFilter: [], + projectFilter: [], + sortBy: 'updated', + hasLoaded: true, + setFilters: vi.fn(), + setPlatformFilter: vi.fn(), + setProjectFilter: vi.fn(), + }), +})); +vi.mock('@/lib/organization-context', () => ({ + useOrganization: () => ({ organizationId: null, isLoaded: true }), +})); +vi.mock('@/lib/hooks/use-theme-colors', () => ({ + useThemeColors: () => ({ primaryForeground: '#ffffff' }), +})); + +const mountedRenderers: TestRenderer.ReactTestRenderer[] = []; + +async function renderScreen(): Promise { + const rendererRef: { current: TestRenderer.ReactTestRenderer | undefined } = { + current: undefined, + }; + await act(async () => { + await Promise.resolve(); + rendererRef.current = TestRenderer.create(createElement(AgentSessionListScreen)); + }); + const renderer = rendererRef.current; + if (!renderer) { + throw new Error('renderer was not created'); + } + mountedRenderers.push(renderer); + return renderer; +} + +describe('AgentSessionListScreen foreground refresh', () => { + beforeEach(() => { + (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + focusState.current = true; + focusCallback.current = undefined; + refetchSpy.mockClear(); + invalidateQueries.mockClear(); + }); + + afterEach(() => { + act(() => { + for (const renderer of mountedRenderers) { + renderer.unmount(); + } + }); + mountedRenderers.length = 0; + appState.listeners.clear(); + vi.restoreAllMocks(); + }); + + it('refetches and invalidates the active-sessions tray on foreground while focused', async () => { + await renderScreen(); + + // The route-focus refetch fires once on mount focus. + act(() => { + focusCallback.current?.(); + }); + expect(refetchSpy).toHaveBeenCalledTimes(1); + + act(() => { + appState.emit('background'); + }); + act(() => { + appState.emit('active'); + }); + + expect(refetchSpy).toHaveBeenCalledTimes(2); + expect(invalidateQueries).toHaveBeenCalledWith({ queryKey: [['activeSessions']] }); + }); + + it('does not refetch or invalidate on foreground while unfocused', async () => { + focusState.current = false; + await renderScreen(); + + act(() => { + appState.emit('background'); + }); + act(() => { + appState.emit('active'); + }); + + expect(refetchSpy).not.toHaveBeenCalled(); + expect(invalidateQueries).not.toHaveBeenCalled(); + }); + + it('does not refetch or invalidate on foreground after focus is lost post-mount', async () => { + await renderScreen(); + + // The route-focus refetch fires once on mount focus. + act(() => { + focusCallback.current?.(); + }); + expect(refetchSpy).toHaveBeenCalledTimes(1); + + // Blur the tab after mount WITHOUT re-rendering: a frozen (unfocused) tab + // does not re-render, so the AppState callback must read focus live. + focusState.current = false; + + act(() => { + appState.emit('background'); + }); + act(() => { + appState.emit('active'); + }); + + expect(refetchSpy).toHaveBeenCalledTimes(1); + expect(invalidateQueries).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/mobile/src/components/agents/session-list-screen.tsx b/apps/mobile/src/components/agents/session-list-screen.tsx index bc850d50dc..386a8e2815 100644 --- a/apps/mobile/src/components/agents/session-list-screen.tsx +++ b/apps/mobile/src/components/agents/session-list-screen.tsx @@ -1,5 +1,6 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { AppState, Platform, Pressable, useWindowDimensions, View } from 'react-native'; +import { useQueryClient } from '@tanstack/react-query'; import Animated, { LinearTransition } from 'react-native-reanimated'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { Plus } from '@/components/ui/icons'; @@ -26,10 +27,12 @@ import { useOrganization } from '@/lib/organization-context'; import { useThemeColors } from '@/lib/hooks/use-theme-colors'; import { getEffectiveTabBarHeight } from '@/lib/tab-bar-layout'; -import { type Href, useFocusEffect, useRouter } from 'expo-router'; +import { type Href, useFocusEffect, useNavigation, useRouter } from 'expo-router'; export function AgentSessionListScreen() { const router = useRouter(); + const navigation = useNavigation(); + const queryClient = useQueryClient(); const colors = useThemeColors(); const { bottom } = useSafeAreaInsets(); const { fontScale } = useWindowDimensions(); @@ -102,17 +105,21 @@ export function AgentSessionListScreen() { // from the list hook), so an OS foreground transition must be driven here — // through the same wrapped `refetch` as navigation focus — to keep every // stored refetch serialized by the shared operation coordinator (backfill - // and departure never overlap a refetch). + // and departure never overlap a refetch). A frozen (unfocused) Agents tab + // must NOT refetch on foreground: only the focused tab refreshes the stored + // list and invalidates the active-sessions tray. Focus is read live via + // `navigation.isFocused()` because a frozen tree does not re-render. useEffect(() => { const subscription = AppState.addEventListener('change', nextState => { - if (nextState === 'active') { + if (nextState === 'active' && navigation.isFocused()) { void refetchRef.current(); + void queryClient.invalidateQueries({ queryKey: [['activeSessions']] }); } }); return () => { subscription.remove(); }; - }, []); + }, [queryClient, navigation]); const showSearchBusy = selectShowSearchBusy({ awaitingCommit, diff --git a/apps/mobile/src/components/agents/user-web-connection-provider.mounted.test.tsx b/apps/mobile/src/components/agents/user-web-connection-provider.mounted.test.tsx index 8f461abf55..8702427664 100644 --- a/apps/mobile/src/components/agents/user-web-connection-provider.mounted.test.tsx +++ b/apps/mobile/src/components/agents/user-web-connection-provider.mounted.test.tsx @@ -25,6 +25,9 @@ vi.mock('@kilocode/cloud-agent-sdk/user-web-connection', () => ({ destroy: vi.fn(), isConnected: vi.fn(() => false), onConnectionChange: vi.fn(() => vi.fn()), + isReconnectExhausted: vi.fn(() => false), + onReconnectExhaustionChange: vi.fn(() => vi.fn()), + retryConnection: vi.fn(), subscribeToCliSession: vi.fn(() => vi.fn()), sendCommand: vi.fn(), sendCommandToConnection: vi.fn(), diff --git a/apps/mobile/src/components/notifications-screen.mounted.test.tsx b/apps/mobile/src/components/notifications-screen.mounted.test.tsx index 64611ad6ce..1d476a7680 100644 --- a/apps/mobile/src/components/notifications-screen.mounted.test.tsx +++ b/apps/mobile/src/components/notifications-screen.mounted.test.tsx @@ -21,6 +21,8 @@ const registerTokenMutationFn = vi.hoisted(() => vi.fn()); const getNotificationPermissionStatus = vi.hoisted(() => vi.fn()); const getDevicePushToken = vi.hoisted(() => vi.fn()); const toastError = vi.hoisted(() => vi.fn()); +const registerTokenOptions = vi.hoisted(() => vi.fn()); +const setPreferenceOptions = vi.hoisted(() => vi.fn()); const useKiloClawTabVisible = vi.hoisted(() => vi.fn(() => true)); vi.mock('@/lib/hooks/use-kiloclaw-tab-visible', () => ({ @@ -87,18 +89,24 @@ vi.mock('@/lib/trpc', () => ({ queryOptions: () => ({ queryKey: ['getNotificationPreferences'], queryFn: prefsQueryFn }), }, registerPushToken: { - mutationOptions: (opts: object) => ({ - ...opts, - mutationFn: registerTokenMutationFn, - mutationKey: ['registerPushToken'], - }), + mutationOptions: (opts: object) => { + registerTokenOptions(opts); + return { + ...opts, + mutationFn: registerTokenMutationFn, + mutationKey: ['registerPushToken'], + }; + }, }, setNotificationPreferences: { - mutationOptions: (opts: object) => ({ - ...opts, - mutationFn: setPreferenceMutationFn, - mutationKey: ['setNotificationPreferences'], - }), + mutationOptions: (opts: object) => { + setPreferenceOptions(opts); + return { + ...opts, + mutationFn: setPreferenceMutationFn, + mutationKey: ['setNotificationPreferences'], + }; + }, }, }, }), @@ -319,3 +327,61 @@ describe('NotificationsScreen KiloClaw activity row', () => { expect(skeletonCount(renderer.root)).toBeGreaterThan(0); }); }); + +describe('NotificationsScreen mutation serialization (scope.id + generation guard)', () => { + beforeEach(() => { + vi.clearAllMocks(); + getNotificationPermissionStatus.mockResolvedValue('granted'); + getDevicePushToken.mockResolvedValue('device-token'); + pushTokensQueryFn.mockResolvedValue([{ token: 'device-token', platform: 'android' }]); + setPreferenceMutationFn.mockResolvedValue({}); + registerTokenMutationFn.mockResolvedValue({ success: true }); + }); + + it('scopes both mutations to their fixed cache entries', async () => { + prefsQueryFn.mockResolvedValue(fullPrefs()); + const { renderer } = await renderScreen(); + await waitForEnabledSwitch(renderer, 'Chat messages'); + + expect(registerTokenOptions).toHaveBeenCalledWith( + expect.objectContaining({ scope: { id: 'push-tokens' } }) + ); + expect(setPreferenceOptions).toHaveBeenCalledWith( + expect.objectContaining({ scope: { id: 'notification-preferences' } }) + ); + }); + + it('a failing older category mutation does not roll back while a newer one owns the cache', async () => { + prefsQueryFn.mockResolvedValue(fullPrefs({ chatMessages: true, agentAttention: true })); + const { renderer, queryClient } = await renderScreen(); + await waitForEnabledSwitch(renderer, 'Chat messages'); + + const opts = setPreferenceOptions.mock.calls[0]?.[0] as + | { + onMutate?: (vars: Record) => Promise; + onError?: (error: Error, vars: Record, context: unknown) => void; + } + | undefined; + if (!opts?.onMutate || !opts.onError) { + throw new Error('setNotificationPreferences options not captured'); + } + + const older = await opts.onMutate({ chatMessages: false }); + const newer = await opts.onMutate({ agentAttention: false }); + + // The older failure must not restore its snapshot over the newer write. + opts.onError(new Error('boom'), { chatMessages: false }, older); + const afterOlder = queryClient.getQueryData>([ + 'getNotificationPreferences', + ]); + expect(afterOlder?.agentAttention).toBe(false); + + // The newer failure (latest generation) rolls back its own snapshot. + opts.onError(new Error('boom'), { agentAttention: false }, newer); + const afterNewer = queryClient.getQueryData>([ + 'getNotificationPreferences', + ]); + expect(afterNewer?.agentAttention).toBe(true); + expect(toastError).toHaveBeenCalledTimes(2); + }); +}); diff --git a/apps/mobile/src/components/notifications-screen.tsx b/apps/mobile/src/components/notifications-screen.tsx index 86e198d7e3..d0828caaf5 100644 --- a/apps/mobile/src/components/notifications-screen.tsx +++ b/apps/mobile/src/components/notifications-screen.tsx @@ -4,7 +4,7 @@ * has seven keys; the KiloClaw row is hidden when useKiloClawTabVisible is false. * Extracting subcomponents would re-encode the same hooks. The screen stays a * single rendered surface. */ -import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import { hashKey, useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import * as Application from 'expo-application'; import * as Notifications from 'expo-notifications'; import { @@ -43,6 +43,10 @@ import { rollbackAgentPushOptimistic, } from '@/lib/hooks/agent-push-preference'; import { useAppLifecycle } from '@/lib/hooks/use-app-lifecycle'; +import { + isLatestMutationGeneration, + nextMutationGeneration, +} from '@/lib/hooks/mutation-generations'; import { useKiloClawTabVisible } from '@/lib/hooks/use-kiloclaw-tab-visible'; import { useThemeColors } from '@/lib/hooks/use-theme-colors'; import { @@ -305,10 +309,13 @@ export function NotificationsScreen() { void queryClient.invalidateQueries({ queryKey: preferencesQueryKey }); }, [queryClient, pushTokensQueryKey, preferencesQueryKey]); + // onError policy: roll back the onMutate snapshot (latest generation only) + // and toast error.message. const registerToken = useMutation( trpc.user.registerPushToken.mutationOptions({ onMutate: async () => { await queryClient.cancelQueries({ queryKey: pushTokensQueryKey }); + const generation = nextMutationGeneration(hashKey(pushTokensQueryKey)); const previous = queryClient.getQueryData(pushTokensQueryKey); if (deviceToken) { queryClient.setQueryData(pushTokensQueryKey, (old: typeof pushTokens) => [ @@ -316,15 +323,21 @@ export function NotificationsScreen() { { token: deviceToken, platform: getPlatform() }, ]); } - return { previous }; + return { previous, generation }; }, onError: (error, _vars, context) => { - if (context?.previous) { + if ( + context?.previous && + isLatestMutationGeneration(hashKey(pushTokensQueryKey), context.generation) + ) { queryClient.setQueryData(pushTokensQueryKey, context.previous); } toast.error(error.message); }, onSettled: invalidateAll, + // One fixed cache entry, so a single static scope id serializes the + // network call (rule 2). + scope: { id: 'push-tokens' }, }) ); @@ -332,12 +345,11 @@ export function NotificationsScreen() { // category. We pass ONE key per call so the server-side partial update // only touches the column the user is flipping; the optimistic helper // scopes its in-memory flip to that same key. + // onError policy: roll back the onMutate snapshot (latest generation only) + // and toast error.message. const setPreference = useMutation( trpc.user.setNotificationPreferences.mutationOptions({ - // react-query's onMutate signature requires either an async function or - // a plain return of a Promise; we need async semantics so the optimistic - // write commits before the mutation body runs. - // eslint-disable-next-line require-await, typescript-eslint/return-await + // async so the optimistic write commits before the mutation body runs. onMutate: async variables => { const category = categoryFromVariables(variables); if (category == null) { @@ -347,19 +359,26 @@ export function NotificationsScreen() { if (next === undefined) { return undefined; } - return applyAgentPushOptimistic({ + const generation = nextMutationGeneration(hashKey(preferencesQueryKey)); + const context = await applyAgentPushOptimistic({ queryClient, queryKey: preferencesQueryKey, next, category, }); + return { ...context, generation }; }, onError: (error, variables, context) => { - rollbackAgentPushOptimistic({ - queryClient, - queryKey: preferencesQueryKey, - context, - }); + if ( + context && + isLatestMutationGeneration(hashKey(preferencesQueryKey), context.generation) + ) { + rollbackAgentPushOptimistic({ + queryClient, + queryKey: preferencesQueryKey, + context, + }); + } toast.error(error.message); if (variables.notificationPreviews !== undefined) { setPreviewErrorCode(readTrpcErrorField(error, 'code')); @@ -382,6 +401,9 @@ export function NotificationsScreen() { } void queryClient.invalidateQueries({ queryKey: preferencesQueryKey }); }, + // One fixed cache entry, so a single static scope id serializes the + // network call (rule 2). + scope: { id: 'notification-preferences' }, }) ); diff --git a/apps/mobile/src/components/profile-credits-card.tsx b/apps/mobile/src/components/profile-credits-card.tsx index 5b0be1d43d..4cb9ec358b 100644 --- a/apps/mobile/src/components/profile-credits-card.tsx +++ b/apps/mobile/src/components/profile-credits-card.tsx @@ -1,8 +1,6 @@ import { useActionSheet } from '@expo/react-native-action-sheet'; import { formatDollars, fromMicrodollars } from '@kilocode/app-shared/utils'; -import { keepPreviousData, useQuery, useQueryClient } from '@tanstack/react-query'; -import { useFocusEffect } from 'expo-router'; -import { useCallback, useRef } from 'react'; +import { keepPreviousData, useQuery } from '@tanstack/react-query'; import { ChevronDown } from '@/components/ui/icons'; import { ActivityIndicator, Platform, Pressable, View } from 'react-native'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; @@ -31,24 +29,6 @@ export function CreditsCard({ enabled, orgs }: Readonly) { const { bottom } = useSafeAreaInsets(); const { organizationId, setOrganizationId } = useOrganization(); const selectedOrgId = organizationId ?? undefined; - const queryClient = useQueryClient(); - - // Credits and pass state also change from webhooks (renewal, refund, expiry), - // which no client action invalidates. Refetching when the tab regains focus - // catches those without polling in the background. The first focus is the - // mount fetch, so it is skipped. - const tabFocusedBeforeRef = useRef(false); - useFocusEffect( - useCallback(() => { - if (!tabFocusedBeforeRef.current) { - tabFocusedBeforeRef.current = true; - return; - } - void queryClient.invalidateQueries(trpc.user.getContextBalance.pathFilter()); - void queryClient.invalidateQueries(trpc.user.getCreditBlocks.pathFilter()); - void queryClient.invalidateQueries(trpc.kiloPass.getState.pathFilter()); - }, [queryClient, trpc]) - ); const { data: balance, diff --git a/apps/mobile/src/components/security-agent/settings-overview-screen.mounted.test.tsx b/apps/mobile/src/components/security-agent/settings-overview-screen.mounted.test.tsx index 43a681e2f8..7444cf36fd 100644 --- a/apps/mobile/src/components/security-agent/settings-overview-screen.mounted.test.tsx +++ b/apps/mobile/src/components/security-agent/settings-overview-screen.mounted.test.tsx @@ -17,15 +17,18 @@ const config = vi.hoisted(() => ({ data: null as unknown, isLoading: false, isError: false, + fetchStatus: 'idle' as 'fetching' | 'paused' | 'idle', refetch: vi.fn(), })); const capability = vi.hoisted(() => ({ canManage: true, - isLoading: false, + status: 'allowed' as 'loading' | 'error' | 'denied' | 'allowed', isError: false, - isFetching: false, refetch: vi.fn(), })); +const committedConnectivity = vi.hoisted(() => ({ + status: 'online' as 'online' | 'offline' | 'unknown', +})); const repositories = vi.hoisted(() => ({ data: null as unknown[] | null, isLoading: false, @@ -44,6 +47,10 @@ const configureRows = vi.hoisted(() => ({ rows: [] as { title: string; subtitle?: string; onPress?: () => void }[], })); +const platformErrorScreens = vi.hoisted(() => ({ + screens: [] as { variant?: string; message?: string; onRetry?: () => void }[], +})); + vi.mock('react-native', () => ({ View: 'View', Switch: 'Switch', @@ -68,13 +75,21 @@ vi.mock('@/lib/hooks/use-security-agent', () => ({ useSetSecurityAgentEnabled: () => setEnabled, useTrackSecurityAgentInteraction: () => trackInteraction, })); +vi.mock('@/lib/hooks/use-offline-banner-state', () => ({ + useCommittedConnectivityStatus: () => committedConnectivity.status, +})); vi.mock('@/lib/security-agent', () => ({ getSecurityAgentPath: (scope: string, section: string) => `/security/${scope}/${section}`, })); vi.mock('@/components/security-agent/audit-report-button', () => ({ AuditReportButton: () => null, })); -vi.mock('@/components/platform-error-screen', () => ({ PlatformErrorScreen: () => null })); +vi.mock('@/components/platform-error-screen', () => ({ + PlatformErrorScreen: (props: { variant?: string; message?: string; onRetry?: () => void }) => { + platformErrorScreens.screens.push(props); + return null; + }, +})); vi.mock('@/components/screen-header', () => ({ ScreenHeader: () => null })); vi.mock('@/components/ui/configure-row', () => ({ ConfigureRow: (props: { title: string; subtitle?: string; onPress?: () => void }) => { @@ -130,6 +145,15 @@ function findSwitch(root: I): I { return n; } +function hasSwitch(root: I): boolean { + return ( + root.findAll(n => typeof n.type === 'string' && (n.type as string) === 'Switch').length > 0 + ); +} + +const denialCopy = + 'Security Agent is disabled. Only organization owners and billing managers can turn it on.'; + function renderedTexts(root: I): string[] { return root .findAll( @@ -141,20 +165,24 @@ function renderedTexts(root: I): string[] { .map(n => n.props.children as string); } +function resetMocks() { + config.data = null; + config.isLoading = false; + config.isError = false; + config.fetchStatus = 'idle'; + capability.canManage = true; + capability.status = 'allowed'; + capability.isError = false; + committedConnectivity.status = 'online'; + repositories.data = []; + repositories.isLoading = false; + repositories.isError = false; + configureRows.rows = []; + platformErrorScreens.screens = []; +} + describe('SettingsOverviewScreen disabled switch', () => { - beforeEach(() => { - config.data = null; - config.isLoading = false; - config.isError = false; - capability.canManage = true; - repositories.data = []; - repositories.isLoading = false; - repositories.isError = false; - setEnabled.isPending = false; - setEnabled.mutate.mockClear(); - trackInteraction.mutate.mockClear(); - configureRows.rows = []; - }); + beforeEach(resetMocks); it('disables the switch while disabled with no effective repo selection', () => { config.data = disabledConfig(); @@ -182,19 +210,7 @@ describe('SettingsOverviewScreen disabled switch', () => { }); describe('SettingsOverviewScreen disabled copy and CTA', () => { - beforeEach(() => { - config.data = null; - config.isLoading = false; - config.isError = false; - capability.canManage = true; - repositories.data = []; - repositories.isLoading = false; - repositories.isError = false; - setEnabled.isPending = false; - setEnabled.mutate.mockClear(); - trackInteraction.mutate.mockClear(); - configureRows.rows = []; - }); + beforeEach(resetMocks); it('shows the empty-selection copy while disabled with no effective repo', () => { config.data = disabledConfig(); @@ -227,6 +243,7 @@ describe('SettingsOverviewScreen disabled copy and CTA', () => { it('does not offer the CTA to a non-manager', () => { capability.canManage = false; + capability.status = 'denied'; config.data = disabledConfig(); repositories.data = [{ id: 1 }]; renderScreen(); @@ -236,19 +253,7 @@ describe('SettingsOverviewScreen disabled copy and CTA', () => { }); describe('SettingsOverviewScreen repository query loading and error', () => { - beforeEach(() => { - config.data = null; - config.isLoading = false; - config.isError = false; - capability.canManage = true; - repositories.data = []; - repositories.isLoading = false; - repositories.isError = false; - setEnabled.isPending = false; - setEnabled.mutate.mockClear(); - trackInteraction.mutate.mockClear(); - configureRows.rows = []; - }); + beforeEach(resetMocks); it('does not read a loading repo query as empty in all mode', () => { config.data = disabledConfig({ repositorySelectionMode: 'all' }); @@ -280,3 +285,74 @@ describe('SettingsOverviewScreen repository query loading and error', () => { expect(configureRows.rows.map(r => r.title)).toContain('Select repositories'); }); }); + +describe('SettingsOverviewScreen capability and connectivity states', () => { + beforeEach(resetMocks); + + it('renders the skeleton while the capability is loading', () => { + config.data = disabledConfig(); + capability.status = 'loading'; + const root = renderScreen(); + + expect(platformErrorScreens.screens).toEqual([]); + expect(hasSwitch(root.root)).toBe(false); + expect(renderedTexts(root.root)).not.toContain(denialCopy); + }); + + it('shows the permissions error with retry and no denial copy when capability errors', () => { + config.data = disabledConfig(); + capability.status = 'error'; + const root = renderScreen(); + + expect(platformErrorScreens.screens[0]?.message).toBe('Could not load permissions'); + expect(platformErrorScreens.screens[0]?.onRetry).toBeTypeOf('function'); + expect(renderedTexts(root.root)).not.toContain(denialCopy); + }); + + it('keeps the resolved branch when a background refetch fails with a settled role', () => { + config.data = disabledConfig(); + capability.isError = true; + const root = renderScreen(); + + expect(platformErrorScreens.screens).toEqual([]); + expect(hasSwitch(root.root)).toBe(true); + }); + + it('shows denial copy and hides the switch when denied', () => { + config.data = disabledConfig(); + capability.status = 'denied'; + capability.canManage = false; + const root = renderScreen(); + + expect(hasSwitch(root.root)).toBe(false); + expect(renderedTexts(root.root)).toContain(denialCopy); + }); + + it('shows the switch when allowed', () => { + config.data = disabledConfig(); + const root = renderScreen(); + + expect(hasSwitch(root.root)).toBe(true); + }); + + it('shows the offline variant when paused with committed offline', () => { + config.data = null; + config.fetchStatus = 'paused'; + committedConnectivity.status = 'offline'; + const root = renderScreen(); + + expect(platformErrorScreens.screens[0]?.variant).toBe('offline'); + expect(platformErrorScreens.screens[0]?.message).toBe('Could not load Security Agent settings'); + expect(hasSwitch(root.root)).toBe(false); + }); + + it('renders the skeleton when paused but connectivity is unknown', () => { + config.data = null; + config.fetchStatus = 'paused'; + committedConnectivity.status = 'unknown'; + const root = renderScreen(); + + expect(platformErrorScreens.screens).toEqual([]); + expect(hasSwitch(root.root)).toBe(false); + }); +}); diff --git a/apps/mobile/src/components/security-agent/settings-overview-screen.tsx b/apps/mobile/src/components/security-agent/settings-overview-screen.tsx index 0a88a63d60..b2e4ef8cc1 100644 --- a/apps/mobile/src/components/security-agent/settings-overview-screen.tsx +++ b/apps/mobile/src/components/security-agent/settings-overview-screen.tsx @@ -18,6 +18,7 @@ import { useSetSecurityAgentEnabled, useTrackSecurityAgentInteraction, } from '@/lib/hooks/use-security-agent'; +import { useCommittedConnectivityStatus } from '@/lib/hooks/use-offline-banner-state'; import { getSecurityAgentPath } from '@/lib/security-agent'; import { capitalize } from '@/lib/utils'; @@ -52,7 +53,8 @@ export function SettingsOverviewScreen({ }: Readonly<{ scope: string; presentation?: SettingsOverviewPresentation }>) { const router = useRouter(); const config = useSecurityAgentConfig(scope); - const canManage = useSecurityAgentCapability(scope).canManage; + const capability = useSecurityAgentCapability(scope); + const committedConnectivity = useCommittedConnectivityStatus(); const setEnabled = useSetSecurityAgentEnabled(scope); const trackInteraction = useTrackSecurityAgentInteraction(scope); const repositories = useSecurityAgentRepositories(scope); @@ -72,7 +74,10 @@ export function SettingsOverviewScreen({ trackRef.current({ interaction: 'settings_config_viewed' }); }, []); - if (config.isError && !config.data) { + if ( + !config.data && + (config.isError || (config.fetchStatus === 'paused' && committedConnectivity === 'offline')) + ) { return ( ); } - if (config.isLoading || !config.data) { + if (capability.status === 'error') { + return ( + void capability.refetch()} + /> + ); + } + if (config.isLoading || !config.data || capability.status === 'loading') { return ; } @@ -105,7 +119,8 @@ export function SettingsOverviewScreen({ // offer a direct path to the repo picker. Hide the CTA only when the repo set // is settled and empty (nothing to select); a loading or failed repo query // keeps the CTA reachable. - const showRepoCta = !data.isEnabled && canManage && !hasEffectiveRepo && !repositoriesEmpty; + const showRepoCta = + !data.isEnabled && capability.canManage && !hasEffectiveRepo && !repositoriesEmpty; const repoCountLabel = data.repositorySelectionMode === 'all' ? 'All repositories' @@ -155,7 +170,7 @@ export function SettingsOverviewScreen({ // connected-but-disabled counterpart: settings-overview-screen is where // scope-entry redirects once the agent is disabled, so the same action // needs to be reachable here too. - const auditAction = canManage ? : null; + const auditAction = capability.canManage ? : null; // Render the disabled-agent copy in three states: a still-loading repo set // (skeleton), a failed repo set (error + Retry), or a settled set (copy). @@ -180,7 +195,7 @@ export function SettingsOverviewScreen({ } return ( - {getDisabledCopy(canManage, hasEffectiveRepo)} + {getDisabledCopy(capability.canManage, hasEffectiveRepo)} ); }; @@ -196,7 +211,7 @@ export function SettingsOverviewScreen({ {data.isEnabled ? repoCountLabel : 'Disabled'} - {canManage ? ( + {capability.canManage ? ( { + it('increments per key and returns the new generation', () => { + expect(nextMutationGeneration('a')).toBe(1); + expect(nextMutationGeneration('a')).toBe(2); + expect(nextMutationGeneration('b')).toBe(1); + }); + + it('tracks keys independently', () => { + nextMutationGeneration('x'); + nextMutationGeneration('y'); + nextMutationGeneration('y'); + expect(isLatestMutationGeneration('x', 1)).toBe(true); + expect(isLatestMutationGeneration('y', 2)).toBe(true); + }); + + it('isLatestMutationGeneration is true only for the latest generation', () => { + const first = nextMutationGeneration('key'); + const second = nextMutationGeneration('key'); + expect(isLatestMutationGeneration('key', second)).toBe(true); + expect(isLatestMutationGeneration('key', first)).toBe(false); + }); + + it('returns false for a key with no generation yet', () => { + expect(isLatestMutationGeneration('untouched', 1)).toBe(false); + }); +}); diff --git a/apps/mobile/src/lib/hooks/mutation-generations.ts b/apps/mobile/src/lib/hooks/mutation-generations.ts new file mode 100644 index 0000000000..9fabaa506e --- /dev/null +++ b/apps/mobile/src/lib/hooks/mutation-generations.ts @@ -0,0 +1,27 @@ +// Pure per-key generation counter, kept dependency-free so it can be +// vitest'd in node env without pulling in react-native transitively +// (same reasoning as save-chain.ts). + +const generations = new Map(); + +/** + * Increments and returns the per-key generation counter. Call this in a + * mutation's `onMutate` to stamp the optimistic write it is about to make. + */ +export function nextMutationGeneration(key: string): number { + const next = (generations.get(key) ?? 0) + 1; + generations.set(key, next); + return next; +} + +/** + * True when the per-key counter still equals `generation`, i.e. no newer + * mutation has stamped the same cache since. An older mutation's failure + * must not roll back cache state a newer mutation already owns; the newer + * mutation's settle-time invalidation reconciles with server truth. + * Accepted residual: when both fail, the older optimistic value can show + * until the settle invalidation refetches. + */ +export function isLatestMutationGeneration(key: string, generation: number): boolean { + return generations.get(key) === generation; +} diff --git a/apps/mobile/src/lib/hooks/use-code-reviewer.test.ts b/apps/mobile/src/lib/hooks/use-code-reviewer.test.ts index 637d7de844..28c78b5657 100644 --- a/apps/mobile/src/lib/hooks/use-code-reviewer.test.ts +++ b/apps/mobile/src/lib/hooks/use-code-reviewer.test.ts @@ -1,14 +1,17 @@ +/* eslint-disable max-lines -- one file for the save/toggle serialization and generation-guard rollback suites */ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { type ConfigPatch, PERSONAL_SCOPE } from '@/lib/code-reviewer-config'; -import { useSaveReviewConfig } from './use-code-reviewer'; +import { useSaveReviewConfig, useToggleReviewer } from './use-code-reviewer'; type MutationOptions = { mutationFn?: (vars: unknown) => Promise; - onError?: (error: unknown) => void; + onMutate?: (vars: unknown) => Promise | unknown; + onError?: (error: unknown, vars?: unknown, context?: unknown) => void; onSettled?: () => void; onSuccess?: (data: unknown) => void; + scope?: { id: string }; }; type PersonalPatch = { @@ -37,6 +40,8 @@ const personalPatchMutateMock = vi.fn(); const orgPatchMutateMock = vi.fn(); const personalSaveMutateMock = vi.fn(); const orgSaveMutateMock = vi.fn(); +const personalToggleMutateMock = vi.fn(); +const orgToggleMutateMock = vi.fn(); const invalidateQueriesMock = vi.fn(); const cancelQueriesMock = vi.fn(); const getQueryDataMock = vi.fn(); @@ -57,6 +62,7 @@ vi.mock('@tanstack/react-query', () => ({ setQueryData: setQueryDataMock, invalidateQueries: invalidateQueriesMock, }), + hashKey: (key: unknown) => JSON.stringify(key), })); vi.mock('@/lib/trpc', () => ({ @@ -76,6 +82,8 @@ vi.mock('@/lib/trpc', () => ({ patchReviewConfig: { mutate: (vars: unknown) => personalPatchMutateMock(vars) }, // eslint-disable-next-line typescript-eslint/promise-function-async -- conflicting require-await rule saveReviewConfig: { mutate: (vars: unknown) => personalSaveMutateMock(vars) }, + // eslint-disable-next-line typescript-eslint/promise-function-async -- conflicting require-await rule + toggleReviewAgent: { mutate: (vars: unknown) => personalToggleMutateMock(vars) }, }, organizations: { reviewAgent: { @@ -83,6 +91,8 @@ vi.mock('@/lib/trpc', () => ({ patchReviewConfig: { mutate: (vars: unknown) => orgPatchMutateMock(vars) }, // eslint-disable-next-line typescript-eslint/promise-function-async -- conflicting require-await rule saveReviewConfig: { mutate: (vars: unknown) => orgSaveMutateMock(vars) }, + // eslint-disable-next-line typescript-eslint/promise-function-async -- conflicting require-await rule + toggleReviewAgent: { mutate: (vars: unknown) => orgToggleMutateMock(vars) }, }, }, }, @@ -114,12 +124,28 @@ function getSaveOptions( return lastCapturedOptions; } +function getToggleOptions( + scope: string, + platform: 'github' | 'gitlab' | 'bitbucket' +): MutationOptions { + lastCapturedOptions = null; + // eslint-disable-next-line react-hooks/rules-of-hooks + useToggleReviewer(scope, platform); + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition + if (!lastCapturedOptions) { + throw new Error('mutation options for useToggleReviewer were not captured'); + } + return lastCapturedOptions; +} + beforeEach(() => { lastCapturedOptions = null; personalPatchMutateMock.mockReset(); orgPatchMutateMock.mockReset(); personalSaveMutateMock.mockReset(); orgSaveMutateMock.mockReset(); + personalToggleMutateMock.mockReset(); + orgToggleMutateMock.mockReset(); invalidateQueriesMock.mockReset(); cancelQueriesMock.mockReset(); getQueryDataMock.mockReset(); @@ -129,6 +155,8 @@ beforeEach(() => { // override per-case when they need a different outcome. personalPatchMutateMock.mockResolvedValue({ success: true, webhookSync: null }); orgPatchMutateMock.mockResolvedValue({ success: true, webhookSync: null }); + personalToggleMutateMock.mockResolvedValue({ success: true }); + orgToggleMutateMock.mockResolvedValue({ success: true }); }); afterEach(() => { @@ -366,3 +394,108 @@ describe('useSaveReviewConfig onError', () => { expect(toastErrorMock).toHaveBeenCalledWith('Network unreachable'); }); }); + +describe('useSaveReviewConfig (generation guard)', () => { + beforeEach(() => { + lastCapturedOptions = null; + getQueryDataMock.mockReset(); + setQueryDataMock.mockReset(); + cancelQueriesMock.mockReset(); + toastErrorMock.mockReset(); + }); + + it('a failing older save does not roll back while a newer save owns the config', async () => { + getQueryDataMock.mockReturnValue({ reviewStyle: 'lenient' }); + const opts = getSaveOptions(PERSONAL_SCOPE, 'github'); + const older = await opts.onMutate?.({ reviewStyle: 'strict' }); + const newer = await opts.onMutate?.({ reviewStyle: 'lenient' }); + + setQueryDataMock.mockClear(); + opts.onError?.(new Error('boom'), { reviewStyle: 'strict' }, older); + expect(setQueryDataMock).not.toHaveBeenCalled(); + + opts.onError?.(new Error('boom'), { reviewStyle: 'lenient' }, newer); + expect(setQueryDataMock).toHaveBeenCalledTimes(1); + expect(toastErrorMock).toHaveBeenCalledTimes(2); + }); + + it('a failing latest save rolls back its snapshot and toasts', async () => { + getQueryDataMock.mockReturnValue({ reviewStyle: 'lenient' }); + const opts = getSaveOptions(PERSONAL_SCOPE, 'github'); + const context = await opts.onMutate?.({ reviewStyle: 'strict' }); + + setQueryDataMock.mockClear(); + opts.onError?.(new Error('boom'), { reviewStyle: 'strict' }, context); + expect(setQueryDataMock).toHaveBeenCalledTimes(1); + expect(toastErrorMock).toHaveBeenCalledWith('boom'); + }); +}); + +describe('useToggleReviewer (generation guard + chain join)', () => { + beforeEach(() => { + lastCapturedOptions = null; + getQueryDataMock.mockReset(); + setQueryDataMock.mockReset(); + cancelQueriesMock.mockReset(); + toastErrorMock.mockReset(); + personalToggleMutateMock.mockReset(); + personalToggleMutateMock.mockResolvedValue({ success: true }); + }); + + it('joins the save chain key (rule 1) and adds no scope.id', async () => { + const opts = getToggleOptions(PERSONAL_SCOPE, 'github'); + await opts.mutationFn?.({ isEnabled: true }); + expect(personalToggleMutateMock).toHaveBeenCalledWith( + expect.objectContaining({ platform: 'github', isEnabled: true }) + ); + expect(opts.scope).toBeUndefined(); + }); + + it('serializes the toggle behind an in-flight save on the same chain key', async () => { + const saveOpts = getSaveOptions(PERSONAL_SCOPE, 'github'); + const toggleOpts = getToggleOptions(PERSONAL_SCOPE, 'github'); + + const saveGate = Promise.withResolvers(); + personalPatchMutateMock.mockReset(); + personalPatchMutateMock.mockReturnValueOnce(saveGate.promise); + personalToggleMutateMock.mockResolvedValue({ success: true }); + + const savePromise = saveOpts.mutationFn?.({ reviewStyle: 'strict' }); + const togglePromise = toggleOpts.mutationFn?.({ isEnabled: true }); + + await Promise.resolve(); + await Promise.resolve(); + // The toggle's network call must not start while the save is in flight. + expect(personalToggleMutateMock).not.toHaveBeenCalled(); + + saveGate.resolve({ success: true, webhookSync: null }); + await Promise.all([savePromise, togglePromise]); + expect(personalToggleMutateMock).toHaveBeenCalledTimes(1); + }); + + it('a failing older toggle does not roll back while a newer toggle owns the config', async () => { + getQueryDataMock.mockReturnValue({ isEnabled: false }); + const opts = getToggleOptions(PERSONAL_SCOPE, 'github'); + const older = await opts.onMutate?.({ isEnabled: true }); + const newer = await opts.onMutate?.({ isEnabled: false }); + + setQueryDataMock.mockClear(); + opts.onError?.(new Error('boom'), { isEnabled: true }, older); + expect(setQueryDataMock).not.toHaveBeenCalled(); + + opts.onError?.(new Error('boom'), { isEnabled: false }, newer); + expect(setQueryDataMock).toHaveBeenCalledTimes(1); + expect(toastErrorMock).toHaveBeenCalledTimes(2); + }); + + it('a failing latest toggle rolls back its snapshot and toasts', async () => { + getQueryDataMock.mockReturnValue({ isEnabled: false }); + const opts = getToggleOptions(PERSONAL_SCOPE, 'github'); + const context = await opts.onMutate?.({ isEnabled: true }); + + setQueryDataMock.mockClear(); + opts.onError?.(new Error('boom'), { isEnabled: true }, context); + expect(setQueryDataMock).toHaveBeenCalledTimes(1); + expect(toastErrorMock).toHaveBeenCalledWith('boom'); + }); +}); diff --git a/apps/mobile/src/lib/hooks/use-code-reviewer.ts b/apps/mobile/src/lib/hooks/use-code-reviewer.ts index 141f7c561b..0036dde01e 100644 --- a/apps/mobile/src/lib/hooks/use-code-reviewer.ts +++ b/apps/mobile/src/lib/hooks/use-code-reviewer.ts @@ -1,4 +1,10 @@ -import { useMutation, useQuery, useQueryClient, type UseQueryResult } from '@tanstack/react-query'; +import { + hashKey, + useMutation, + useQuery, + useQueryClient, + type UseQueryResult, +} from '@tanstack/react-query'; import { announcingToast } from '@/lib/a11y/announcing-toast'; import { @@ -7,6 +13,10 @@ import { type ReviewConfigData, type ReviewerPlatform, } from '@/lib/code-reviewer-config'; +import { + isLatestMutationGeneration, + nextMutationGeneration, +} from '@/lib/hooks/mutation-generations'; import { chainSave } from '@/lib/hooks/save-chain'; import { trpcClient, useTRPC } from '@/lib/trpc'; import { pick } from '@/lib/utils'; @@ -141,40 +151,51 @@ export function useReviewConfigCacheReader(scope: string, platform: ReviewerPlat export function useToggleReviewer(scope: string, platform: ReviewerPlatform) { const queryClient = useQueryClient(); const queryKey = useReviewConfigQueryKey(scope, platform); + const toggleChainKey = `${scope}:${platform}`; + // onError policy: roll back the onMutate snapshot (latest generation only) + // and toast error.message. return useMutation({ - mutationFn: async (vars: { isEnabled: boolean }) => { - const result = isPersonal(scope) - ? await trpcClient.personalReviewAgent.toggleReviewAgent.mutate({ - platform: toPersonalPlatform(platform), - isEnabled: vars.isEnabled, - }) - : await trpcClient.organizations.reviewAgent.toggleReviewAgent.mutate({ - organizationId: scope, - platform, - isEnabled: vars.isEnabled, - }); - // The output type widens `success` to `boolean` (not a `true` - // literal), so a domain failure here must not be treated as a - // resolved mutation — throwing routes it to onError (toast) instead - // of letting callers' onSuccess fire haptics/navigation as if it worked. - if (!result.success) { - throw new Error('Failed to update reviewer'); - } - return result; - }, + // eslint-disable-next-line typescript-eslint/promise-function-async -- conflicting require-await rule + mutationFn: (vars: { isEnabled: boolean }) => + // The toggle joins the same chain key the config save uses, so a toggle + // and a save for the same scope+platform serialize their network calls. + chainSave(toggleChainKey, async () => { + const result = isPersonal(scope) + ? await trpcClient.personalReviewAgent.toggleReviewAgent.mutate({ + platform: toPersonalPlatform(platform), + isEnabled: vars.isEnabled, + }) + : await trpcClient.organizations.reviewAgent.toggleReviewAgent.mutate({ + organizationId: scope, + platform, + isEnabled: vars.isEnabled, + }); + // The output type widens `success` to `boolean` (not a `true` + // literal), so a domain failure here must not be treated as a + // resolved mutation — throwing routes it to onError (toast) instead + // of letting callers' onSuccess fire haptics/navigation as if it worked. + if (!result.success) { + throw new Error('Failed to update reviewer'); + } + return result; + }), onMutate: async vars => { await queryClient.cancelQueries({ queryKey }); + const generation = nextMutationGeneration(hashKey(queryKey)); const previous = queryClient.getQueryData(queryKey); queryClient.setQueryData(queryKey, old => old ? { ...old, isEnabled: vars.isEnabled } : old ); - return { previous }; + return { previous, generation }; }, onError: (error, _vars, context) => { - queryClient.setQueryData(queryKey, old => - old && context?.previous ? { ...old, isEnabled: context.previous.isEnabled } : old - ); + if (context?.previous && isLatestMutationGeneration(hashKey(queryKey), context.generation)) { + const previous = context.previous; + queryClient.setQueryData(queryKey, old => + old ? { ...old, isEnabled: previous.isEnabled } : old + ); + } announcingToast.error(error.message); }, // eslint-disable-next-line typescript-eslint/promise-function-async -- conflicting require-await rule @@ -215,6 +236,8 @@ export function useSaveReviewConfig(scope: string, platform: ReviewerPlatform) { const webhookWarningQueryKey = gitLabWebhookWarningQueryKey(scope, platform); const saveChainKey = `${scope}:${platform}`; + // onError policy: roll back the onMutate snapshot (latest generation only) + // and toast error.message. return useMutation({ // eslint-disable-next-line typescript-eslint/promise-function-async -- conflicting require-await rule mutationFn: (patch: ConfigPatch) => @@ -315,14 +338,15 @@ export function useSaveReviewConfig(scope: string, platform: ReviewerPlatform) { }), onMutate: async patch => { await queryClient.cancelQueries({ queryKey }); + const generation = nextMutationGeneration(hashKey(queryKey)); const previous = queryClient.getQueryData(queryKey); queryClient.setQueryData(queryKey, old => old ? { ...old, ...patch } : old ); - return { previous, patch }; + return { previous, patch, generation }; }, onError: (error, _patch, context) => { - if (context?.previous) { + if (context?.previous && isLatestMutationGeneration(hashKey(queryKey), context.generation)) { const keys = Object.keys(context.patch) as (keyof ConfigPatch)[]; const restoredFields = pick(context.previous, keys); queryClient.setQueryData(queryKey, old => diff --git a/apps/mobile/src/lib/hooks/use-model-preferences.test.ts b/apps/mobile/src/lib/hooks/use-model-preferences.test.ts new file mode 100644 index 0000000000..f941272bc1 --- /dev/null +++ b/apps/mobile/src/lib/hooks/use-model-preferences.test.ts @@ -0,0 +1,158 @@ +/* eslint-disable require-await, @typescript-eslint/require-await -- the fake chainSave factories settle without await because they resolve immediately */ + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import type * as React from 'react'; + +import { useModelPreferences } from './use-model-preferences'; + +type MutationOptions = { + mutationFn?: (vars: unknown) => Promise; + onMutate?: (vars: unknown) => Promise | unknown; + onError?: (error: unknown, vars: unknown, context: unknown) => void; + onSuccess?: (result: unknown, vars: unknown) => void; + onSettled?: (data?: unknown, error?: unknown, vars?: unknown) => Promise | void; + scope?: { id: string }; +}; + +// useModelPreferences registers its mutations in this order: +// setLastSelected, clearLastSelected, addFavorite, removeFavorite, setFavorites. +const capturedMutations: (MutationOptions | null)[] = []; +const invalidateQueriesMock = vi.fn(); +const getQueryDataMock = vi.fn(); +const setQueryDataMock = vi.fn(); +const cancelQueriesMock = vi.fn(); +const toastErrorMock = vi.fn(); +const setFavoritesErrorMock = vi.hoisted(() => vi.fn()); +const chainSaveMock = vi.hoisted(() => + vi.fn(async (_key: string, op: () => Promise) => op()) +); + +vi.mock('react', async () => { + const actual = await vi.importActual('react'); + return { + ...actual, + useState: vi.fn((initial: unknown) => [initial, setFavoritesErrorMock] as const), + useCallback: vi.fn( unknown>(fn: T) => fn), + useMemo: vi.fn((fn: () => T) => fn()), + }; +}); + +vi.mock('@tanstack/react-query', () => ({ + useMutation: (opts: MutationOptions) => { + capturedMutations.push(opts); + return { mutate: vi.fn(), mutateAsync: vi.fn(), isPending: false, isError: false }; + }, + useQuery: () => ({ data: { favorites: [], lastSelected: null }, isLoading: false }), + useQueryClient: () => ({ + invalidateQueries: (...args: unknown[]) => { + invalidateQueriesMock(...args); + }, + getQueryData: (...args: unknown[]) => getQueryDataMock(...args), + setQueryData: (...args: unknown[]) => setQueryDataMock(...args), + cancelQueries: (...args: unknown[]) => cancelQueriesMock(...args), + }), + hashKey: (key: unknown) => JSON.stringify(key), +})); + +vi.mock('sonner-native', () => ({ + toast: { error: (msg: string) => toastErrorMock(msg) }, +})); + +vi.mock('@/lib/hooks/save-chain', () => ({ + chainSave: async (key: string, op: () => Promise) => chainSaveMock(key, op), +})); + +vi.mock('@/lib/trpc', () => ({ + useTRPC: () => ({ + modelPreferences: { + get: { + queryOptions: () => ({ queryKey: ['modelPreferences', 'get'], queryFn: () => undefined }), + queryKey: (input: unknown) => ['modelPreferences', 'get', input], + }, + setLastSelected: { mutationOptions: (opts: MutationOptions) => opts }, + clearLastSelected: { mutationOptions: (opts: MutationOptions) => opts }, + setFavorites: { mutationOptions: (opts: MutationOptions) => opts }, + }, + }), + trpcClient: { + modelPreferences: { + addFavorite: { mutate: vi.fn() }, + removeFavorite: { mutate: vi.fn() }, + }, + }, +})); + +describe('useModelPreferences (generation guard)', () => { + beforeEach(() => { + capturedMutations.length = 0; + invalidateQueriesMock.mockReset(); + getQueryDataMock.mockReset(); + setQueryDataMock.mockReset(); + cancelQueriesMock.mockReset(); + toastErrorMock.mockReset(); + setFavoritesErrorMock.mockReset(); + chainSaveMock.mockClear(); + chainSaveMock.mockImplementation(async (_key, op) => op()); + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + it('serializes favorite mutations through chainSave (rule 1) and adds no scope', async () => { + useModelPreferences(undefined); + const addFavorite = capturedMutations[2]; + const removeFavorite = capturedMutations[3]; + + await addFavorite?.mutationFn?.({ model: 'm1' }); + expect(chainSaveMock).toHaveBeenCalledWith('model-preferences-favorites', expect.any(Function)); + expect(addFavorite?.scope).toBeUndefined(); + expect(removeFavorite?.scope).toBeUndefined(); + }); + + it('a failing older addFavorite does not roll back while a newer one owns the cache', async () => { + getQueryDataMock.mockReturnValue({ favorites: [], lastSelected: null }); + useModelPreferences(undefined); + const addFavorite = capturedMutations[2]; + const older = await addFavorite?.onMutate?.({ model: 'm1' }); + const newer = await addFavorite?.onMutate?.({ model: 'm2' }); + + setQueryDataMock.mockClear(); + addFavorite?.onError?.(new Error('boom'), { model: 'm1' }, older); + expect(setQueryDataMock).not.toHaveBeenCalled(); + + addFavorite?.onError?.(new Error('boom'), { model: 'm2' }, newer); + expect(setQueryDataMock).toHaveBeenCalledTimes(1); + // Favorites surface the error inline (no toast). + expect(toastErrorMock).not.toHaveBeenCalled(); + expect(setFavoritesErrorMock).toHaveBeenCalledWith('boom'); + }); + + it('a failing latest removeFavorite rolls back its snapshot', async () => { + getQueryDataMock.mockReturnValue({ favorites: ['m1'], lastSelected: null }); + useModelPreferences(undefined); + const removeFavorite = capturedMutations[3]; + const context = await removeFavorite?.onMutate?.({ model: 'm1' }); + + setQueryDataMock.mockClear(); + removeFavorite?.onError?.(new Error('boom'), { model: 'm1' }, context); + expect(setQueryDataMock).toHaveBeenCalledTimes(1); + expect(toastErrorMock).not.toHaveBeenCalled(); + expect(setFavoritesErrorMock).toHaveBeenCalledWith('boom'); + }); + + it('surfaces the inline error even when the stale generation skips the rollback', async () => { + getQueryDataMock.mockReturnValue({ favorites: [], lastSelected: null }); + useModelPreferences(undefined); + const addFavorite = capturedMutations[2]; + const older = await addFavorite?.onMutate?.({ model: 'm1' }); + await addFavorite?.onMutate?.({ model: 'm2' }); + + setQueryDataMock.mockClear(); + setFavoritesErrorMock.mockClear(); + addFavorite?.onError?.(new Error('boom'), { model: 'm1' }, older); + expect(setQueryDataMock).not.toHaveBeenCalled(); + expect(setFavoritesErrorMock).toHaveBeenCalledWith('boom'); + }); +}); diff --git a/apps/mobile/src/lib/hooks/use-model-preferences.ts b/apps/mobile/src/lib/hooks/use-model-preferences.ts index fea113467f..ed7c55b07a 100644 --- a/apps/mobile/src/lib/hooks/use-model-preferences.ts +++ b/apps/mobile/src/lib/hooks/use-model-preferences.ts @@ -1,8 +1,12 @@ -import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import { hashKey, useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { type inferRouterOutputs, type MobileRouter } from '@kilocode/trpc/mobile'; import { useCallback, useMemo, useState } from 'react'; import { toast } from 'sonner-native'; +import { + isLatestMutationGeneration, + nextMutationGeneration, +} from '@/lib/hooks/mutation-generations'; import { chainSave } from '@/lib/hooks/save-chain'; import { trpcClient, useTRPC } from '@/lib/trpc'; @@ -38,6 +42,7 @@ export function useModelPreferences(organizationId: string | undefined) { async (update: (favorites: string[]) => string[]) => { const queryKey = trpc.modelPreferences.get.queryKey(input); await queryClient.cancelQueries({ queryKey }); + const generation = nextMutationGeneration(hashKey(queryKey)); const previous = queryClient.getQueryData(queryKey); if (previous) { queryClient.setQueryData(queryKey, { @@ -45,15 +50,19 @@ export function useModelPreferences(organizationId: string | undefined) { favorites: update(previous.favorites), }); } - return { previous }; + return { previous, generation }; }, [queryClient, trpc.modelPreferences.get, input] ); const rollbackFavorites = useCallback( - (error: { message: string }, context: { previous?: ModelPreferences } | undefined) => { - if (context?.previous) { - queryClient.setQueryData(trpc.modelPreferences.get.queryKey(input), context.previous); + ( + error: { message: string }, + context: { previous?: ModelPreferences; generation: number } | undefined + ) => { + const queryKey = trpc.modelPreferences.get.queryKey(input); + if (context?.previous && isLatestMutationGeneration(hashKey(queryKey), context.generation)) { + queryClient.setQueryData(queryKey, context.previous); } setFavoritesError(error.message || 'Could not update favorites'); }, @@ -79,6 +88,8 @@ export function useModelPreferences(organizationId: string | undefined) { // out of order and the earlier response can stomp the later one's result. // Chaining onto the prior in-flight request keeps them in order — simple // FIFO, no dedupe/coalescing (see save-chain.ts). + // onError policy: roll back the onMutate snapshot (latest generation only); + // the caller renders the error inline (no toast). const addFavorite = useMutation({ mutationFn: (vars: { model: string }) => // eslint-disable-next-line typescript-eslint/promise-function-async -- conflicting require-await rule @@ -97,6 +108,8 @@ export function useModelPreferences(organizationId: string | undefined) { }); const removeFavorite = useMutation({ + // onError policy: roll back the onMutate snapshot (latest generation only); + // the caller renders the error inline (no toast). mutationFn: (vars: { model: string }) => // eslint-disable-next-line typescript-eslint/promise-function-async -- conflicting require-await rule chainSave(FAVORITES_CHAIN_KEY, () => trpcClient.modelPreferences.removeFavorite.mutate(vars)), diff --git a/apps/mobile/src/lib/hooks/use-offline-banner-state.mounted.test.tsx b/apps/mobile/src/lib/hooks/use-offline-banner-state.mounted.test.tsx index b23061f194..1440028eba 100644 --- a/apps/mobile/src/lib/hooks/use-offline-banner-state.mounted.test.tsx +++ b/apps/mobile/src/lib/hooks/use-offline-banner-state.mounted.test.tsx @@ -3,8 +3,7 @@ import { createElement } from 'react'; import TestRenderer, { act } from 'react-test-renderer'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { useOfflineBannerState } from '@/lib/hooks/use-offline-banner-state'; -import { OFFLINE_BANNER_SHOW_DELAY_MS } from '@/lib/offline-banner-state'; +import { type BannerState, OFFLINE_BANNER_SHOW_DELAY_MS } from '@/lib/offline-banner-state'; type ConnectivityState = { isConnected: boolean | null; isInternetReachable: boolean | null }; @@ -30,9 +29,20 @@ vi.mock('@react-native-community/netinfo', () => ({ addEventListener: netinfo.addEventListener, })); -function Probe() { - const isOffline = useOfflineBannerState(); - return createElement('ProbeText', null, String(isOffline)); +type Hooks = { + useOfflineBannerState: () => boolean; + useCommittedConnectivityStatus: () => BannerState; +}; + +async function loadHooks(): Promise { + const hooks = await import('@/lib/hooks/use-offline-banner-state'); + return hooks; +} + +function Probe({ hooks }: { hooks: Hooks }) { + const isOffline = hooks.useOfflineBannerState(); + const status = hooks.useCommittedConnectivityStatus(); + return createElement('ProbeText', null, `${String(isOffline)}:${status}`); } function textChildren(renderer: TestRenderer.ReactTestRenderer): string[] | null { @@ -46,13 +56,13 @@ function textChildren(renderer: TestRenderer.ReactTestRenderer): string[] | null return json.children?.filter((child): child is string => typeof child === 'string') ?? null; } -async function renderProbe(): Promise { +async function renderProbe(hooks: Hooks): Promise { const rendererRef: { current: TestRenderer.ReactTestRenderer | undefined } = { current: undefined, }; await act(async () => { await Promise.resolve(); - rendererRef.current = TestRenderer.create(createElement(Probe)); + rendererRef.current = TestRenderer.create(createElement(Probe, { hooks })); }); const renderer = rendererRef.current; if (!renderer) { @@ -61,57 +71,91 @@ async function renderProbe(): Promise { return renderer; } -describe('useOfflineBannerState mounted', () => { +describe('useOfflineBannerState and useCommittedConnectivityStatus mounted', () => { beforeEach(() => { // React 19 requires the act environment flag before `act` supports // updates scheduled from effects and external stores. (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + // A fresh module per test gives a fresh module-level store singleton. + vi.resetModules(); + netinfo.listeners.clear(); }); afterEach(() => { netinfo.listeners.clear(); vi.useRealTimers(); }); - it('subscribes once, shows after the delay, hides at once, and cleans up on unmount', async () => { + it('subscribes once, shows after the delay, hides at once, and reports the tri-state', async () => { vi.useFakeTimers(); - const renderer = await renderProbe(); + const hooks = await loadHooks(); + const renderer = await renderProbe(hooks); expect(netinfo.listeners.size).toBe(1); - expect(textChildren(renderer)).toEqual(['false']); + expect(textChildren(renderer)).toEqual(['false:unknown']); act(() => { netinfo.emit({ isConnected: false, isInternetReachable: false }); }); - expect(textChildren(renderer)).toEqual(['false']); + expect(textChildren(renderer)).toEqual(['false:unknown']); act(() => { vi.advanceTimersByTime(OFFLINE_BANNER_SHOW_DELAY_MS); }); - expect(textChildren(renderer)).toEqual(['true']); + expect(textChildren(renderer)).toEqual(['true:offline']); act(() => { netinfo.emit({ isConnected: true, isInternetReachable: true }); }); - expect(textChildren(renderer)).toEqual(['false']); - - const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined); - try { - act(() => { - renderer.unmount(); - }); - - expect(netinfo.listeners.size).toBe(0); - - act(() => { - netinfo.emit({ isConnected: false, isInternetReachable: false }); - vi.advanceTimersByTime(OFFLINE_BANNER_SHOW_DELAY_MS); - }); - - expect(renderer.toJSON()).toBeNull(); - expect(consoleErrorSpy).not.toHaveBeenCalled(); - } finally { - consoleErrorSpy.mockRestore(); - } + expect(textChildren(renderer)).toEqual(['false:online']); + }); + + it('reports the unknown → online edge even though the banner stays hidden', async () => { + vi.useFakeTimers(); + + const hooks = await loadHooks(); + const renderer = await renderProbe(hooks); + + expect(textChildren(renderer)).toEqual(['false:unknown']); + + act(() => { + netinfo.emit({ isConnected: true, isInternetReachable: true }); + }); + expect(textChildren(renderer)).toEqual(['false:online']); + }); + + it('shares one store and one NetInfo subscription across callers', async () => { + vi.useFakeTimers(); + + const hooks = await loadHooks(); + const first = await renderProbe(hooks); + const second = await renderProbe(hooks); + + expect(netinfo.listeners.size).toBe(1); + + act(() => { + netinfo.emit({ isConnected: false, isInternetReachable: false }); + }); + act(() => { + vi.advanceTimersByTime(OFFLINE_BANNER_SHOW_DELAY_MS); + }); + + expect(textChildren(first)).toEqual(['true:offline']); + expect(textChildren(second)).toEqual(['true:offline']); + }); + + it('keeps the shared store alive after unmount (never destroyed)', async () => { + vi.useFakeTimers(); + + const hooks = await loadHooks(); + const renderer = await renderProbe(hooks); + + expect(netinfo.listeners.size).toBe(1); + + act(() => { + renderer.unmount(); + }); + + expect(netinfo.listeners.size).toBe(1); }); }); diff --git a/apps/mobile/src/lib/hooks/use-offline-banner-state.ts b/apps/mobile/src/lib/hooks/use-offline-banner-state.ts index 4ba615ac16..d83a9626ee 100644 --- a/apps/mobile/src/lib/hooks/use-offline-banner-state.ts +++ b/apps/mobile/src/lib/hooks/use-offline-banner-state.ts @@ -1,17 +1,14 @@ import { addEventListener } from '@react-native-community/netinfo'; -import { useCallback, useEffect, useState, useSyncExternalStore } from 'react'; +import { useSyncExternalStore } from 'react'; import { + type BannerState, type ConnectivitySource, createOfflineBannerStore, type OfflineBannerStore, type OfflineBannerTimer, } from '@/lib/offline-banner-state'; -const NOOP_SUBSCRIBE = (): (() => void) => () => { - // No store on the first render; the effect creates one and destroys it on unmount. -}; - const netInfoSource: ConnectivitySource = { subscribe: listener => addEventListener(listener), }; @@ -27,15 +24,20 @@ const defaultTimer: OfflineBannerTimer = { }, }; +// One store per app, created lazily on first use and never destroyed, so +// every caller of the two hooks below shares a single NetInfo subscription +// (the app must not grow a second connectivity system). +let store: OfflineBannerStore | null = null; + +function getStore(): OfflineBannerStore { + store ??= createOfflineBannerStore({ source: netInfoSource, timer: defaultTimer }); + return store; +} + export function useOfflineBannerState(): boolean { - const [store, setStore] = useState(null); - useEffect(() => { - const created = createOfflineBannerStore({ source: netInfoSource, timer: defaultTimer }); - setStore(created); - return () => { - created.destroy(); - }; - }, []); - const getSnapshot = useCallback(() => store?.isOffline() ?? false, [store]); - return useSyncExternalStore(store?.subscribe ?? NOOP_SUBSCRIBE, getSnapshot); + return useSyncExternalStore(getStore().subscribe, getStore().isOffline); +} + +export function useCommittedConnectivityStatus(): BannerState { + return useSyncExternalStore(getStore().subscribe, getStore().state); } diff --git a/apps/mobile/src/lib/hooks/use-organization-mutations.test.ts b/apps/mobile/src/lib/hooks/use-organization-mutations.test.ts new file mode 100644 index 0000000000..a1e4674325 --- /dev/null +++ b/apps/mobile/src/lib/hooks/use-organization-mutations.test.ts @@ -0,0 +1,218 @@ +/* eslint-disable require-await, @typescript-eslint/require-await -- the second mutationFn resolves immediately without awaiting */ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import type * as ReactQuery from '@tanstack/react-query'; + +import { useOrganizationMutations } from './use-organization-mutations'; + +type MutationOptions = { + mutationFn?: (vars: unknown) => Promise; + onMutate?: (vars: unknown) => Promise | unknown; + onError?: (error: unknown, vars: unknown, context: unknown) => void; + onSuccess?: (result: unknown, vars: unknown) => void; + onSettled?: (data?: unknown, error?: unknown, vars?: unknown) => Promise | void; + scope?: { id: string }; +}; + +// useOrganizationMutations registers its mutations in this order: +// rename, invite, updateMember, removeMember, deleteInvite, updateMinimumBalanceAlert. +const capturedMutations: (MutationOptions | null)[] = []; +const invalidateQueriesMock = vi.fn(); +const getQueryDataMock = vi.fn(); +const setQueryDataMock = vi.fn(); +const cancelQueriesMock = vi.fn(); +const toastErrorMock = vi.fn(); + +vi.mock('@tanstack/react-query', () => ({ + useMutation: (opts: MutationOptions) => { + capturedMutations.push(opts); + return { mutate: vi.fn(), mutateAsync: vi.fn(), isPending: false, isError: false }; + }, + useQueryClient: () => ({ + invalidateQueries: (...args: unknown[]) => { + invalidateQueriesMock(...args); + }, + getQueryData: (...args: unknown[]) => getQueryDataMock(...args), + setQueryData: (...args: unknown[]) => setQueryDataMock(...args), + cancelQueries: (...args: unknown[]) => cancelQueriesMock(...args), + }), + hashKey: (key: unknown) => JSON.stringify(key), +})); + +vi.mock('@/lib/a11y/announcing-toast', () => ({ + announcingToast: { + error: (msg: string) => toastErrorMock(msg), + success: vi.fn(), + warning: vi.fn(), + }, +})); + +vi.mock('@/lib/trpc', () => ({ + useTRPC: () => ({ + organizations: { + withMembers: { + queryKey: ({ organizationId }: { organizationId: string }) => [ + 'organizations', + 'withMembers', + { organizationId }, + ], + }, + list: { queryKey: () => ['organizations', 'list'] }, + }, + }), + trpcClient: { + organizations: { + update: { mutate: vi.fn() }, + members: { + invite: { mutate: vi.fn() }, + update: { mutate: vi.fn() }, + remove: { mutate: vi.fn() }, + deleteInvite: { mutate: vi.fn() }, + }, + settings: { + updateMinimumBalanceAlert: { mutate: vi.fn() }, + }, + }, + }, +})); + +const ORG_ID = 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa'; + +describe('useOrganizationMutations (generation guard + scope)', () => { + beforeEach(() => { + capturedMutations.length = 0; + invalidateQueriesMock.mockReset(); + getQueryDataMock.mockReset(); + setQueryDataMock.mockReset(); + cancelQueriesMock.mockReset(); + toastErrorMock.mockReset(); + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + it('scopes all five optimistic mutations and leaves invite unscoped', () => { + useOrganizationMutations(ORG_ID); + const expectedScope = { id: `organization:${ORG_ID}` }; + // rename + expect(capturedMutations[0]?.scope).toEqual(expectedScope); + // invite + expect(capturedMutations[1]?.scope).toBeUndefined(); + // updateMember + expect(capturedMutations[2]?.scope).toEqual(expectedScope); + // removeMember + expect(capturedMutations[3]?.scope).toEqual(expectedScope); + // deleteInvite + expect(capturedMutations[4]?.scope).toEqual(expectedScope); + // updateMinimumBalanceAlert + expect(capturedMutations[5]?.scope).toEqual(expectedScope); + }); + + it('a failing older updateMember does not roll back while a newer one owns the cache', async () => { + getQueryDataMock.mockReturnValue({ members: [] }); + useOrganizationMutations(ORG_ID); + const updateMember = capturedMutations[2]; + const older = await updateMember?.onMutate?.({ memberId: 'm1', role: 'admin' }); + const newer = await updateMember?.onMutate?.({ memberId: 'm2', role: 'member' }); + + setQueryDataMock.mockClear(); + updateMember?.onError?.(new Error('boom'), { memberId: 'm1', role: 'admin' }, older); + expect(setQueryDataMock).not.toHaveBeenCalled(); + + updateMember?.onError?.(new Error('boom'), { memberId: 'm2', role: 'member' }, newer); + expect(setQueryDataMock).toHaveBeenCalledTimes(1); + // updateMember toasts by default (not silent). + expect(toastErrorMock).toHaveBeenCalledTimes(2); + }); + + it('a failing latest updateMember rolls back its snapshot', async () => { + getQueryDataMock.mockReturnValue({ members: [] }); + useOrganizationMutations(ORG_ID); + const updateMember = capturedMutations[2]; + const context = await updateMember?.onMutate?.({ memberId: 'm1', role: 'admin' }); + + setQueryDataMock.mockClear(); + updateMember?.onError?.(new Error('boom'), { memberId: 'm1', role: 'admin' }, context); + expect(setQueryDataMock).toHaveBeenCalledTimes(1); + expect(toastErrorMock).toHaveBeenCalledWith('boom'); + }); + + it('updateMember stays inline (no toast) when silenceUpdateMemberToast is set', async () => { + getQueryDataMock.mockReturnValue({ members: [] }); + useOrganizationMutations(ORG_ID, { silenceUpdateMemberToast: true }); + const updateMember = capturedMutations[2]; + const context = await updateMember?.onMutate?.({ memberId: 'm1', role: 'admin' }); + + updateMember?.onError?.(new Error('boom'), { memberId: 'm1', role: 'admin' }, context); + expect(toastErrorMock).not.toHaveBeenCalled(); + }); + + it('rename: a failing older rename does not roll back while a newer rename owns the cache', async () => { + getQueryDataMock.mockReturnValue({ name: 'Old', members: [] }); + useOrganizationMutations(ORG_ID); + const rename = capturedMutations[0]; + const older = await rename?.onMutate?.({ name: 'A' }); + const newer = await rename?.onMutate?.({ name: 'B' }); + + setQueryDataMock.mockClear(); + rename?.onError?.(new Error('boom'), { name: 'A' }, older); + expect(setQueryDataMock).not.toHaveBeenCalled(); + + rename?.onError?.(new Error('boom'), { name: 'B' }, newer); + // rename rolls back both the withMembers and the list caches. + expect(setQueryDataMock).toHaveBeenCalledTimes(2); + // rename renders inline (no toast). + expect(toastErrorMock).not.toHaveBeenCalled(); + }); + + it('updateMinimumBalanceAlert renders inline (no toast) and rolls back the latest snapshot', async () => { + getQueryDataMock.mockReturnValue({ settings: {} }); + useOrganizationMutations(ORG_ID); + const updateMinimumBalanceAlert = capturedMutations[5]; + const context = await updateMinimumBalanceAlert?.onMutate?.({ enabled: true }); + + setQueryDataMock.mockClear(); + updateMinimumBalanceAlert?.onError?.(new Error('boom'), { enabled: true }, context); + expect(setQueryDataMock).toHaveBeenCalledTimes(1); + expect(toastErrorMock).not.toHaveBeenCalled(); + }); +}); + +describe('scope.id network serialization (real MutationCache)', () => { + it('starts the second same-scope org mutationFn only after the first settles', async () => { + const { MutationCache, QueryClient } = + await vi.importActual('@tanstack/react-query'); + const cache = new MutationCache(); + const client = new QueryClient({ mutationCache: cache }); + const order: string[] = []; + const gate = Promise.withResolvers(); + + const first = cache.build(client, { + mutationFn: async () => { + order.push('first-start'); + await gate.promise; + order.push('first-end'); + return 'first'; + }, + scope: { id: `organization:${ORG_ID}` }, + }); + const second = cache.build(client, { + mutationFn: async () => { + order.push('second-start'); + return 'second'; + }, + scope: { id: `organization:${ORG_ID}` }, + }); + + const p1 = first.execute({}); + const p2 = second.execute({}); + await Promise.resolve(); + await Promise.resolve(); + expect(order).toEqual(['first-start']); + + gate.resolve(null); + await Promise.all([p1, p2]); + expect(order).toEqual(['first-start', 'first-end', 'second-start']); + }); +}); diff --git a/apps/mobile/src/lib/hooks/use-organization-mutations.ts b/apps/mobile/src/lib/hooks/use-organization-mutations.ts index 2b6b9afa16..3e12692e9f 100644 --- a/apps/mobile/src/lib/hooks/use-organization-mutations.ts +++ b/apps/mobile/src/lib/hooks/use-organization-mutations.ts @@ -1,6 +1,10 @@ -import { useMutation, useQueryClient } from '@tanstack/react-query'; +import { hashKey, useMutation, useQueryClient } from '@tanstack/react-query'; import { announcingToast } from '@/lib/a11y/announcing-toast'; +import { + isLatestMutationGeneration, + nextMutationGeneration, +} from '@/lib/hooks/mutation-generations'; import { type OrgListEntry, type OrgRole, @@ -47,6 +51,8 @@ export function useOrganizationMutations( // Every optimistic mutation here only touches the withMembers cache, so the // key is fixed rather than threaded through like use-kiloclaw-mutations.ts // (which juggles many caches across a personal/org split). + // onError policy: roll back the onMutate snapshot (latest generation only); + // toast error.message, or the caller renders the error inline when silent. function optimistic( updater: (old: OrgWithMembers, input: TInput) => OrgWithMembers, { silent }: { silent?: boolean } = {} @@ -54,18 +60,22 @@ export function useOrganizationMutations( return { onMutate: async (input: TInput) => { await queryClient.cancelQueries({ queryKey: withMembersKey }); + const generation = nextMutationGeneration(hashKey(withMembersKey)); const previous = queryClient.getQueryData(withMembersKey); queryClient.setQueryData(withMembersKey, old => old ? updater(old, input) : old ); - return { previous }; + return { previous, generation }; }, onError: ( error: { message: string }, _input: TInput, - context?: { previous?: OrgWithMembers } + context?: { previous?: OrgWithMembers; generation: number } ) => { - if (context?.previous) { + if ( + context?.previous && + isLatestMutationGeneration(hashKey(withMembersKey), context.generation) + ) { queryClient.setQueryData(withMembersKey, context.previous); } if (!silent) { @@ -77,6 +87,8 @@ export function useOrganizationMutations( } return { + // onError policy: roll back the onMutate snapshot (latest generation only); + // the caller renders the error inline (no toast). rename: useMutation({ // eslint-disable-next-line typescript-eslint/promise-function-async -- conflicting require-await rule mutationFn: (input: { name: string }) => @@ -86,6 +98,7 @@ export function useOrganizationMutations( queryClient.cancelQueries({ queryKey: withMembersKey }), queryClient.cancelQueries({ queryKey: listKey }), ]); + const generation = nextMutationGeneration(hashKey(withMembersKey)); const previousWithMembers = queryClient.getQueryData(withMembersKey); const previousList = queryClient.getQueryData(listKey); queryClient.setQueryData(withMembersKey, old => @@ -100,23 +113,36 @@ export function useOrganizationMutations( ) : old ); - return { previousWithMembers, previousList }; + return { previousWithMembers, previousList, generation }; }, // No onMutationError toast here: RenameModal (the only caller) shows // the error inline while it stays open (see Pattern P2). onError: ( _error: { message: string }, _input, - context?: { previousWithMembers?: OrgWithMembers; previousList?: OrgListEntry[] } + context?: { + previousWithMembers?: OrgWithMembers; + previousList?: OrgListEntry[]; + generation: number; + } ) => { - if (context?.previousWithMembers) { + if ( + context?.previousWithMembers && + isLatestMutationGeneration(hashKey(withMembersKey), context.generation) + ) { queryClient.setQueryData(withMembersKey, context.previousWithMembers); } - if (context?.previousList) { + if ( + context?.previousList && + isLatestMutationGeneration(hashKey(withMembersKey), context.generation) + ) { queryClient.setQueryData(listKey, context.previousList); } }, onSettled: invalidateAll, + // Serialize rename against the other optimistic org writes so the + // network calls land in order; the generation guard orders rollbacks. + scope: { id: `organization:${organizationId}` }, }), // No onMutationError toast here: invite-member-sheet (the only caller) @@ -153,6 +179,7 @@ export function useOrganizationMutations( }), { silent: silenceUpdateMemberToast } ), + scope: { id: `organization:${organizationId}` }, }), removeMember: useMutation({ @@ -165,6 +192,7 @@ export function useOrganizationMutations( member => !(member.status === 'active' && member.id === input.memberId) ), })), + scope: { id: `organization:${organizationId}` }, }), deleteInvite: useMutation({ @@ -177,10 +205,13 @@ export function useOrganizationMutations( member => !(member.status === 'invited' && member.inviteId === input.inviteId) ), })), + scope: { id: `organization:${organizationId}` }, }), // No onMutationError toast here: low-balance-alert-sheet (the only // caller) shows the error inline while it stays open (see Pattern P2). + // onError policy: roll back the onMutate snapshot (latest generation only); + // the caller renders the error inline (no toast). updateMinimumBalanceAlert: useMutation({ // eslint-disable-next-line typescript-eslint/promise-function-async -- conflicting require-await rule mutationFn: (input: { @@ -217,6 +248,7 @@ export function useOrganizationMutations( }, { silent: true } ), + scope: { id: `organization:${organizationId}` }, }), }; } diff --git a/apps/mobile/src/lib/hooks/use-route-foreground-refresh.mounted.test.tsx b/apps/mobile/src/lib/hooks/use-route-foreground-refresh.mounted.test.tsx new file mode 100644 index 0000000000..60e03520bf --- /dev/null +++ b/apps/mobile/src/lib/hooks/use-route-foreground-refresh.mounted.test.tsx @@ -0,0 +1,236 @@ +/* eslint-disable typescript-eslint/no-deprecated -- react-test-renderer is the DOM-free renderer used to mount React/RN trees under vitest (same pattern as src/lib/hooks/use-force-update.mounted.test.tsx) */ +import { createElement } from 'react'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import type * as TanStackReactQuery from '@tanstack/react-query'; +import TestRenderer, { act } from 'react-test-renderer'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { useRouteForegroundRefresh } from '@/lib/hooks/use-route-foreground-refresh'; + +const invalidateQueries = vi.hoisted(() => vi.fn()); + +// When true, the mocked useQueryClient delegates to the real hook, so a probe +// mounted under a real QueryClientProvider reads the provider's client. The +// existing mocked blocks keep this false and get the pass-through spy. +const useRealQueryClient = vi.hoisted(() => ({ value: false })); + +const appState = vi.hoisted(() => { + const listeners = new Set<(state: string) => void>(); + return { + listeners, + addEventListener: (_event: string, listener: (state: string) => void) => { + listeners.add(listener); + return { + remove: () => { + listeners.delete(listener); + }, + }; + }, + emit: (state: string): void => { + for (const listener of listeners) { + listener(state); + } + }, + }; +}); + +const focusState = vi.hoisted(() => ({ + isFocused: true, +})); + +// Captures the useFocusEffect callback so a test can simulate a focus event. +const focusEffect = vi.hoisted(() => ({ + effect: undefined as (() => void) | undefined, +})); + +vi.mock('react-native', () => ({ + AppState: { addEventListener: appState.addEventListener }, +})); + +vi.mock('@tanstack/react-query', async importOriginal => { + const actual = await importOriginal(); + return { + ...actual, + useQueryClient: () => + useRealQueryClient.value ? actual.useQueryClient() : { invalidateQueries }, + }; +}); + +vi.mock('expo-router', () => ({ + useIsFocused: () => focusState.isFocused, + useFocusEffect: (effect: () => void) => { + focusEffect.effect = effect; + }, +})); + +const KEYS: readonly (readonly unknown[])[] = [['securityAgent'], ['organizations']]; + +function Probe({ queryKeys }: { queryKeys: readonly (readonly unknown[])[] }) { + useRouteForegroundRefresh(queryKeys); + return createElement('ProbeText', null, 'probe'); +} + +const mountedRenderers: TestRenderer.ReactTestRenderer[] = []; + +async function renderProbe( + queryKeys: readonly (readonly unknown[])[] +): Promise { + const rendererRef: { current: TestRenderer.ReactTestRenderer | undefined } = { + current: undefined, + }; + await act(async () => { + await Promise.resolve(); + rendererRef.current = TestRenderer.create(createElement(Probe, { queryKeys })); + }); + const renderer = rendererRef.current; + if (!renderer) { + throw new Error('renderer was not created'); + } + mountedRenderers.push(renderer); + return renderer; +} + +async function renderProbeWithProvider( + queryClient: QueryClient, + queryKeys: readonly (readonly unknown[])[] +): Promise { + const rendererRef: { current: TestRenderer.ReactTestRenderer | undefined } = { + current: undefined, + }; + await act(async () => { + await Promise.resolve(); + rendererRef.current = TestRenderer.create( + createElement( + QueryClientProvider, + { client: queryClient }, + createElement(Probe, { queryKeys }) + ) + ); + }); + const renderer = rendererRef.current; + if (!renderer) { + throw new Error('renderer was not created'); + } + mountedRenderers.push(renderer); + return renderer; +} + +function backgroundThenActive(): void { + act(() => { + appState.emit('background'); + }); + act(() => { + appState.emit('active'); + }); +} + +describe('useRouteForegroundRefresh mounted', () => { + beforeEach(() => { + (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + focusState.isFocused = true; + focusEffect.effect = undefined; + invalidateQueries.mockReset(); + }); + + afterEach(() => { + act(() => { + for (const renderer of mountedRenderers) { + renderer.unmount(); + } + }); + mountedRenderers.length = 0; + appState.listeners.clear(); + focusEffect.effect = undefined; + }); + + it('invalidates exactly the listed keys on the focused foreground transition', async () => { + await renderProbe(KEYS); + + backgroundThenActive(); + + expect(invalidateQueries).toHaveBeenCalledTimes(2); + expect(invalidateQueries).toHaveBeenNthCalledWith(1, { queryKey: ['securityAgent'] }); + expect(invalidateQueries).toHaveBeenNthCalledWith(2, { queryKey: ['organizations'] }); + }); + + it('invalidates nothing on the foreground transition while unfocused', async () => { + focusState.isFocused = false; + await renderProbe(KEYS); + + backgroundThenActive(); + + expect(invalidateQueries).not.toHaveBeenCalled(); + }); + + it('invalidates nothing on the first focus (mount)', async () => { + await renderProbe(KEYS); + + act(() => { + focusEffect.effect?.(); + }); + + expect(invalidateQueries).not.toHaveBeenCalled(); + }); + + it('invalidates the keys on a later focus regain', async () => { + await renderProbe(KEYS); + + act(() => { + focusEffect.effect?.(); + }); + expect(invalidateQueries).not.toHaveBeenCalled(); + + act(() => { + focusEffect.effect?.(); + }); + + expect(invalidateQueries).toHaveBeenCalledTimes(2); + expect(invalidateQueries).toHaveBeenNthCalledWith(1, { queryKey: ['securityAgent'] }); + expect(invalidateQueries).toHaveBeenNthCalledWith(2, { queryKey: ['organizations'] }); + }); +}); + +describe('useRouteForegroundRefresh key matching with a real QueryClient', () => { + beforeEach(() => { + (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + focusState.isFocused = true; + focusEffect.effect = undefined; + useRealQueryClient.value = true; + }); + + afterEach(() => { + useRealQueryClient.value = false; + act(() => { + for (const renderer of mountedRenderers) { + renderer.unmount(); + } + }); + mountedRenderers.length = 0; + appState.listeners.clear(); + focusEffect.effect = undefined; + }); + + it('invalidates a tRPC-shaped query key with the nested prefix form', async () => { + const queryClient = new QueryClient(); + const queryFn = vi.fn().mockResolvedValue('sentinel'); + await queryClient.prefetchQuery({ queryKey: [['user', 'getMe']], queryFn }); + + await renderProbeWithProvider(queryClient, [[['user']]]); + + backgroundThenActive(); + + expect(queryClient.getQueryState([['user', 'getMe']])?.isInvalidated).toBe(true); + }); + + it('does not invalidate a tRPC-shaped query key with the flat prefix form', async () => { + const queryClient = new QueryClient(); + const queryFn = vi.fn().mockResolvedValue('sentinel'); + await queryClient.prefetchQuery({ queryKey: [['user', 'getMe']], queryFn }); + + await renderProbeWithProvider(queryClient, [['user']]); + + backgroundThenActive(); + + expect(queryClient.getQueryState([['user', 'getMe']])?.isInvalidated).toBe(false); + }); +}); diff --git a/apps/mobile/src/lib/hooks/use-route-foreground-refresh.ts b/apps/mobile/src/lib/hooks/use-route-foreground-refresh.ts new file mode 100644 index 0000000000..dc6e693c3a --- /dev/null +++ b/apps/mobile/src/lib/hooks/use-route-foreground-refresh.ts @@ -0,0 +1,59 @@ +import { useQueryClient } from '@tanstack/react-query'; +import { useFocusEffect, useIsFocused } from 'expo-router'; +import { useCallback, useEffect, useRef } from 'react'; + +import { useAppLifecycle } from '@/lib/hooks/use-app-lifecycle'; + +/** + * Invalidates the given tRPC path-prefix query keys when the route regains + * foreground freshness: + * + * - on the AppState false -> true transition to active while the route is + * focused, and + * - on every route focus regain after the first (the mount focus is already + * covered by refetchOnMount). + * + * Keys are tRPC path-prefix arrays (e.g. `[['securityAgent']]`), which match + * every procedure and scope variant under the prefix; only mounted observers + * refetch. + */ +export function useRouteForegroundRefresh(queryKeys: readonly (readonly unknown[])[]): void { + const queryClient = useQueryClient(); + const { isActive } = useAppLifecycle(); + const isFocused = useIsFocused(); + + // Keep the latest keys in a ref so the focus effect callback stays stable: + // the owner passes an inline array literal, which is a fresh reference every + // render, and a changing useFocusEffect callback would re-run on every render. + const queryKeysRef = useRef(queryKeys); + queryKeysRef.current = queryKeys; + + const wasActiveRef = useRef(isActive); + const focusedRef = useRef(isFocused); + const firstFocusRef = useRef(true); + + useEffect(() => { + focusedRef.current = isFocused; + }, [isFocused]); + + useEffect(() => { + if (!wasActiveRef.current && isActive && focusedRef.current) { + for (const queryKey of queryKeysRef.current) { + void queryClient.invalidateQueries({ queryKey }); + } + } + wasActiveRef.current = isActive; + }, [isActive, queryClient]); + + useFocusEffect( + useCallback(() => { + if (firstFocusRef.current) { + firstFocusRef.current = false; + return; + } + for (const queryKey of queryKeysRef.current) { + void queryClient.invalidateQueries({ queryKey }); + } + }, [queryClient]) + ); +} diff --git a/apps/mobile/src/lib/hooks/use-security-agent-mutations.test.ts b/apps/mobile/src/lib/hooks/use-security-agent-mutations.test.ts index 81d028dd0b..c16276ead3 100644 --- a/apps/mobile/src/lib/hooks/use-security-agent-mutations.test.ts +++ b/apps/mobile/src/lib/hooks/use-security-agent-mutations.test.ts @@ -1,17 +1,26 @@ -// P1-A-08e wiring tests for `useTriggerSecuritySync`. +// Tests for the security-agent mutation hooks and their helpers. // -// The dashboard owns the sync button and its toasts; these tests assert the -// HOOK WIRING: the `mutationFn` delegates to the matching +// The dashboard owns the sync button and its toasts; the sync suite asserts +// the HOOK WIRING: the `mutationFn` delegates to the matching // `trpcClient.(organizations.)securityAgent.triggerSync.mutate`, the hoisted // operation key is merged into the input, and the key rotation policy (real // `isSecuritySyncRetryable` + `mapSecuritySyncOperationError`) runs inside // `mutationFn`. Only `useHoistedOperationKey` is mocked (it holds React ref // state that needs a mounted renderer). -/* eslint-disable max-lines -- cohesive suite for sync wiring, retryability matrix, and the reconcile-first outbox path */ +// +// Suites: `useTriggerSecuritySync` wiring, retryability, and reconcile-first +// outbox; `useSaveSecurityAgentConfig` expectedRevision and generation +// guard+scope; `useSetSecurityAgentEnabled` generation guard+scope; `scope.id` +// network serialization (real MutationCache); `securitySyncIntentFingerprint`; +// `isSecuritySyncRetryable`; in-progress copy per surface; and +// `isSecurityConfigurationError`. +/* eslint-disable max-lines -- one file for the useTriggerSecuritySync wiring/retryability/outbox, useSaveSecurityAgentConfig expectedRevision + generation-guard/scope, useSetSecurityAgentEnabled generation-guard/scope, scope.id serialization (real MutationCache), securitySyncIntentFingerprint, isSecuritySyncRetryable, in-progress copy per surface, and isSecurityConfigurationError suites */ /* eslint-disable require-await, @typescript-eslint/require-await -- the fake outbox factories settle without await because they resolve immediately */ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import type * as ReactQuery from '@tanstack/react-query'; + import type * as OperationKeyModule from '@/lib/operation-key'; import { isSecurityConfigurationError, @@ -21,6 +30,7 @@ import { SECURITY_SERVICE_NOT_CONFIGURED_MESSAGE, securitySyncIntentFingerprint, useSaveSecurityAgentConfig, + useSetSecurityAgentEnabled, useTriggerSecuritySync, } from './use-security-agent-mutations'; @@ -62,9 +72,11 @@ vi.mock('@/lib/hooks/use-security-agent-commands', () => ({ type MutationOptions = { mutationFn?: (vars: unknown) => Promise; - onError?: (error: unknown) => void; + onMutate?: (vars: unknown) => Promise | unknown; + onError?: (error: unknown, vars?: unknown, context?: unknown) => void; onSuccess?: (result: unknown, vars: unknown) => void; onSettled?: (data?: unknown, error?: unknown, vars?: unknown) => Promise | void; + scope?: { id: string }; }; let lastCapturedOptions: MutationOptions | null = null; @@ -92,6 +104,7 @@ vi.mock('@tanstack/react-query', () => ({ setQueryData: (...args: unknown[]) => setQueryDataMock(...args), cancelQueries: (...args: unknown[]) => cancelQueriesMock(...args), }), + hashKey: (key: unknown) => JSON.stringify(key), })); vi.mock('@/lib/trpc', () => ({ @@ -346,6 +359,127 @@ describe('useSaveSecurityAgentConfig (expectedRevision)', () => { }); }); +describe('useSaveSecurityAgentConfig (generation guard + scope)', () => { + beforeEach(() => { + lastCapturedOptions = null; + getQueryDataMock.mockReset(); + setQueryDataMock.mockReset(); + cancelQueriesMock.mockReset(); + toastErrorMock.mockReset(); + }); + + it('scopes the mutation to serialize against a toggle on the same config', () => { + useSaveSecurityAgentConfig('personal'); + expect(lastCapturedOptions?.scope).toEqual({ id: 'security-agent-config:personal' }); + }); + + it('a failing older save does not roll back while a newer save owns the config', async () => { + getQueryDataMock.mockReturnValue({ isEnabled: false, slaEnabled: false }); + useSaveSecurityAgentConfig('personal'); + const older = await lastCapturedOptions?.onMutate?.({ slaEnabled: true }); + const newer = await lastCapturedOptions?.onMutate?.({ slaEnabled: false }); + + setQueryDataMock.mockClear(); + lastCapturedOptions?.onError?.(new Error('boom'), { slaEnabled: true }, older); + expect(setQueryDataMock).not.toHaveBeenCalled(); + + lastCapturedOptions?.onError?.(new Error('boom'), { slaEnabled: false }, newer); + expect(setQueryDataMock).toHaveBeenCalledTimes(1); + // The toast fires regardless of which generation failed. + expect(toastErrorMock).toHaveBeenCalledTimes(2); + }); + + it('a failing latest save rolls back its snapshot', async () => { + getQueryDataMock.mockReturnValue({ isEnabled: false, slaEnabled: false }); + useSaveSecurityAgentConfig('personal'); + const context = await lastCapturedOptions?.onMutate?.({ slaEnabled: true }); + + setQueryDataMock.mockClear(); + lastCapturedOptions?.onError?.(new Error('boom'), { slaEnabled: true }, context); + expect(setQueryDataMock).toHaveBeenCalledTimes(1); + expect(toastErrorMock).toHaveBeenCalledWith('boom'); + }); +}); + +describe('useSetSecurityAgentEnabled (generation guard + scope)', () => { + beforeEach(() => { + lastCapturedOptions = null; + getQueryDataMock.mockReset(); + setQueryDataMock.mockReset(); + cancelQueriesMock.mockReset(); + toastErrorMock.mockReset(); + }); + + it('scopes the mutation to serialize against a save on the same config', () => { + useSetSecurityAgentEnabled('personal'); + expect(lastCapturedOptions?.scope).toEqual({ id: 'security-agent-config:personal' }); + }); + + it('a failing older toggle does not roll back while a newer toggle owns the config', async () => { + getQueryDataMock.mockReturnValue({ isEnabled: false, configRevision: 1 }); + useSetSecurityAgentEnabled('personal'); + const older = await lastCapturedOptions?.onMutate?.({ isEnabled: true }); + const newer = await lastCapturedOptions?.onMutate?.({ isEnabled: false }); + + setQueryDataMock.mockClear(); + lastCapturedOptions?.onError?.(new Error('boom'), { isEnabled: true }, older); + expect(setQueryDataMock).not.toHaveBeenCalled(); + + lastCapturedOptions?.onError?.(new Error('boom'), { isEnabled: false }, newer); + expect(setQueryDataMock).toHaveBeenCalledTimes(1); + expect(toastErrorMock).toHaveBeenCalledTimes(2); + }); + + it('a failing latest toggle rolls back its snapshot', async () => { + getQueryDataMock.mockReturnValue({ isEnabled: false, configRevision: 1 }); + useSetSecurityAgentEnabled('personal'); + const context = await lastCapturedOptions?.onMutate?.({ isEnabled: true }); + + setQueryDataMock.mockClear(); + lastCapturedOptions?.onError?.(new Error('boom'), { isEnabled: true }, context); + expect(setQueryDataMock).toHaveBeenCalledTimes(1); + expect(toastErrorMock).toHaveBeenCalledWith('boom'); + }); +}); + +describe('scope.id network serialization (real MutationCache)', () => { + it('starts the second same-scope mutationFn only after the first settles', async () => { + const { MutationCache, QueryClient } = + await vi.importActual('@tanstack/react-query'); + const cache = new MutationCache(); + const client = new QueryClient({ mutationCache: cache }); + const order: string[] = []; + const gate = Promise.withResolvers(); + + const first = cache.build(client, { + mutationFn: async () => { + order.push('first-start'); + await gate.promise; + order.push('first-end'); + return 'first'; + }, + scope: { id: 'security-agent-config:personal' }, + }); + const second = cache.build(client, { + mutationFn: async () => { + order.push('second-start'); + return 'second'; + }, + scope: { id: 'security-agent-config:personal' }, + }); + + const p1 = first.execute({}); + const p2 = second.execute({}); + await Promise.resolve(); + await Promise.resolve(); + expect(order).toEqual(['first-start']); + + gate.resolve(null); + await Promise.all([p1, p2]); + expect(order).toEqual(['first-start', 'first-end', 'second-start']); + }); +}); + describe('securitySyncIntentFingerprint (P1-A-08e changed-input)', () => { it('stays stable for a retry of the same scope+repo and rotates when the repo or scope changes', () => { const original = securitySyncIntentFingerprint(ORG_ID, 'kilo/repo'); diff --git a/apps/mobile/src/lib/hooks/use-security-agent-mutations.ts b/apps/mobile/src/lib/hooks/use-security-agent-mutations.ts index 8b8a2f0215..a1f016445b 100644 --- a/apps/mobile/src/lib/hooks/use-security-agent-mutations.ts +++ b/apps/mobile/src/lib/hooks/use-security-agent-mutations.ts @@ -1,7 +1,11 @@ import { isPersonalSecurityScope } from '@kilocode/app-shared/security-agent'; -import { useMutation, useQueryClient } from '@tanstack/react-query'; +import { hashKey, useMutation, useQueryClient } from '@tanstack/react-query'; import { announcingToast } from '@/lib/a11y/announcing-toast'; +import { + isLatestMutationGeneration, + nextMutationGeneration, +} from '@/lib/hooks/mutation-generations'; import { trackSecurityAgentCommand } from '@/lib/hooks/use-security-agent-commands'; import { isOperationInProgress, @@ -124,6 +128,8 @@ export function useSaveSecurityAgentConfig(scope: string) { const queryClient = useQueryClient(); const configQueryKey = useSecurityAgentConfigQueryKey(scope); + // onError policy: roll back the onMutate snapshot (latest generation only) + // and toast error.message. return useMutation({ // eslint-disable-next-line typescript-eslint/promise-function-async -- conflicting require-await rule mutationFn: (patch: Omit) => { @@ -141,14 +147,18 @@ export function useSaveSecurityAgentConfig(scope: string) { }, onMutate: async patch => { await queryClient.cancelQueries({ queryKey: configQueryKey }); + const generation = nextMutationGeneration(hashKey(configQueryKey)); const previous = queryClient.getQueryData(configQueryKey); queryClient.setQueryData(configQueryKey, old => old ? { ...old, ...patch } : old ); - return { previous, patch }; + return { previous, patch, generation }; }, onError: (error, _patch, context) => { - if (context?.previous) { + if ( + context?.previous && + isLatestMutationGeneration(hashKey(configQueryKey), context.generation) + ) { const keys = Object.keys(context.patch) as (keyof SecurityAgentConfig)[]; const restoredFields = pick(context.previous, keys); queryClient.setQueryData(configQueryKey, old => @@ -185,6 +195,9 @@ export function useSaveSecurityAgentConfig(scope: string) { ); }); }, + // Serialize a save against a toggle on the same config so the network + // calls land in order; the generation guard above orders the rollbacks. + scope: { id: `security-agent-config:${scope}` }, }); } @@ -193,6 +206,8 @@ export function useSetSecurityAgentEnabled(scope: string) { const queryClient = useQueryClient(); const configQueryKey = useSecurityAgentConfigQueryKey(scope); + // onError policy: roll back the onMutate snapshot (latest generation only) + // and toast error.message. return useMutation({ // eslint-disable-next-line typescript-eslint/promise-function-async -- conflicting require-await rule mutationFn: (vars: Parameters[0]) => @@ -204,16 +219,23 @@ export function useSetSecurityAgentEnabled(scope: string) { }), onMutate: async vars => { await queryClient.cancelQueries({ queryKey: configQueryKey }); + const generation = nextMutationGeneration(hashKey(configQueryKey)); const previous = queryClient.getQueryData(configQueryKey); queryClient.setQueryData(configQueryKey, old => old ? { ...old, isEnabled: vars.isEnabled } : old ); - return { previous }; + return { previous, generation }; }, onError: (error, _vars, context) => { - queryClient.setQueryData(configQueryKey, old => - old && context?.previous ? { ...old, isEnabled: context.previous.isEnabled } : old - ); + if ( + context?.previous && + isLatestMutationGeneration(hashKey(configQueryKey), context.generation) + ) { + const previous = context.previous; + queryClient.setQueryData(configQueryKey, old => + old ? { ...old, isEnabled: previous.isEnabled } : old + ); + } announcingToast.error(error.message); }, onSuccess: result => { @@ -252,6 +274,9 @@ export function useSetSecurityAgentEnabled(scope: string) { }), ]); }, + // Serialize a toggle against a save on the same config so the network + // calls land in order; the generation guard above orders the rollbacks. + scope: { id: `security-agent-config:${scope}` }, }); } diff --git a/apps/mobile/src/lib/hooks/use-security-agent.test.ts b/apps/mobile/src/lib/hooks/use-security-agent.test.ts new file mode 100644 index 0000000000..7f2e2f8245 --- /dev/null +++ b/apps/mobile/src/lib/hooks/use-security-agent.test.ts @@ -0,0 +1,126 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { useSecurityAgentCapability } from './use-security-agent'; + +// use-security-agent.ts re-exports the mutation hooks from +// use-security-agent-mutations.ts; stub that module so loading this file in +// node does not pull in the mutation graph (expo-router, outbox, etc.). +vi.mock('@/lib/hooks/use-security-agent-mutations', () => ({ + useSaveSecurityAgentConfig: () => ({}), + useSetSecurityAgentEnabled: () => ({}), + useTrackSecurityAgentInteraction: () => ({}), + useTriggerSecuritySync: () => ({}), +})); + +type OrgListEntry = { organizationId: string; role: string }; + +const queryState = vi.hoisted(() => ({ + data: undefined as OrgListEntry[] | undefined, + isLoading: false, + isError: false, + isFetching: false, + isPending: false, + refetch: vi.fn(), +})); + +vi.mock('@tanstack/react-query', () => ({ + useQuery: () => queryState, +})); + +vi.mock('@/lib/trpc', () => ({ + useTRPC: () => ({ + organizations: { + list: { queryOptions: () => ({}) }, + }, + }), +})); + +function capabilityFor(scope: string) { + // eslint-disable-next-line react-hooks/rules-of-hooks -- the mocked hooks have no React state; this mirrors use-code-reviewer.test.ts + return useSecurityAgentCapability(scope); +} + +beforeEach(() => { + queryState.data = undefined; + queryState.isLoading = false; + queryState.isError = false; + queryState.isFetching = false; + queryState.isPending = false; + queryState.refetch.mockReset(); +}); + +afterEach(() => { + vi.clearAllMocks(); +}); + +describe('useSecurityAgentCapability status derivation', () => { + it('returns allowed for the personal scope', () => { + const capability = capabilityFor('personal'); + + expect(capability.status).toBe('allowed'); + expect(capability.canManage).toBe(true); + }); + + it('returns allowed for an org with a settled owner role', () => { + queryState.data = [{ organizationId: 'org_123', role: 'owner' }]; + const capability = capabilityFor('org_123'); + + expect(capability.status).toBe('allowed'); + expect(capability.canManage).toBe(true); + }); + + it('returns denied for an org with a settled member role', () => { + queryState.data = [{ organizationId: 'org_123', role: 'member' }]; + const capability = capabilityFor('org_123'); + + expect(capability.status).toBe('denied'); + expect(capability.canManage).toBe(false); + }); + + it('returns error when the org role query fails with no data', () => { + queryState.data = undefined; + queryState.isError = true; + queryState.isPending = false; + const capability = capabilityFor('org_123'); + + expect(capability.status).toBe('error'); + }); + + it('returns loading when the org role query is pending with no data', () => { + queryState.data = undefined; + queryState.isError = false; + queryState.isPending = true; + const capability = capabilityFor('org_123'); + + expect(capability.status).toBe('loading'); + }); + + it('falls back to loading for any other unresolved combination', () => { + queryState.data = undefined; + queryState.isError = false; + queryState.isPending = false; + const capability = capabilityFor('org_123'); + + expect(capability.status).toBe('loading'); + }); + + it('keeps a settled owner role authoritative when a background refetch fails', () => { + queryState.data = [{ organizationId: 'org_123', role: 'owner' }]; + queryState.isError = true; + queryState.isPending = false; + const capability = capabilityFor('org_123'); + + expect(capability.status).toBe('allowed'); + expect(capability.canManage).toBe(true); + }); + + it('keeps a settled member role denied when a background refetch fails', () => { + queryState.data = [{ organizationId: 'org_123', role: 'member' }]; + queryState.isError = true; + queryState.isPending = false; + const capability = capabilityFor('org_123'); + + expect(capability.status).toBe('denied'); + expect(capability.canManage).toBe(false); + }); +}); diff --git a/apps/mobile/src/lib/hooks/use-security-agent.ts b/apps/mobile/src/lib/hooks/use-security-agent.ts index b92daaf3d1..5ad730b383 100644 --- a/apps/mobile/src/lib/hooks/use-security-agent.ts +++ b/apps/mobile/src/lib/hooks/use-security-agent.ts @@ -125,6 +125,8 @@ function useSecurityAgentOrgRoleQuery(scope: string) { isError: false, isFetching: false, refetch: query.refetch, + hasData: true, + isPending: false, }; } return { @@ -133,16 +135,39 @@ function useSecurityAgentOrgRoleQuery(scope: string) { isError: query.isError, isFetching: query.isFetching, refetch: query.refetch, + hasData: query.data !== undefined, + isPending: query.isPending, }; } // Discriminated capability state for consumers (e.g. audit-report access) // that must distinguish "still loading"/"failed to load" from "resolved: // no access" instead of treating an undefined role as permission-denied. +type SecurityAgentCapabilityStatus = 'loading' | 'error' | 'denied' | 'allowed'; + export function useSecurityAgentCapability(scope: string) { - const { role, isLoading, isError, isFetching, refetch } = useSecurityAgentOrgRoleQuery(scope); + const { role, isLoading, isError, isFetching, refetch, hasData, isPending } = + useSecurityAgentOrgRoleQuery(scope); + const canManage = canManageSecurityAgent(scope, role); + + let status: SecurityAgentCapabilityStatus = 'loading'; + if (isPersonalSecurityScope(scope)) { + status = 'allowed'; + } else if (hasData) { + // A settled role stays authoritative: a failed background refetch must + // never demote an already-resolved capability to 'error'. + status = canManage ? 'allowed' : 'denied'; + } else if (isError) { + status = 'error'; + } else if (isPending) { + // Covers an offline-paused cold launch: pending with no data yet. + status = 'loading'; + } + // Any other unresolved combination keeps the initial 'loading'. + return { - canManage: canManageSecurityAgent(scope, role), + canManage, + status, isLoading, isError, isFetching, diff --git a/apps/mobile/src/lib/hooks/use-security-remediation.test.ts b/apps/mobile/src/lib/hooks/use-security-remediation.test.ts new file mode 100644 index 0000000000..a1ccfb6f5c --- /dev/null +++ b/apps/mobile/src/lib/hooks/use-security-remediation.test.ts @@ -0,0 +1,189 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { useCancelSecurityRemediation } from './use-security-remediation'; + +type MutationOptions = { + mutationFn?: (vars: unknown) => Promise; + onMutate?: (vars: unknown) => Promise | unknown; + onError?: (error: unknown, vars: unknown, context: unknown) => void; + onSuccess?: (result: unknown, vars: unknown) => void; + onSettled?: (data?: unknown, error?: unknown, vars?: unknown) => Promise | void; + scope?: { id: string }; +}; + +let lastCapturedOptions: MutationOptions | null = null; +const cancelMutateMock = vi.fn(); +const orgCancelMutateMock = vi.fn(); +const invalidateQueriesMock = vi.fn(); +const getQueryDataMock = vi.fn(); +const setQueryDataMock = vi.fn(); +const cancelQueriesMock = vi.fn(); +const toastErrorMock = vi.fn(); +const toastSuccessMock = vi.fn(); + +vi.mock('@tanstack/react-query', () => ({ + useMutation: (opts: MutationOptions) => { + lastCapturedOptions = opts; + return { mutate: vi.fn(), mutateAsync: vi.fn(), isPending: false, isError: false }; + }, + useQueryClient: () => ({ + invalidateQueries: (...args: unknown[]) => { + invalidateQueriesMock(...args); + }, + getQueryData: (...args: unknown[]) => getQueryDataMock(...args), + setQueryData: (...args: unknown[]) => setQueryDataMock(...args), + cancelQueries: (...args: unknown[]) => cancelQueriesMock(...args), + }), + hashKey: (key: unknown) => JSON.stringify(key), +})); + +vi.mock('react-native', () => ({ + InteractionManager: { runAfterInteractions: vi.fn() }, +})); + +vi.mock('sonner-native', () => ({ + toast: { + error: (msg: string) => toastErrorMock(msg), + success: (msg: string) => toastSuccessMock(msg), + }, +})); + +vi.mock('@kilocode/app-shared/security-agent', () => ({ + isPersonalSecurityScope: (scope: string) => scope === 'personal', + getRemediationUnavailableCopy: () => null, +})); + +vi.mock('@/lib/trpc', () => ({ + useTRPC: () => ({ + securityAgent: { + getAnalysis: { + queryKey: ({ findingId }: { findingId: string }) => [ + 'securityAgent', + 'getAnalysis', + { findingId }, + ], + }, + getFinding: { queryKey: () => ['securityAgent', 'getFinding'] }, + getDashboardStats: { queryKey: () => ['securityAgent', 'getDashboardStats'] }, + listFindings: { queryKey: () => ['securityAgent', 'listFindings'] }, + }, + organizations: { + securityAgent: { + getAnalysis: { + queryKey: ({ + organizationId, + findingId, + }: { + organizationId: string; + findingId: string; + }) => ['organizations', 'securityAgent', 'getAnalysis', { organizationId, findingId }], + }, + getFinding: { queryKey: () => ['organizations', 'securityAgent', 'getFinding'] }, + getDashboardStats: { + queryKey: () => ['organizations', 'securityAgent', 'getDashboardStats'], + }, + listFindings: { queryKey: () => ['organizations', 'securityAgent', 'listFindings'] }, + }, + }, + }), + trpcClient: { + securityAgent: { + cancelRemediation: { mutate: (vars: unknown) => cancelMutateMock(vars) }, + }, + organizations: { + securityAgent: { + cancelRemediation: { mutate: (vars: unknown) => orgCancelMutateMock(vars) }, + }, + }, + }, +})); + +const ORG_ID = 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa'; + +describe('useCancelSecurityRemediation (generation guard)', () => { + beforeEach(() => { + lastCapturedOptions = null; + cancelMutateMock.mockReset(); + orgCancelMutateMock.mockReset(); + invalidateQueriesMock.mockReset(); + getQueryDataMock.mockReset(); + setQueryDataMock.mockReset(); + cancelQueriesMock.mockReset(); + toastErrorMock.mockReset(); + toastSuccessMock.mockReset(); + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + it('has no scope (rule 4: concurrent cancels write different findings)', () => { + useCancelSecurityRemediation('personal'); + expect(lastCapturedOptions?.scope).toBeUndefined(); + }); + + it('a failing older cancel does not roll back while a newer cancel owns the finding', async () => { + getQueryDataMock.mockReturnValue({ + remediationAttempts: [{ id: 'a1', cancellationRequestedAt: null }], + }); + useCancelSecurityRemediation('personal'); + const older = await lastCapturedOptions?.onMutate?.({ attemptId: 'a1', findingId: 'f1' }); + const newer = await lastCapturedOptions?.onMutate?.({ attemptId: 'a1', findingId: 'f1' }); + + setQueryDataMock.mockClear(); + lastCapturedOptions?.onError?.(new Error('boom'), { attemptId: 'a1', findingId: 'f1' }, older); + expect(setQueryDataMock).not.toHaveBeenCalled(); + + lastCapturedOptions?.onError?.(new Error('boom'), { attemptId: 'a1', findingId: 'f1' }, newer); + expect(setQueryDataMock).toHaveBeenCalledTimes(1); + // The toast fires regardless of which generation failed. + expect(toastErrorMock).toHaveBeenCalledTimes(2); + }); + + it('a failing latest cancel rolls back its snapshot', async () => { + getQueryDataMock.mockReturnValue({ + remediationAttempts: [{ id: 'a1', cancellationRequestedAt: null }], + }); + useCancelSecurityRemediation('personal'); + const context = await lastCapturedOptions?.onMutate?.({ attemptId: 'a1', findingId: 'f1' }); + + setQueryDataMock.mockClear(); + lastCapturedOptions?.onError?.( + new Error('boom'), + { attemptId: 'a1', findingId: 'f1' }, + context + ); + expect(setQueryDataMock).toHaveBeenCalledTimes(1); + expect(toastErrorMock).toHaveBeenCalledWith('boom'); + }); + + it('keys the generation guard per finding', async () => { + getQueryDataMock.mockReturnValue({ remediationAttempts: [] }); + useCancelSecurityRemediation('personal'); + // Different findings write different analysis query keys, so the first + // finding's failure still rolls back after a second finding was written. + const findingA = await lastCapturedOptions?.onMutate?.({ attemptId: 'a1', findingId: 'f1' }); + const findingB = await lastCapturedOptions?.onMutate?.({ attemptId: 'a2', findingId: 'f2' }); + void findingB; + + setQueryDataMock.mockClear(); + lastCapturedOptions?.onError?.( + new Error('boom'), + { attemptId: 'a1', findingId: 'f1' }, + findingA + ); + expect(setQueryDataMock).toHaveBeenCalledTimes(1); + }); + + it('delegates an org cancel to organizations.securityAgent.cancelRemediation', async () => { + orgCancelMutateMock.mockResolvedValueOnce({ status: 'cancelled' }); + useCancelSecurityRemediation(ORG_ID); + + await lastCapturedOptions?.mutationFn?.({ attemptId: 'a1', findingId: 'f1' }); + + expect(orgCancelMutateMock).toHaveBeenCalledWith({ + organizationId: ORG_ID, + attemptId: 'a1', + }); + }); +}); diff --git a/apps/mobile/src/lib/hooks/use-security-remediation.ts b/apps/mobile/src/lib/hooks/use-security-remediation.ts index 52e35eac66..50c0ae0ebd 100644 --- a/apps/mobile/src/lib/hooks/use-security-remediation.ts +++ b/apps/mobile/src/lib/hooks/use-security-remediation.ts @@ -5,9 +5,13 @@ import { getRemediationUnavailableCopy, isPersonalSecurityScope, } from '@kilocode/app-shared/security-agent'; -import { useMutation, useQueryClient } from '@tanstack/react-query'; +import { hashKey, useMutation, useQueryClient } from '@tanstack/react-query'; import { toast } from 'sonner-native'; +import { + isLatestMutationGeneration, + nextMutationGeneration, +} from '@/lib/hooks/mutation-generations'; import { reconcileFirstPage } from '@/lib/query/infinite-retention'; import { scheduleCacheMaintenance } from '@/lib/query/schedule-cache-maintenance'; import { type SecurityAnalysis } from '@/lib/security-agent'; @@ -141,6 +145,8 @@ function getSecurityAnalysisQueryKey( export function useCancelSecurityRemediation(scope: string) { const trpc = useTRPC(); const queryClient = useQueryClient(); + // onError policy: roll back the onMutate snapshot (latest generation only) + // and toast error.message. return useMutation({ // eslint-disable-next-line typescript-eslint/promise-function-async -- conflicting require-await rule mutationFn: (vars: { attemptId: string; findingId: string }) => @@ -153,6 +159,7 @@ export function useCancelSecurityRemediation(scope: string) { onMutate: async vars => { const analysisQueryKey = getSecurityAnalysisQueryKey(trpc, scope, vars.findingId); await queryClient.cancelQueries({ queryKey: analysisQueryKey }); + const generation = nextMutationGeneration(hashKey(analysisQueryKey)); const previous = queryClient.getQueryData(analysisQueryKey); queryClient.setQueryData(analysisQueryKey, old => old @@ -166,10 +173,13 @@ export function useCancelSecurityRemediation(scope: string) { } : old ); - return { previous, analysisQueryKey }; + return { previous, analysisQueryKey, generation }; }, onError: (error, _vars, context) => { - if (context?.previous) { + if ( + context?.previous && + isLatestMutationGeneration(hashKey(context.analysisQueryKey), context.generation) + ) { queryClient.setQueryData(context.analysisQueryKey, context.previous); } toast.error(error.message); diff --git a/apps/mobile/src/lib/hooks/use-session-mutations.test.ts b/apps/mobile/src/lib/hooks/use-session-mutations.test.ts index 1b912261d5..c37341884e 100644 --- a/apps/mobile/src/lib/hooks/use-session-mutations.test.ts +++ b/apps/mobile/src/lib/hooks/use-session-mutations.test.ts @@ -1,3 +1,4 @@ +/* eslint-disable max-lines -- one file for the rename/delete optimistic mutation wiring and rollback suites */ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { useSessionMutations } from './use-session-mutations'; @@ -6,6 +7,7 @@ type MutationOptions = { onMutate?: (input: { session_id: string; title?: string }) => Promise | unknown; onError?: (error: Error, input: unknown, context: unknown) => void; onSettled?: () => unknown; + scope?: { id: string }; [key: string]: unknown; }; type TrpcMock = { @@ -63,6 +65,7 @@ vi.mock('@tanstack/react-query', () => ({ setQueryData: setQueryDataMock, invalidateQueries: invalidateQueriesMock, }), + hashKey: (key: unknown) => JSON.stringify(key), })); vi.mock('@/lib/trpc', () => ({ @@ -222,28 +225,30 @@ describe('useSessionMutations', () => { expect(context).toEqual({ previous: storedSnapshot, previousActive: activeSnapshot, + generation: expect.any(Number), }); }); - it('onError restores both snapshots and toasts the error', () => { + it('onError restores both snapshots and toasts the error', async () => { + const storedSnapshot: [unknown, unknown][] = [[['stored-key'], { pages: ['stored'] }]]; + const activeSnapshot: [unknown, unknown][] = [ + [['active-key'], { sessions: [{ id: 's1', title: 'Old' }] }], + ]; + getQueriesDataMock.mockReturnValueOnce(storedSnapshot).mockReturnValueOnce(activeSnapshot); + useSessionMutations(); const options = capturedOptions.rename; - const storedKey = ['stored-key'] as const; - const activeKey = ['active-key'] as const; - const previous = [[storedKey, { pages: ['stored'] }]] as [unknown, unknown][]; - const previousActive = [[activeKey, { sessions: [{ id: 's1', title: 'Old' }] }]] as [ - unknown, - unknown, - ][]; - - options?.onError?.( - new Error('rename failed'), - { session_id: 's1', title: 'New' }, - { previous, previousActive } - ); + const onMutate = options?.onMutate; + if (!onMutate) { + throw new Error('expected rename onMutate'); + } + const context = await onMutate({ session_id: 's1', title: 'New' }); + + setQueryDataMock.mockClear(); + options.onError?.(new Error('rename failed'), { session_id: 's1', title: 'New' }, context); - expect(setQueryDataMock).toHaveBeenCalledWith(storedKey, { pages: ['stored'] }); - expect(setQueryDataMock).toHaveBeenCalledWith(activeKey, { + expect(setQueryDataMock).toHaveBeenCalledWith(['stored-key'], { pages: ['stored'] }); + expect(setQueryDataMock).toHaveBeenCalledWith(['active-key'], { sessions: [{ id: 's1', title: 'Old' }], }); expect(toastErrorMock).toHaveBeenCalledWith('rename failed'); @@ -284,6 +289,75 @@ describe('useSessionMutations', () => { }); }); + describe('generation guard (shared cliSessionsV2.list cache)', () => { + it('adds no scope.id (callers already serialize per session via chainSave)', () => { + useSessionMutations(); + expect(capturedOptions.delete?.scope).toBeUndefined(); + expect(capturedOptions.rename?.scope).toBeUndefined(); + }); + + it('a failing older delete does not roll back while a newer rename owns the shared list cache', async () => { + const deleteSnapshot: [unknown, unknown][] = [[['delete-key'], { pages: ['delete'] }]]; + const renameStoredSnapshot: [unknown, unknown][] = [[['stored-key'], { pages: ['stored'] }]]; + const renameActiveSnapshot: [unknown, unknown][] = [ + [['active-key'], { sessions: [{ id: 's2', title: 'Old' }] }], + ]; + getQueriesDataMock + .mockReturnValueOnce(deleteSnapshot) + .mockReturnValueOnce(renameStoredSnapshot) + .mockReturnValueOnce(renameActiveSnapshot); + + useSessionMutations(); + const deleteOnMutate = capturedOptions.delete?.onMutate; + const renameOnMutate = capturedOptions.rename?.onMutate; + if (!deleteOnMutate || !renameOnMutate) { + throw new Error('expected onMutate'); + } + + const olderDelete = await deleteOnMutate({ session_id: 's1' }); + const newerRename = await renameOnMutate({ session_id: 's2', title: 'New' }); + + setQueryDataMock.mockClear(); + capturedOptions.delete?.onError?.( + new Error('delete failed'), + { session_id: 's1' }, + olderDelete + ); + // The older delete's rollback must not restore its snapshot over the + // newer rename's optimistic write. + expect(setQueryDataMock).not.toHaveBeenCalled(); + expect(toastErrorMock).toHaveBeenCalledWith('delete failed'); + + capturedOptions.rename?.onError?.( + new Error('rename failed'), + { session_id: 's2', title: 'New' }, + newerRename + ); + expect(setQueryDataMock).toHaveBeenCalledWith(['stored-key'], { pages: ['stored'] }); + expect(setQueryDataMock).toHaveBeenCalledWith(['active-key'], { + sessions: [{ id: 's2', title: 'Old' }], + }); + expect(toastErrorMock).toHaveBeenCalledWith('rename failed'); + }); + + it('a failing latest delete rolls back its snapshot and toasts', async () => { + const deleteSnapshot: [unknown, unknown][] = [[['delete-key'], { pages: ['delete'] }]]; + getQueriesDataMock.mockReturnValue(deleteSnapshot); + + useSessionMutations(); + const onMutate = capturedOptions.delete?.onMutate; + if (!onMutate) { + throw new Error('expected delete onMutate'); + } + const context = await onMutate({ session_id: 's1' }); + + setQueryDataMock.mockClear(); + capturedOptions.delete?.onError?.(new Error('delete failed'), { session_id: 's1' }, context); + expect(setQueryDataMock).toHaveBeenCalledWith(['delete-key'], { pages: ['delete'] }); + expect(toastErrorMock).toHaveBeenCalledWith('delete failed'); + }); + }); + describe('deleteSession completion callback', () => { it('toasts success and invokes onDeleted after a successful delete', async () => { mutateAsyncMock.mockResolvedValue(undefined); diff --git a/apps/mobile/src/lib/hooks/use-session-mutations.ts b/apps/mobile/src/lib/hooks/use-session-mutations.ts index 685cbbf089..a2901fea9a 100644 --- a/apps/mobile/src/lib/hooks/use-session-mutations.ts +++ b/apps/mobile/src/lib/hooks/use-session-mutations.ts @@ -1,8 +1,12 @@ -import { type QueryKey, useMutation, useQueryClient } from '@tanstack/react-query'; +import { hashKey, type QueryKey, useMutation, useQueryClient } from '@tanstack/react-query'; import { invalidateAgentSessionQueries } from '@/lib/agent-session-cache'; import { applyActiveSessionTitle, type CachedActiveSessionsData } from '@/lib/active-sessions-live'; import { announcingToast } from '@/lib/a11y/announcing-toast'; +import { + isLatestMutationGeneration, + nextMutationGeneration, +} from '@/lib/hooks/mutation-generations'; import { chainSave } from '@/lib/hooks/save-chain'; import { scheduleCacheMaintenance } from '@/lib/query/schedule-cache-maintenance'; import { @@ -32,13 +36,14 @@ export function useSessionMutations() { const snapshotAndUpdate = async ( update: (data: SessionsListData) => SessionsListData - ): Promise<{ previous: SessionsListSnapshot }> => { + ): Promise<{ previous: SessionsListSnapshot; generation: number }> => { await queryClient.cancelQueries({ queryKey: listKey }); + const generation = nextMutationGeneration(hashKey(listKey)); const previous = queryClient.getQueriesData({ queryKey: listKey }); queryClient.setQueriesData({ queryKey: listKey }, old => old ? update(old) : old ); - return { previous }; + return { previous, generation }; }; /** @@ -61,31 +66,42 @@ export function useSessionMutations() { } }; + // onError policy: roll back the onMutate snapshot (latest generation only) + // and toast error.message. const deleteSessionMutation = useMutation( trpc.cliSessionsV2.delete.mutationOptions({ - // eslint-disable-next-line typescript-eslint/promise-function-async -- conflicting require-await rule - onMutate: ({ session_id }) => - snapshotAndUpdate(data => removeStoredSession(data, session_id)), + onMutate: async ({ session_id }) => { + const { previous, generation } = await snapshotAndUpdate(data => + removeStoredSession(data, session_id) + ); + return { previous, generation }; + }, onError: (error, _input, context) => { - rollback(context?.previous); + if (context && isLatestMutationGeneration(hashKey(listKey), context.generation)) { + rollback(context.previous); + } onError(error); }, onSettled: invalidateSessions, }) ); + // onError policy: roll back the onMutate snapshot (latest generation only) + // and toast error.message. const renameSessionMutation = useMutation( trpc.cliSessionsV2.rename.mutationOptions({ onMutate: async ({ session_id, title }) => { - const { previous } = await snapshotAndUpdate(data => + const { previous, generation } = await snapshotAndUpdate(data => mapStoredSessions(data, session_id, session => ({ ...session, title })) ); const previousActive = await snapshotAndUpdateActive(session_id, title); - return { previous, previousActive }; + return { previous, previousActive, generation }; }, onError: (error, _input, context) => { - rollback(context?.previous); - rollback(context?.previousActive); + if (context && isLatestMutationGeneration(hashKey(listKey), context.generation)) { + rollback(context.previous); + rollback(context.previousActive); + } onError(error); }, onSettled: invalidateSessions, diff --git a/apps/mobile/src/lib/hooks/use-user-web-connection-state.ts b/apps/mobile/src/lib/hooks/use-user-web-connection-state.ts index 7865958d6b..8858f0abd4 100644 --- a/apps/mobile/src/lib/hooks/use-user-web-connection-state.ts +++ b/apps/mobile/src/lib/hooks/use-user-web-connection-state.ts @@ -17,3 +17,27 @@ export function useUserWebConnectionState(): boolean { () => connection.isConnected() ); } + +type UserWebConnectionHealth = { + isConnected: boolean; + reconnectExhausted: boolean; +}; + +/** + * Reactive binding to both user-web transport signals: readiness and + * reconnect exhaustion. Readiness keeps driving the automatic + * `Reconnecting…` / `Connecting…` labels; exhaustion drives the explicit + * recovery UI (`Connection lost` + `Retry`). + */ +export function useUserWebConnectionHealth(): UserWebConnectionHealth { + const connection = useUserWebConnection(); + const isConnected = useSyncExternalStore( + listener => connection.onConnectionChange(listener), + () => connection.isConnected() + ); + const reconnectExhausted = useSyncExternalStore( + listener => connection.onReconnectExhaustionChange(listener), + () => connection.isReconnectExhausted() + ); + return { isConnected, reconnectExhausted }; +} diff --git a/apps/mobile/src/lib/offline-banner-state.test.ts b/apps/mobile/src/lib/offline-banner-state.test.ts index 2850d9148b..8e11d5f45b 100644 --- a/apps/mobile/src/lib/offline-banner-state.test.ts +++ b/apps/mobile/src/lib/offline-banner-state.test.ts @@ -90,6 +90,7 @@ describe('createOfflineBannerStore', () => { const { store } = createStore(); expect(store.isOffline()).toBe(false); + expect(store.state()).toBe('unknown'); }); it('does not show the offline banner while connectivity is unknown', () => { @@ -104,7 +105,7 @@ describe('createOfflineBannerStore', () => { expect(listener).not.toHaveBeenCalled(); }); - it('does not notify on unknown → online (the banner stays hidden)', () => { + it('notifies on unknown → online (the banner stays hidden)', () => { const { store, source } = createStore(); const listener = vi.fn(() => undefined); store.subscribe(listener); @@ -113,7 +114,8 @@ describe('createOfflineBannerStore', () => { source.emit(onlineState); expect(store.isOffline()).toBe(false); - expect(listener).not.toHaveBeenCalled(); + expect(store.state()).toBe('online'); + expect(listener).toHaveBeenCalledTimes(1); }); it('commits offline only after the show delay and notifies once', () => { @@ -134,7 +136,7 @@ describe('createOfflineBannerStore', () => { expect(listener).toHaveBeenCalledTimes(1); }); - it('never commits when the state returns online inside the window', () => { + it('cancels the pending offline commit when the state returns online inside the window', () => { const { store, source, timer } = createStore(); const listener = vi.fn(() => undefined); store.subscribe(listener); @@ -145,7 +147,8 @@ describe('createOfflineBannerStore', () => { timer.firePending(); expect(store.isOffline()).toBe(false); - expect(listener).not.toHaveBeenCalled(); + expect(store.state()).toBe('online'); + expect(listener).toHaveBeenCalledTimes(1); }); it('hides immediately when the connection returns after a committed offline', () => { @@ -179,7 +182,39 @@ describe('createOfflineBannerStore', () => { timer.firePending(); expect(store.isOffline()).toBe(true); - expect(listener).toHaveBeenCalledTimes(1); + // One notification for the unknown → online commit, one for the final + // offline commit — the intermediate online commits are no-ops. + expect(listener).toHaveBeenCalledTimes(2); + }); + + it('flapping unknown → offline → online → offline shows only after the debounce and hides immediately on online', () => { + const { store, source, timer } = createStore(); + const listener = vi.fn(() => undefined); + store.subscribe(listener); + + source.emit(unknownState); + timer.firePending(); + expect(store.isOffline()).toBe(false); + expect(listener).not.toHaveBeenCalled(); + + source.emit(offlineState); + // Not committed yet: the banner waits out the show delay. + expect(store.isOffline()).toBe(false); + expect(timer.scheduled[0]?.delayMs).toBe(OFFLINE_BANNER_SHOW_DELAY_MS); + + source.emit(onlineState); + // Hides immediately: the pending offline commit was cancelled. + expect(store.isOffline()).toBe(false); + expect(store.state()).toBe('online'); + + source.emit(offlineState); + timer.firePending(); + expect(store.isOffline()).toBe(true); + expect(store.state()).toBe('offline'); + + // One notification for the unknown → online commit, one for the final + // offline commit. + expect(listener).toHaveBeenCalledTimes(2); }); it('destroy with a pending commit cancels the timer and unsubscribes the source', () => { @@ -221,4 +256,17 @@ describe('createOfflineBannerStore', () => { expect(listener).not.toHaveBeenCalled(); }); + + it('exposes the committed state via state()', () => { + const { store, source, timer } = createStore(); + + expect(store.state()).toBe('unknown'); + + source.emit(onlineState); + expect(store.state()).toBe('online'); + + source.emit(offlineState); + timer.firePending(); + expect(store.state()).toBe('offline'); + }); }); diff --git a/apps/mobile/src/lib/offline-banner-state.ts b/apps/mobile/src/lib/offline-banner-state.ts index 6ad421c03c..2ec57ee773 100644 --- a/apps/mobile/src/lib/offline-banner-state.ts +++ b/apps/mobile/src/lib/offline-banner-state.ts @@ -19,11 +19,12 @@ export type ConnectivitySource = { export type OfflineBannerStore = { subscribe: (listener: () => void) => () => void; isOffline: () => boolean; + state: () => BannerState; destroy: () => void; }; /** The banner's committed connectivity state. */ -type BannerState = 'online' | 'offline' | 'unknown'; +export type BannerState = 'online' | 'offline' | 'unknown'; export function createOfflineBannerStore(options: { source: ConnectivitySource; @@ -45,14 +46,15 @@ export function createOfflineBannerStore(options: { } function commit(next: BannerState): void { - const wasOffline = state === 'offline'; + if (state === next) { + return; + } state = next; - // Notify only when the observable `isOffline` value changes; an - // unknown → online transition leaves it false and must not re-render. - if (wasOffline !== (next === 'offline')) { - for (const listener of listeners) { - listener(); - } + // Notify on every committed state change. The banner's `getSnapshot` + // (`isOffline`) is unchanged on an unknown → online edge, so it does not + // re-render there; the tri-state hook's `getSnapshot` (`state`) does. + for (const listener of listeners) { + listener(); } } @@ -87,11 +89,13 @@ export function createOfflineBannerStore(options: { const isOffline = (): boolean => state === 'offline'; + const getState = (): BannerState => state; + const destroy = (): void => { cancelPending(); unsubscribeSource(); listeners.clear(); }; - return { subscribe, isOffline, destroy }; + return { subscribe, isOffline, state: getState, destroy }; } diff --git a/apps/mobile/src/lib/persist/mutation-outbox.test.ts b/apps/mobile/src/lib/persist/mutation-outbox.test.ts index a1a48e785c..2a1c680362 100644 --- a/apps/mobile/src/lib/persist/mutation-outbox.test.ts +++ b/apps/mobile/src/lib/persist/mutation-outbox.test.ts @@ -147,6 +147,23 @@ describe('round trip and absent load', () => { }); }); +describe('process kill and relaunch', () => { + it('recovers a written safe-retry row after a simulated relaunch and keeps its stored operationKey', async () => { + // Crash mid-flight: the row was persisted before the POST. + await writeOutboxRow('u1', safeRetryRow({ operationKey: 'op-key-1', fingerprint: 'fp-1' })); + + // Relaunch: the launch load lists the rows again from the encrypted KV. + const rows = await listOutboxRows('u1'); + + expect(rows).toHaveLength(1); + expect(rows?.[0]).toMatchObject({ + taxonomy: 'safe-retry', + operationKey: 'op-key-1', + fingerprint: 'fp-1', + }); + }); +}); + describe('remove and list', () => { it('removes a stored row', async () => { await writeOutboxRow('u1', safeRetryRow({ fingerprint: 'fp' })); diff --git a/apps/mobile/src/lib/pr-review/discussion/use-review-discussion-mutations.test.ts b/apps/mobile/src/lib/pr-review/discussion/use-review-discussion-mutations.test.ts index 13fa6251d1..dc26f01ad4 100644 --- a/apps/mobile/src/lib/pr-review/discussion/use-review-discussion-mutations.test.ts +++ b/apps/mobile/src/lib/pr-review/discussion/use-review-discussion-mutations.test.ts @@ -9,12 +9,20 @@ // `mutationFn`. Only `useHoistedOperationKey` is mocked (it holds React // ref state that needs a mounted renderer, covered by // `operation-key.mounted.test.tsx`). +/* eslint-disable max-lines -- one file for the reply wiring, the resolve/unresolve/reaction generation guard + chainSave/scope serialization, and the real-MutationCache scope.id serialization suites */ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import type * as OperationKeyModule from '@/lib/operation-key'; +import type * as ReactQuery from '@tanstack/react-query'; import { prIntentFingerprint } from '@kilocode/app-shared/pr-review'; -import { useReplyToCommentMutation } from './use-review-discussion-mutations'; +import { + useAddReactionMutation, + useRemoveReactionMutation, + useReplyToCommentMutation, + useResolveThreadMutation, + useUnresolveThreadMutation, +} from './use-review-discussion-mutations'; const hoistedKeys = vi.hoisted(() => ({ getKey: vi.fn(() => 'hoisted-op-key'), @@ -32,13 +40,31 @@ vi.mock('@/lib/operation-key', async importOriginal => { type MutationOptions = { mutationFn?: (vars: unknown) => Promise; - onError?: (error: unknown) => void; + onMutate?: (vars: unknown) => Promise | unknown; + onError?: (error: unknown, vars?: unknown, context?: unknown) => void; onSettled?: (data?: unknown, error?: unknown, vars?: unknown) => Promise | void; + scope?: { id: string }; }; +function captureOptions(run: () => unknown): MutationOptions { + lastCapturedOptions = null; + run(); + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition + if (!lastCapturedOptions) { + throw new Error('mutation options not captured'); + } + return lastCapturedOptions; +} + let lastCapturedOptions: MutationOptions | null = null; const replyMutateMock = vi.fn(); +const resolveMutateMock = vi.fn(); +const unresolveMutateMock = vi.fn(); const invalidateQueriesMock = vi.fn(); +const cancelQueriesMock = vi.fn(); +const getQueriesDataMock = vi.fn(); +const setQueriesDataMock = vi.fn(); +const setQueryDataMock = vi.fn(); const toastErrorMock = vi.fn(); vi.mock('@tanstack/react-query', () => ({ @@ -50,6 +76,12 @@ vi.mock('@tanstack/react-query', () => ({ invalidateQueries: (...args: unknown[]) => { invalidateQueriesMock(...args); }, + cancelQueries: (...args: unknown[]) => { + cancelQueriesMock(...args); + }, + getQueriesData: (...args: unknown[]) => getQueriesDataMock(...args), + setQueriesData: (...args: unknown[]) => setQueriesDataMock(...args), + setQueryData: (...args: unknown[]) => setQueryDataMock(...args), }), })); @@ -57,12 +89,18 @@ vi.mock('@/lib/trpc', () => ({ useTRPC: () => ({ githubPrReview: { listReviewThreads: { pathFilter: () => ['githubPrReview', 'listReviewThreads'] }, + addReaction: { mutationOptions: (opts: MutationOptions) => opts }, + removeReaction: { mutationOptions: (opts: MutationOptions) => opts }, }, }), trpcClient: { githubPrReview: { // eslint-disable-next-line typescript-eslint/promise-function-async -- conflicting require-await rule replyToComment: { mutate: (vars: unknown) => replyMutateMock(vars) }, + // eslint-disable-next-line typescript-eslint/promise-function-async -- conflicting require-await rule + resolveThread: { mutate: (vars: unknown) => resolveMutateMock(vars) }, + // eslint-disable-next-line typescript-eslint/promise-function-async -- conflicting require-await rule + unresolveThread: { mutate: (vars: unknown) => unresolveMutateMock(vars) }, }, }, })); @@ -83,7 +121,13 @@ describe('useReplyToCommentMutation (P1-A-08c wiring)', () => { beforeEach(() => { lastCapturedOptions = null; replyMutateMock.mockReset(); + resolveMutateMock.mockReset(); + unresolveMutateMock.mockReset(); invalidateQueriesMock.mockReset(); + cancelQueriesMock.mockReset(); + getQueriesDataMock.mockReset(); + setQueriesDataMock.mockReset(); + setQueryDataMock.mockReset(); toastErrorMock.mockReset(); hoistedKeys.getKey.mockClear(); hoistedKeys.rotateKey.mockClear(); @@ -197,3 +241,229 @@ describe('reply_comment fingerprint (P1-A-08c changed-input)', () => { expect(otherComment).not.toBe(original); }); }); + +describe('useResolveThreadMutation (generation guard + chainSave)', () => { + beforeEach(() => { + lastCapturedOptions = null; + resolveMutateMock.mockReset(); + cancelQueriesMock.mockReset(); + getQueriesDataMock.mockReset(); + setQueriesDataMock.mockReset(); + setQueryDataMock.mockReset(); + toastErrorMock.mockReset(); + }); + + it('wraps the tRPC call in chainSave keyed by threadId (rule 3) and adds no scope.id', async () => { + resolveMutateMock.mockResolvedValue({ threadId: 't1', isResolved: true }); + useResolveThreadMutation(); + const opts = lastCapturedOptions; + if (!opts) { + throw new Error('resolve options not captured'); + } + + await opts.mutationFn?.({ threadId: 't1' }); + expect(resolveMutateMock).toHaveBeenCalledWith({ threadId: 't1' }); + expect(opts.scope).toBeUndefined(); + }); + + it('serializes two resolves for the same thread (second mutationFn starts after the first settles)', async () => { + useResolveThreadMutation(); + const opts = lastCapturedOptions; + if (!opts) { + throw new Error('resolve options not captured'); + } + + const gate = Promise.withResolvers<{ threadId: string; isResolved: boolean }>(); + resolveMutateMock + .mockReturnValueOnce(gate.promise) + .mockResolvedValueOnce({ threadId: 't1', isResolved: true }); + + const first = opts.mutationFn?.({ threadId: 't1' }); + const second = opts.mutationFn?.({ threadId: 't1' }); + + await Promise.resolve(); + await Promise.resolve(); + expect(resolveMutateMock).toHaveBeenCalledTimes(1); + + gate.resolve({ threadId: 't1', isResolved: true }); + await Promise.all([first, second]); + expect(resolveMutateMock).toHaveBeenCalledTimes(2); + }); + + it('a failing older resolve does not roll back while a newer reaction owns the threads cache', async () => { + const resolveOpts = captureOptions(() => useResolveThreadMutation()); + const reactionOpts = captureOptions(() => useAddReactionMutation('t2')); + + getQueriesDataMock + .mockReturnValueOnce([['k1', { pages: [] }]]) + .mockReturnValueOnce([['k2', { pages: [] }]]); + + const older = await resolveOpts.onMutate?.({ threadId: 't1' }); + const newer = await reactionOpts.onMutate?.({ commentNodeId: 'c1', content: 'THUMBS_UP' }); + + setQueryDataMock.mockClear(); + resolveOpts.onError?.(new Error('boom'), { threadId: 't1' }, older); + // The older resolve's rollback must not restore its snapshot over the + // newer reaction's optimistic write to the same procedure-wide cache. + expect(setQueryDataMock).not.toHaveBeenCalled(); + expect(toastErrorMock).toHaveBeenCalledWith('boom'); + + reactionOpts.onError?.(new Error('boom'), { commentNodeId: 'c1', content: 'THUMBS_UP' }, newer); + expect(setQueryDataMock).toHaveBeenCalledTimes(1); + expect(toastErrorMock).toHaveBeenCalledWith('boom'); + }); + + it('a failing latest resolve rolls back its snapshot and toasts', async () => { + useResolveThreadMutation(); + const opts = lastCapturedOptions; + if (!opts) { + throw new Error('resolve options not captured'); + } + + getQueriesDataMock.mockReturnValueOnce([['k1', { pages: [] }]]); + const context = await opts.onMutate?.({ threadId: 't1' }); + + setQueryDataMock.mockClear(); + opts.onError?.(new Error('boom'), { threadId: 't1' }, context); + expect(setQueryDataMock).toHaveBeenCalledTimes(1); + expect(toastErrorMock).toHaveBeenCalledWith('boom'); + }); +}); + +describe('useUnresolveThreadMutation (generation guard + chainSave)', () => { + beforeEach(() => { + lastCapturedOptions = null; + unresolveMutateMock.mockReset(); + getQueriesDataMock.mockReset(); + setQueryDataMock.mockReset(); + toastErrorMock.mockReset(); + }); + + it('wraps the tRPC call in chainSave keyed by threadId and adds no scope.id', async () => { + unresolveMutateMock.mockResolvedValue({ threadId: 't1', isResolved: false }); + useUnresolveThreadMutation(); + const opts = lastCapturedOptions; + if (!opts) { + throw new Error('unresolve options not captured'); + } + + await opts.mutationFn?.({ threadId: 't1' }); + expect(unresolveMutateMock).toHaveBeenCalledWith({ threadId: 't1' }); + expect(opts.scope).toBeUndefined(); + }); + + it('a failing latest unresolve rolls back its snapshot and toasts', async () => { + useUnresolveThreadMutation(); + const opts = lastCapturedOptions; + if (!opts) { + throw new Error('unresolve options not captured'); + } + + getQueriesDataMock.mockReturnValueOnce([['k1', { pages: [] }]]); + const context = await opts.onMutate?.({ threadId: 't1' }); + + setQueryDataMock.mockClear(); + opts.onError?.(new Error('boom'), { threadId: 't1' }, context); + expect(setQueryDataMock).toHaveBeenCalledTimes(1); + expect(toastErrorMock).toHaveBeenCalledWith('boom'); + }); +}); + +describe('useAddReactionMutation (generation guard + scope.id)', () => { + beforeEach(() => { + lastCapturedOptions = null; + getQueriesDataMock.mockReset(); + setQueryDataMock.mockReset(); + toastErrorMock.mockReset(); + }); + + it('scopes the mutation per thread from the hook closure (rule 2)', () => { + useAddReactionMutation('t1'); + expect(lastCapturedOptions?.scope).toEqual({ id: 'pr-thread:t1' }); + }); + + it('a failing latest reaction rolls back its snapshot and toasts', async () => { + useAddReactionMutation('t1'); + const opts = lastCapturedOptions; + if (!opts) { + throw new Error('reaction options not captured'); + } + + getQueriesDataMock.mockReturnValueOnce([['k1', { pages: [] }]]); + const context = await opts.onMutate?.({ commentNodeId: 'c1', content: 'THUMBS_UP' }); + + setQueryDataMock.mockClear(); + opts.onError?.(new Error('boom'), { commentNodeId: 'c1', content: 'THUMBS_UP' }, context); + expect(setQueryDataMock).toHaveBeenCalledTimes(1); + expect(toastErrorMock).toHaveBeenCalledWith('boom'); + }); +}); + +describe('useRemoveReactionMutation (generation guard + scope.id)', () => { + beforeEach(() => { + lastCapturedOptions = null; + getQueriesDataMock.mockReset(); + setQueryDataMock.mockReset(); + toastErrorMock.mockReset(); + }); + + it('scopes the mutation per thread from the hook closure (rule 2)', () => { + useRemoveReactionMutation('t1'); + expect(lastCapturedOptions?.scope).toEqual({ id: 'pr-thread:t1' }); + }); + + it('a failing latest reaction removal rolls back its snapshot and toasts', async () => { + useRemoveReactionMutation('t1'); + const opts = lastCapturedOptions; + if (!opts) { + throw new Error('reaction options not captured'); + } + + getQueriesDataMock.mockReturnValueOnce([['k1', { pages: [] }]]); + const context = await opts.onMutate?.({ commentNodeId: 'c1', content: 'THUMBS_UP' }); + + setQueryDataMock.mockClear(); + opts.onError?.(new Error('boom'), { commentNodeId: 'c1', content: 'THUMBS_UP' }, context); + expect(setQueryDataMock).toHaveBeenCalledTimes(1); + expect(toastErrorMock).toHaveBeenCalledWith('boom'); + }); +}); + +describe('scope.id network serialization (real MutationCache)', () => { + it('starts the second same-scope mutationFn only after the first settles', async () => { + const { MutationCache, QueryClient } = + await vi.importActual('@tanstack/react-query'); + const cache = new MutationCache(); + const client = new QueryClient({ mutationCache: cache }); + const order: string[] = []; + const gate = Promise.withResolvers(); + + const first = cache.build(client, { + mutationFn: async () => { + order.push('first-start'); + await gate.promise; + order.push('first-end'); + return 'first'; + }, + scope: { id: 'pr-thread:t1' }, + }); + const second = cache.build(client, { + // eslint-disable-next-line require-await, typescript-eslint/require-await -- MutationFunction requires a Promise return + mutationFn: async () => { + order.push('second-start'); + return 'second'; + }, + scope: { id: 'pr-thread:t1' }, + }); + + const p1 = first.execute({}); + const p2 = second.execute({}); + await Promise.resolve(); + await Promise.resolve(); + expect(order).toEqual(['first-start']); + + gate.resolve(null); + await Promise.all([p1, p2]); + expect(order).toEqual(['first-start', 'first-end', 'second-start']); + }); +}); diff --git a/apps/mobile/src/lib/pr-review/discussion/use-review-discussion-mutations.ts b/apps/mobile/src/lib/pr-review/discussion/use-review-discussion-mutations.ts index d2662d9c80..647d88ccd5 100644 --- a/apps/mobile/src/lib/pr-review/discussion/use-review-discussion-mutations.ts +++ b/apps/mobile/src/lib/pr-review/discussion/use-review-discussion-mutations.ts @@ -38,6 +38,11 @@ import { toast } from 'sonner-native'; import { prIntentFingerprint } from '@kilocode/app-shared/pr-review'; +import { + isLatestMutationGeneration, + nextMutationGeneration, +} from '@/lib/hooks/mutation-generations'; +import { chainSave } from '@/lib/hooks/save-chain'; import { trpcClient, useTRPC } from '@/lib/trpc'; import { useHoistedOperationKey } from '@/lib/operation-key'; import { @@ -60,6 +65,11 @@ function useDiscussionKeys() { }; } +// Every discussion mutation snapshots the same procedure-wide +// `listReviewThreads` cache through its path filter, so one shared generation +// key guards all rollbacks across resolve/unresolve/reaction writes. +const LIST_REVIEW_THREADS_GENERATION_KEY = 'githubPrReview.listReviewThreads'; + async function invalidateDiscussionCaches( queryClient: ReturnType, keys: ReturnType @@ -110,71 +120,83 @@ export function useReplyToCommentMutation() { // ── Resolve / unresolve (optimistic) ────────────────────────────────── export function useResolveThreadMutation() { - const trpc = useTRPC(); const queryClient = useQueryClient(); const keys = useDiscussionKeys(); - return useMutation( - trpc.githubPrReview.resolveThread.mutationOptions({ + // onError policy: roll back the onMutate snapshot (latest generation only) + // and toast error.message. + return useMutation({ + mutationFn: (vars: { threadId: string }) => // eslint-disable-next-line typescript-eslint/promise-function-async -- conflicting require-await rule - onMutate: async ({ threadId }) => { - await queryClient.cancelQueries(keys.listReviewThreadsPath); - const previous = queryClient.getQueriesData( - keys.listReviewThreadsPath - ); - queryClient.setQueriesData(keys.listReviewThreadsPath, old => - applyResolveToggle(old, threadId, true) - ); - return { previous }; - }, - onError: (error, _input, context) => { - const previous = context?.previous; - if (previous) { - for (const [key, data] of previous) { - queryClient.setQueryData(key, data); - } + chainSave(`pr-thread:${vars.threadId}`, () => + trpcClient.githubPrReview.resolveThread.mutate(vars) + ), + onMutate: async ({ threadId }) => { + await queryClient.cancelQueries(keys.listReviewThreadsPath); + const generation = nextMutationGeneration(LIST_REVIEW_THREADS_GENERATION_KEY); + const previous = queryClient.getQueriesData( + keys.listReviewThreadsPath + ); + queryClient.setQueriesData(keys.listReviewThreadsPath, old => + applyResolveToggle(old, threadId, true) + ); + return { previous, generation }; + }, + onError: (error, _input, context) => { + if ( + context?.previous && + isLatestMutationGeneration(LIST_REVIEW_THREADS_GENERATION_KEY, context.generation) + ) { + for (const [key, data] of context.previous) { + queryClient.setQueryData(key, data); } - toast.error(error.message); - }, - onSettled: async () => { - await invalidateDiscussionCaches(queryClient, keys); - }, - }) - ); + } + toast.error(error.message); + }, + onSettled: async () => { + await invalidateDiscussionCaches(queryClient, keys); + }, + }); } export function useUnresolveThreadMutation() { - const trpc = useTRPC(); const queryClient = useQueryClient(); const keys = useDiscussionKeys(); - return useMutation( - trpc.githubPrReview.unresolveThread.mutationOptions({ + // onError policy: roll back the onMutate snapshot (latest generation only) + // and toast error.message. + return useMutation({ + mutationFn: (vars: { threadId: string }) => // eslint-disable-next-line typescript-eslint/promise-function-async -- conflicting require-await rule - onMutate: async ({ threadId }) => { - await queryClient.cancelQueries(keys.listReviewThreadsPath); - const previous = queryClient.getQueriesData( - keys.listReviewThreadsPath - ); - queryClient.setQueriesData(keys.listReviewThreadsPath, old => - applyResolveToggle(old, threadId, false) - ); - return { previous }; - }, - onError: (error, _input, context) => { - const previous = context?.previous; - if (previous) { - for (const [key, data] of previous) { - queryClient.setQueryData(key, data); - } + chainSave(`pr-thread:${vars.threadId}`, () => + trpcClient.githubPrReview.unresolveThread.mutate(vars) + ), + onMutate: async ({ threadId }) => { + await queryClient.cancelQueries(keys.listReviewThreadsPath); + const generation = nextMutationGeneration(LIST_REVIEW_THREADS_GENERATION_KEY); + const previous = queryClient.getQueriesData( + keys.listReviewThreadsPath + ); + queryClient.setQueriesData(keys.listReviewThreadsPath, old => + applyResolveToggle(old, threadId, false) + ); + return { previous, generation }; + }, + onError: (error, _input, context) => { + if ( + context?.previous && + isLatestMutationGeneration(LIST_REVIEW_THREADS_GENERATION_KEY, context.generation) + ) { + for (const [key, data] of context.previous) { + queryClient.setQueryData(key, data); } - toast.error(error.message); - }, - onSettled: async () => { - await invalidateDiscussionCaches(queryClient, keys); - }, - }) - ); + } + toast.error(error.message); + }, + onSettled: async () => { + await invalidateDiscussionCaches(queryClient, keys); + }, + }); } // ── Reactions (optimistic) ──────────────────────────────────────────── @@ -188,11 +210,14 @@ export function useAddReactionMutation(threadId: string) { const queryClient = useQueryClient(); const keys = useDiscussionKeys(); + // onError policy: roll back the onMutate snapshot (latest generation only) + // and toast error.message. return useMutation( trpc.githubPrReview.addReaction.mutationOptions({ // eslint-disable-next-line typescript-eslint/promise-function-async -- conflicting require-await rule onMutate: async ({ commentNodeId, content }) => { await queryClient.cancelQueries(keys.listReviewThreadsPath); + const generation = nextMutationGeneration(LIST_REVIEW_THREADS_GENERATION_KEY); const previous = queryClient.getQueriesData( keys.listReviewThreadsPath ); @@ -204,12 +229,14 @@ export function useAddReactionMutation(threadId: string) { content: content as ReviewReactionContent, }) ); - return { previous }; + return { previous, generation }; }, onError: (error, _input, context) => { - const previous = context?.previous; - if (previous) { - for (const [key, data] of previous) { + if ( + context?.previous && + isLatestMutationGeneration(LIST_REVIEW_THREADS_GENERATION_KEY, context.generation) + ) { + for (const [key, data] of context.previous) { queryClient.setQueryData(key, data); } } @@ -218,6 +245,10 @@ export function useAddReactionMutation(threadId: string) { onSettled: async () => { await invalidateDiscussionCaches(queryClient, keys); }, + // The reaction DTO carries only {commentNodeId, content}; the owning + // threadId comes from the hook closure, so scope.id serializes network + // calls per thread (rule 2). + scope: { id: `pr-thread:${threadId}` }, }) ); } @@ -227,11 +258,14 @@ export function useRemoveReactionMutation(threadId: string) { const queryClient = useQueryClient(); const keys = useDiscussionKeys(); + // onError policy: roll back the onMutate snapshot (latest generation only) + // and toast error.message. return useMutation( trpc.githubPrReview.removeReaction.mutationOptions({ // eslint-disable-next-line typescript-eslint/promise-function-async -- conflicting require-await rule onMutate: async ({ commentNodeId, content }) => { await queryClient.cancelQueries(keys.listReviewThreadsPath); + const generation = nextMutationGeneration(LIST_REVIEW_THREADS_GENERATION_KEY); const previous = queryClient.getQueriesData( keys.listReviewThreadsPath ); @@ -243,12 +277,14 @@ export function useRemoveReactionMutation(threadId: string) { content: content as ReviewReactionContent, }) ); - return { previous }; + return { previous, generation }; }, onError: (error, _input, context) => { - const previous = context?.previous; - if (previous) { - for (const [key, data] of previous) { + if ( + context?.previous && + isLatestMutationGeneration(LIST_REVIEW_THREADS_GENERATION_KEY, context.generation) + ) { + for (const [key, data] of context.previous) { queryClient.setQueryData(key, data); } } @@ -257,6 +293,10 @@ export function useRemoveReactionMutation(threadId: string) { onSettled: async () => { await invalidateDiscussionCaches(queryClient, keys); }, + // The reaction DTO carries only {commentNodeId, content}; the owning + // threadId comes from the hook closure, so scope.id serializes network + // calls per thread (rule 2). + scope: { id: `pr-thread:${threadId}` }, }) ); } diff --git a/apps/mobile/src/lib/query-client-lifecycle.test.ts b/apps/mobile/src/lib/query-client-lifecycle.test.ts index 0d445120d6..33bc7fdadb 100644 --- a/apps/mobile/src/lib/query-client-lifecycle.test.ts +++ b/apps/mobile/src/lib/query-client-lifecycle.test.ts @@ -122,4 +122,31 @@ describe('installQueryClientNativeLifecycle', () => { expect(setOnline).toHaveBeenNthCalledWith(3, false); expect(setOnline).toHaveBeenNthCalledWith(4, true); }); + + it('flapping unknown → offline → online → offline is online only on the confirmed online state', () => { + const native = createSources(); + const setFocused = createBooleanSetterMock(); + const setOnline = createBooleanSetterMock(); + + const cleanup = installQueryClientNativeLifecycle({ + sources: native.sources, + managers: { + focus: { setFocused }, + online: { setOnline }, + }, + }); + + native.setConnectivity({ isConnected: null, isInternetReachable: null }); + native.setConnectivity({ isConnected: false, isInternetReachable: false }); + native.setConnectivity({ isConnected: true, isInternetReachable: true }); + native.setConnectivity({ isConnected: false, isInternetReachable: false }); + cleanup(); + + // Only the confirmed online state resumes React Query; unknown and every + // offline state pause it, so a paused query never surfaces an error state. + expect(setOnline).toHaveBeenNthCalledWith(1, false); + expect(setOnline).toHaveBeenNthCalledWith(2, false); + expect(setOnline).toHaveBeenNthCalledWith(3, true); + expect(setOnline).toHaveBeenNthCalledWith(4, false); + }); }); diff --git a/apps/mobile/src/lib/query-client.ts b/apps/mobile/src/lib/query-client.ts index 5a861b4b57..a90b4b8305 100644 --- a/apps/mobile/src/lib/query-client.ts +++ b/apps/mobile/src/lib/query-client.ts @@ -98,6 +98,8 @@ export function createKiloAppQueryClient(): QueryClient { const queryClient = new QueryClient({ defaultOptions: { queries: { + // Foreground freshness is owned per route by useRouteForegroundRefresh mounts; the blanket focusManager refetch also woke frozen background tabs. + refetchOnWindowFocus: false, retry: (failureCount, error) => { const code = (error as { data?: { code?: string } } | null)?.data?.code; if (code !== undefined && PERMANENT_CODES.has(code)) { diff --git a/packages/cloud-agent-sdk/src/base-connection.test.ts b/packages/cloud-agent-sdk/src/base-connection.test.ts index 24aa92c109..8673553d46 100644 --- a/packages/cloud-agent-sdk/src/base-connection.test.ts +++ b/packages/cloud-agent-sdk/src/base-connection.test.ts @@ -503,6 +503,130 @@ describe('createBaseConnection – stale WebSocket recovery', () => { }); }); + describe('reconnect exhaustion signal', () => { + function exhaustWithCap(onReconnectExhaustionChange: jest.Mock) { + jest.spyOn(Math, 'random').mockReturnValue(0); + const { connection } = createTestConnection({ + maxReconnectAttempts: 2, + onReconnectExhaustionChange, + }); + connection.connect(); + + closeSocket(0); + jest.advanceTimersByTime(60_000); + closeSocket(1); + jest.advanceTimersByTime(60_000); + closeSocket(2); + + return connection; + } + + it('fires true exactly once at the cap', () => { + const onReconnectExhaustionChange = jest.fn(); + const connection = exhaustWithCap(onReconnectExhaustionChange); + + expect(onReconnectExhaustionChange).toHaveBeenCalledTimes(1); + expect(onReconnectExhaustionChange).toHaveBeenCalledWith(true); + + connection.destroy(); + }); + + it('retryReconnect fires false and reconnects', () => { + const onReconnectExhaustionChange = jest.fn(); + const connection = exhaustWithCap(onReconnectExhaustionChange); + const socketsAfterExhaustion = sockets.length; + + connection.retryReconnect(); + + expect(onReconnectExhaustionChange).toHaveBeenLastCalledWith(false); + expect(sockets.length).toBe(socketsAfterExhaustion + 1); + + connection.destroy(); + }); + + it('online event after exhaustion fires false and reconnects', () => { + const onReconnectExhaustionChange = jest.fn(); + const connection = exhaustWithCap(onReconnectExhaustionChange); + const socketsAfterExhaustion = sockets.length; + + sockets[sockets.length - 1].readyState = 3; // WebSocket.CLOSED + mockWindow.dispatchEvent(new Event('online')); + + expect(onReconnectExhaustionChange).toHaveBeenLastCalledWith(false); + expect(sockets.length).toBe(socketsAfterExhaustion + 1); + + connection.destroy(); + }); + + it('successful message after exhaustion fires false', () => { + const onReconnectExhaustionChange = jest.fn(); + const connection = exhaustWithCap(onReconnectExhaustionChange); + + connectSocket(2); + + expect(onReconnectExhaustionChange).toHaveBeenLastCalledWith(false); + + connection.destroy(); + }); + + it('connect() while exhausted fires false', () => { + const onReconnectExhaustionChange = jest.fn(); + const connection = exhaustWithCap(onReconnectExhaustionChange); + + connection.connect(); + + expect(onReconnectExhaustionChange).toHaveBeenLastCalledWith(false); + + connection.destroy(); + }); + + it('reaches the cap for a live session whose reconnects all go through refreshAndConnect', async () => { + const refreshAuth = jest.fn(() => Promise.resolve()); + const onReconnectExhaustionChange = jest.fn(); + const { connection } = createTestConnection({ + refreshAuth, + shouldRefreshAuthBeforeConnect: () => true, + maxReconnectAttempts: 2, + onReconnectExhaustionChange, + }); + + connection.connect(); + await Promise.resolve(); + await Promise.resolve(); + connectSocket(0); + + // Live session loses its server. Every reconnect now goes through + // refreshAndConnect because shouldRefreshAuthBeforeConnect is true. + closeSocket(0); + jest.advanceTimersByTime(60_000); + await Promise.resolve(); + await Promise.resolve(); + + closeSocket(1); + jest.advanceTimersByTime(60_000); + await Promise.resolve(); + await Promise.resolve(); + + closeSocket(2); + + expect(onReconnectExhaustionChange).toHaveBeenCalledTimes(1); + expect(onReconnectExhaustionChange).toHaveBeenCalledWith(true); + + // The attempt counter now accumulates through refreshAndConnect, so the + // socket count grows past the cap attempt (2). + expect(sockets.length).toBeGreaterThan(2); + + // A recovery path resets the counter and fires false. + connection.retryReconnect(); + await Promise.resolve(); + await Promise.resolve(); + + expect(onReconnectExhaustionChange).toHaveBeenLastCalledWith(false); + + connection.destroy(); + }); + }); + describe('onReconnected vs onConnected', () => { it('fires onConnected on first successful connection', () => { const { connection, onConnected, onReconnected } = createTestConnection(); @@ -564,9 +688,11 @@ describe('createBaseConnection – stale WebSocket recovery', () => { it('notifies route loss when the refreshed socket also closes for auth failure', async () => { const refreshAuth = jest.fn(() => Promise.resolve()); const onReplacingConnection = jest.fn(); + const onReconnectExhaustionChange = jest.fn(); const { connection } = createTestConnection({ refreshAuth, onReplacingConnection, + onReconnectExhaustionChange, isAuthFailure: event => event.code === 4001 || event.code === 1008, }); connection.connect(); @@ -580,6 +706,8 @@ describe('createBaseConnection – stale WebSocket recovery', () => { expect(onReplacingConnection).toHaveBeenCalledTimes(2); expect(refreshAuth).toHaveBeenCalledTimes(1); + expect(onReconnectExhaustionChange).toHaveBeenCalledTimes(1); + expect(onReconnectExhaustionChange).toHaveBeenCalledWith(true); jest.advanceTimersByTime(60_000); expect(sockets).toHaveLength(2); connection.destroy(); diff --git a/packages/cloud-agent-sdk/src/base-connection.ts b/packages/cloud-agent-sdk/src/base-connection.ts index ccd7e77ce8..ee689ae262 100644 --- a/packages/cloud-agent-sdk/src/base-connection.ts +++ b/packages/cloud-agent-sdk/src/base-connection.ts @@ -43,12 +43,20 @@ export type BaseConnectionConfig = { * For browser usage, use `createBrowserLifecycleHooks()`. * For CLI usage, omit this or provide custom hooks. */ lifecycleHooks?: ConnectionLifecycleHooks | undefined; + /** Cap on automatic reconnect attempts before the connection is considered + * exhausted. Defaults to `MAX_RECONNECT_ATTEMPTS`. */ + maxReconnectAttempts?: number | undefined; + /** Fires on both edges of the reconnect-exhaustion state: `true` when the + * automatic retry cap is reached (or a terminal auth failure stops retries), + * and `false` when any recovery path resets the state. */ + onReconnectExhaustionChange?: ((exhausted: boolean) => void) | undefined; }; export type Connection = { connect: () => void; disconnect: () => void; reconnectWithRefreshedAuth?: () => void; + retryReconnect: () => void; destroy: () => void; }; @@ -72,6 +80,7 @@ export function createBaseConnection(config: BaseConnectionConfig): Connec let authRefreshAttempted = false; let connected = false; let reconnectAttempt = 0; + let exhausted = false; let generation = 0; let hasConnectedOnce = false; let stalenessTimeoutId: ReturnType | null = null; @@ -79,6 +88,7 @@ export function createBaseConnection(config: BaseConnectionConfig): Connec let hiddenAt = 0; let preconnectAuthRefreshAttempted = false; const stalenessTimeoutMs = config.stalenessTimeoutMs ?? DEFAULT_STALENESS_TIMEOUT_MS; + const maxReconnectAttempts = config.maxReconnectAttempts ?? MAX_RECONNECT_ATTEMPTS; // Cleanup functions returned by lifecycle hooks const cleanupFns: Array<() => void> = []; @@ -98,6 +108,13 @@ export function createBaseConnection(config: BaseConnectionConfig): Connec } } + function clearExhausted(): void { + if (exhausted) { + exhausted = false; + config.onReconnectExhaustionChange?.(false); + } + } + function notifyReplacingConnection(expectedGeneration = generation): boolean { config.onReplacingConnection?.(); return !destroyed && !intentionalDisconnect && expectedGeneration === generation; @@ -129,7 +146,7 @@ export function createBaseConnection(config: BaseConnectionConfig): Connec } } - async function refreshAndConnect(expectedGeneration: number): Promise { + async function refreshAndConnect(expectedGeneration: number, attempt = 0): Promise { preconnectAuthRefreshAttempted = true; try { @@ -149,7 +166,7 @@ export function createBaseConnection(config: BaseConnectionConfig): Connec // refresh resolves. if (connected && ws !== null && ws.readyState === WebSocket.OPEN) return; } - connectInternal(0, expectedGeneration, true); + connectInternal(attempt, expectedGeneration, true); } finally { if (expectedGeneration === generation) { preconnectAuthRefreshAttempted = false; @@ -160,7 +177,11 @@ export function createBaseConnection(config: BaseConnectionConfig): Connec function scheduleReconnect(attempt: number, expectedGeneration: number) { if (destroyed || intentionalDisconnect || expectedGeneration !== generation) return; - if (attempt >= MAX_RECONNECT_ATTEMPTS) { + if (attempt >= maxReconnectAttempts) { + if (!exhausted) { + exhausted = true; + config.onReconnectExhaustionChange?.(true); + } return; } @@ -184,7 +205,7 @@ export function createBaseConnection(config: BaseConnectionConfig): Connec !skipAuthRefresh && !preconnectAuthRefreshAttempted ) { - void refreshAndConnect(expectedGeneration); + void refreshAndConnect(expectedGeneration, attempt); return; } @@ -234,6 +255,7 @@ export function createBaseConnection(config: BaseConnectionConfig): Connec // Reset auth refresh flag on successful message authRefreshAttempted = false; reconnectAttempt = 0; + clearExhausted(); if (!connected) { connected = true; @@ -285,6 +307,10 @@ export function createBaseConnection(config: BaseConnectionConfig): Connec // The current physical route is gone even though no new socket follows. if (isAuthFailure && authRefreshAttempted) { notifyReplacingConnection(expectedGeneration); + if (!exhausted) { + exhausted = true; + config.onReconnectExhaustionChange?.(true); + } return; } @@ -319,6 +345,7 @@ export function createBaseConnection(config: BaseConnectionConfig): Connec // Tab became visible reconnectAttempt = 0; + clearExhausted(); const wasHiddenSince = hiddenAt; hiddenAt = 0; @@ -365,6 +392,7 @@ export function createBaseConnection(config: BaseConnectionConfig): Connec // BFCache restore - WebSocket is guaranteed dead reconnectAttempt = 0; + clearExhausted(); clearReconnectTimer(); clearStalenessTimeout(); if (!notifyReplacingConnection()) return; @@ -388,6 +416,7 @@ export function createBaseConnection(config: BaseConnectionConfig): Connec if (connected && ws !== null && ws.readyState === WebSocket.OPEN) return; reconnectAttempt = 0; + clearExhausted(); clearReconnectTimer(); void refreshAndConnect(generation); } @@ -423,6 +452,7 @@ export function createBaseConnection(config: BaseConnectionConfig): Connec preconnectAuthRefreshAttempted = false; connected = false; reconnectAttempt = 0; + clearExhausted(); hasConnectedOnce = false; lastMessageTime = 0; generation += 1; @@ -456,6 +486,7 @@ export function createBaseConnection(config: BaseConnectionConfig): Connec if (destroyed || intentionalDisconnect) return; reconnectAttempt = 0; + clearExhausted(); clearReconnectTimer(); clearStalenessTimeout(); if (!notifyReplacingConnection()) return; @@ -472,6 +503,15 @@ export function createBaseConnection(config: BaseConnectionConfig): Connec void refreshAndConnect(generation); } + function retryReconnect() { + if (destroyed || intentionalDisconnect) return; + + clearReconnectTimer(); + reconnectAttempt = 0; + clearExhausted(); + void refreshAndConnect(generation); + } + function destroy() { destroyed = true; generation += 1; @@ -490,7 +530,7 @@ export function createBaseConnection(config: BaseConnectionConfig): Connec connected = false; } - return { connect, disconnect, reconnectWithRefreshedAuth, destroy }; + return { connect, disconnect, reconnectWithRefreshedAuth, retryReconnect, destroy }; } export function createBrowserLifecycleHooks(): ConnectionLifecycleHooks { diff --git a/packages/cloud-agent-sdk/src/session-routing.test.ts b/packages/cloud-agent-sdk/src/session-routing.test.ts index 64e035f1c0..5c52370741 100644 --- a/packages/cloud-agent-sdk/src/session-routing.test.ts +++ b/packages/cloud-agent-sdk/src/session-routing.test.ts @@ -129,6 +129,9 @@ describe('session transport routing', () => { destroy: jest.fn(), isConnected: jest.fn(() => false), onConnectionChange: jest.fn(() => jest.fn()), + isReconnectExhausted: jest.fn(() => false), + onReconnectExhaustionChange: jest.fn(() => jest.fn()), + retryConnection: jest.fn(), subscribeToCliSession: jest.fn(() => release), sendCommand: jest.fn(() => Promise.resolve()), sendCommandToConnection: jest.fn(() => Promise.resolve()), @@ -219,6 +222,9 @@ describe('session transport routing', () => { destroy: jest.fn(), isConnected: jest.fn(() => false), onConnectionChange: jest.fn(() => jest.fn()), + isReconnectExhausted: jest.fn(() => false), + onReconnectExhaustionChange: jest.fn(() => jest.fn()), + retryConnection: jest.fn(), subscribeToCliSession: jest.fn(() => subscribeRelease), sendCommand: jest.fn(() => Promise.resolve()), sendCommandToConnection: jest.fn(() => Promise.resolve()), diff --git a/packages/cloud-agent-sdk/src/session-transport.test.ts b/packages/cloud-agent-sdk/src/session-transport.test.ts index 936f1838a2..7436c3951f 100644 --- a/packages/cloud-agent-sdk/src/session-transport.test.ts +++ b/packages/cloud-agent-sdk/src/session-transport.test.ts @@ -100,6 +100,9 @@ function createUserWebConnection() { destroy: jest.fn(), isConnected: jest.fn(() => false), onConnectionChange: jest.fn(() => jest.fn()), + isReconnectExhausted: jest.fn(() => false), + onReconnectExhaustionChange: jest.fn(() => jest.fn()), + retryConnection: jest.fn(), subscribeToCliSession: jest.fn(() => jest.fn()), sendCommand: jest.fn((_sessionId: string, command: string) => Promise.resolve( diff --git a/packages/cloud-agent-sdk/src/session.test.ts b/packages/cloud-agent-sdk/src/session.test.ts index 0d7b8686f3..615ba000c8 100644 --- a/packages/cloud-agent-sdk/src/session.test.ts +++ b/packages/cloud-agent-sdk/src/session.test.ts @@ -375,6 +375,9 @@ describe('remote session transport state', () => { destroy: jest.fn(), isConnected: jest.fn(() => false), onConnectionChange: jest.fn(() => jest.fn()), + isReconnectExhausted: jest.fn(() => false), + onReconnectExhaustionChange: jest.fn(() => jest.fn()), + retryConnection: jest.fn(), subscribeToCliSession: jest.fn(() => jest.fn()), sendCommand: jest.fn(() => Promise.resolve({ @@ -448,6 +451,9 @@ describe('remote session create and retry commands', () => { destroy: jest.fn(), isConnected: jest.fn(() => false), onConnectionChange: jest.fn(() => jest.fn()), + isReconnectExhausted: jest.fn(() => false), + onReconnectExhaustionChange: jest.fn(() => jest.fn()), + retryConnection: jest.fn(), subscribeToCliSession: jest.fn(() => jest.fn()), sendCommand: jest.fn, [string, string, unknown, string?]>(() => Promise.resolve({ diff --git a/packages/cloud-agent-sdk/src/user-web-connection.test.ts b/packages/cloud-agent-sdk/src/user-web-connection.test.ts index 2f07ce7f74..4cd1b84ea2 100644 --- a/packages/cloud-agent-sdk/src/user-web-connection.test.ts +++ b/packages/cloud-agent-sdk/src/user-web-connection.test.ts @@ -1927,3 +1927,71 @@ describe('createUserWebConnection connection-state API', () => { client.destroy(); }); }); + +describe('createUserWebConnection reconnect-exhaustion API', () => { + it('reports false initially and subscribes/unsubscribes listeners', () => { + const client = createUserWebConnection({ websocketUrl: WS_URL, getAuthToken: () => 'token' }); + const listener = jest.fn(); + const unsubscribe = client.onReconnectExhaustionChange(listener); + + expect(client.isReconnectExhausted()).toBe(false); + expect(listener).not.toHaveBeenCalled(); + + unsubscribe(); + client.destroy(); + }); + + it('fires the listener on both edges and retryConnection resets the snapshot', async () => { + jest.useFakeTimers(); + jest.spyOn(Math, 'random').mockReturnValue(0); + try { + const listener = jest.fn(); + const client = createUserWebConnection({ + websocketUrl: WS_URL, + getAuthToken: () => 'token', + maxReconnectAttempts: 2, + }); + client.onReconnectExhaustionChange(listener); + const release = client.retain(); + + // Drive the base connection to exhaustion without ever opening a socket + // (no successful message), so `hasEverOpened` stays false and reconnects + // skip the auth refresh. + sockets[0].onclose?.({ code: 1006 } as CloseEvent); + jest.advanceTimersByTime(60_000); + sockets[1].onclose?.({ code: 1006 } as CloseEvent); + jest.advanceTimersByTime(60_000); + sockets[2].onclose?.({ code: 1006 } as CloseEvent); + + expect(listener).toHaveBeenCalledTimes(1); + expect(listener).toHaveBeenCalledWith(true); + expect(client.isReconnectExhausted()).toBe(true); + + client.retryConnection(); + await Promise.resolve(); + await Promise.resolve(); + + expect(listener).toHaveBeenCalledTimes(2); + expect(listener).toHaveBeenLastCalledWith(false); + expect(client.isReconnectExhausted()).toBe(false); + expect(sockets).toHaveLength(4); + + release(); + client.destroy(); + } finally { + jest.useRealTimers(); + } + }); + + it('ignores exhaustion listeners registered after destroy()', () => { + const client = createUserWebConnection({ websocketUrl: WS_URL, getAuthToken: () => 'token' }); + client.destroy(); + const listener = jest.fn(); + + const unsubscribe = client.onReconnectExhaustionChange(listener); + unsubscribe(); + + expect(listener).not.toHaveBeenCalled(); + expect(client.isReconnectExhausted()).toBe(false); + }); +}); diff --git a/packages/cloud-agent-sdk/src/user-web-connection.ts b/packages/cloud-agent-sdk/src/user-web-connection.ts index c0a429afd9..b8c41aa7c9 100644 --- a/packages/cloud-agent-sdk/src/user-web-connection.ts +++ b/packages/cloud-agent-sdk/src/user-web-connection.ts @@ -60,6 +60,7 @@ type UserWebConnectionConfig = { onError?: (message: string) => void; onReconnect?: () => void; lifecycleHooks?: ConnectionLifecycleHooks; + maxReconnectAttempts?: number; }; type SendCommandToConnectionInput = { @@ -91,6 +92,10 @@ type UserWebConnection = { * never invoked and the unsubscribe is a no-op. */ onConnectionChange: (listener: (connected: boolean) => void) => () => void; + // The boolean readiness API (old form) stays the readiness source for existing consumers; the exhaustion signal is additive and owns only recovery UI. Removal condition: none — the boolean API is permanent. + isReconnectExhausted: () => boolean; + onReconnectExhaustionChange: (listener: (exhausted: boolean) => void) => () => void; + retryConnection: () => void; subscribeToCliSession: (sessionId: string) => () => void; sendCommand: ( sessionId: string, @@ -193,6 +198,19 @@ function createUserWebConnection( for (const listener of connectionChangeListeners) listener(value); } + // Wrapper-owned reconnect-exhaustion snapshot, mirroring `connected`. The base + // connection fires the two-edge callback; the wrapper keeps a synchronous + // snapshot so `isReconnectExhausted()` reads it without crossing the base + // boundary and listeners fire only on value changes. + let exhausted = false; + const exhaustionChangeListeners = new Set<(exhausted: boolean) => void>(); + + function setExhausted(value: boolean): void { + if (exhausted === value) return; + exhausted = value; + for (const listener of exhaustionChangeListeners) listener(value); + } + function hasLifetime(): boolean { return retainCount > 0; } @@ -431,6 +449,8 @@ function createUserWebConnection( hasEverOpened = false; baseConnection = createBaseConnection({ lifecycleHooks: createLifecycleHooks(), + maxReconnectAttempts: config.maxReconnectAttempts, + onReconnectExhaustionChange: setExhausted, buildUrl, parseMessage: (data: unknown) => { if (typeof data !== 'string') return null; @@ -541,6 +561,7 @@ function createUserWebConnection( // callback, so the wrapper must drive the disconnected transition itself // before the next `startConnection()` reopens from a false baseline. setConnected(false); + setExhausted(false); } function connect(): void { @@ -646,6 +667,7 @@ function createUserWebConnection( reconnectListeners.clear(); sessionListeners.clear(); connectionChangeListeners.clear(); + exhaustionChangeListeners.clear(); }, isConnected: () => connected, onConnectionChange(listener) { @@ -655,6 +677,17 @@ function createUserWebConnection( connectionChangeListeners.delete(listener); }; }, + isReconnectExhausted: () => exhausted, + onReconnectExhaustionChange(listener) { + if (destroyed) return () => {}; + exhaustionChangeListeners.add(listener); + return () => { + exhaustionChangeListeners.delete(listener); + }; + }, + retryConnection() { + baseConnection?.retryReconnect(); + }, subscribeToCliSession(sessionId) { if (destroyed) return () => {}; const releaseConnection = retainConnection();