| /** | |
| * React Query Client Configuration | |
| * | |
| * Central configuration for TanStack React Query. | |
| * Provides default options for queries and mutations including | |
| * caching, retries, and error handling. | |
| * | |
| * Mobile-aware: Automatically extends stale times and garbage collection | |
| * on mobile devices to reduce unnecessary refetching, which causes | |
| * blank screens, reloads, and battery drain on flaky mobile connections. | |
| */ | |
| import { QueryClient, keepPreviousData } from '@tanstack/react-query'; | |
| import { toast } from 'sonner'; | |
| import { createLogger } from '@automaker/utils/logger'; | |
| import { isConnectionError, handleServerOffline } from './http-api-client'; | |
| import { isMobileDevice } from './mobile-detect'; | |
| const logger = createLogger('QueryClient'); | |
| /** | |
| * Mobile multiplier for stale times. | |
| * On mobile, data stays "fresh" longer to avoid refetching on every | |
| * component mount, which causes blank flickers and layout shifts. | |
| * The WebSocket invalidation system still ensures critical updates | |
| * (feature status changes, agent events) arrive in real-time. | |
| */ | |
| const MOBILE_STALE_MULTIPLIER = isMobileDevice ? 3 : 1; | |
| /** | |
| * Default stale times for different data types. | |
| * On mobile, these are multiplied by MOBILE_STALE_MULTIPLIER to reduce | |
| * unnecessary network requests while WebSocket handles real-time updates. | |
| */ | |
| export const STALE_TIMES = { | |
| /** Features change frequently during auto-mode */ | |
| FEATURES: 60 * 1000 * MOBILE_STALE_MULTIPLIER, // 1 min (3 min on mobile) | |
| /** GitHub data is relatively stable */ | |
| GITHUB: 2 * 60 * 1000 * MOBILE_STALE_MULTIPLIER, // 2 min (6 min on mobile) | |
| /** Running agents state changes very frequently */ | |
| RUNNING_AGENTS: 5 * 1000 * MOBILE_STALE_MULTIPLIER, // 5s (15s on mobile) | |
| /** Agent output changes during streaming */ | |
| AGENT_OUTPUT: 5 * 1000 * MOBILE_STALE_MULTIPLIER, // 5s (15s on mobile) | |
| /** Usage data with polling */ | |
| USAGE: 30 * 1000 * MOBILE_STALE_MULTIPLIER, // 30s (90s on mobile) | |
| /** Models rarely change */ | |
| MODELS: 5 * 60 * 1000 * MOBILE_STALE_MULTIPLIER, // 5 min (15 min on mobile) | |
| /** CLI status rarely changes */ | |
| CLI_STATUS: 5 * 60 * 1000 * MOBILE_STALE_MULTIPLIER, // 5 min (15 min on mobile) | |
| /** Settings are relatively stable */ | |
| SETTINGS: 2 * 60 * 1000 * MOBILE_STALE_MULTIPLIER, // 2 min (6 min on mobile) | |
| /** Worktrees change during feature development */ | |
| WORKTREES: 30 * 1000 * MOBILE_STALE_MULTIPLIER, // 30s (90s on mobile) | |
| /** Sessions rarely change */ | |
| SESSIONS: 2 * 60 * 1000 * MOBILE_STALE_MULTIPLIER, // 2 min (6 min on mobile) | |
| /** Default for unspecified queries */ | |
| DEFAULT: 30 * 1000 * MOBILE_STALE_MULTIPLIER, // 30s (90s on mobile) | |
| } as const; | |
| /** | |
| * Default garbage collection times (gcTime, formerly cacheTime). | |
| * On mobile, cache is kept longer so data persists across navigations | |
| * and component unmounts, preventing blank screens on re-mount. | |
| */ | |
| export const GC_TIMES = { | |
| /** Default garbage collection time - must exceed persist maxAge for cache to survive tab discard */ | |
| DEFAULT: isMobileDevice ? 15 * 60 * 1000 : 10 * 60 * 1000, // 15 min on mobile, 10 min desktop | |
| /** Extended for expensive queries */ | |
| EXTENDED: isMobileDevice ? 30 * 60 * 1000 : 15 * 60 * 1000, // 30 min on mobile, 15 min desktop | |
| } as const; | |
| /** | |
| * Global error handler for queries | |
| */ | |
| const handleQueryError = (error: Error) => { | |
| logger.error('Query error:', error); | |
| // Check for connection errors (server offline) | |
| if (isConnectionError(error)) { | |
| handleServerOffline(); | |
| return; | |
| } | |
| // Don't toast for auth errors - those are handled by http-api-client | |
| if (error.message === 'Unauthorized') { | |
| return; | |
| } | |
| }; | |
| /** | |
| * Global error handler for mutations | |
| */ | |
| const handleMutationError = (error: Error) => { | |
| logger.error('Mutation error:', error); | |
| // Check for connection errors | |
| if (isConnectionError(error)) { | |
| handleServerOffline(); | |
| return; | |
| } | |
| // Don't toast for auth errors | |
| if (error.message === 'Unauthorized') { | |
| return; | |
| } | |
| // Show error toast for other errors | |
| toast.error('Operation failed', { | |
| description: error.message || 'An unexpected error occurred', | |
| }); | |
| }; | |
| /** | |
| * Create and configure the QueryClient singleton. | |
| * | |
| * Mobile optimizations: | |
| * - refetchOnWindowFocus disabled on mobile (prevents refetch storms when | |
| * switching apps, which causes the blank screen + reload cycle) | |
| * - refetchOnMount uses 'always' on desktop but only refetches stale data | |
| * on mobile (prevents unnecessary network requests on navigation) | |
| * - Longer stale times and GC times via STALE_TIMES and GC_TIMES above | |
| * - structuralSharing enabled to minimize re-renders when data hasn't changed | |
| */ | |
| export const queryClient = new QueryClient({ | |
| defaultOptions: { | |
| queries: { | |
| staleTime: STALE_TIMES.DEFAULT, | |
| gcTime: GC_TIMES.DEFAULT, | |
| retry: (failureCount, error) => { | |
| // Don't retry on auth errors | |
| if (error instanceof Error && error.message === 'Unauthorized') { | |
| return false; | |
| } | |
| // Retry connection errors a few times before declaring server offline. | |
| // This handles transient network blips without immediately redirecting to login. | |
| if (isConnectionError(error)) { | |
| return failureCount < 3; | |
| } | |
| // Retry up to 2 times for other errors (3 on mobile for flaky connections) | |
| return failureCount < (isMobileDevice ? 3 : 2); | |
| }, | |
| retryDelay: (attemptIndex, error) => { | |
| // Use shorter delays for connection errors to recover quickly from blips | |
| if (isConnectionError(error)) { | |
| return Math.min(1000 * 2 ** attemptIndex, 5000); // 1s, 2s, 4s (capped at 5s) | |
| } | |
| return Math.min(1000 * 2 ** attemptIndex, 30000); | |
| }, | |
| // On mobile, disable refetch on focus to prevent the blank screen + reload | |
| // cycle that occurs when the user switches back to the app. WebSocket | |
| // invalidation handles real-time updates; polling handles the rest. | |
| refetchOnWindowFocus: !isMobileDevice, | |
| refetchOnReconnect: true, | |
| // On mobile, only refetch on mount if data is stale (true = refetch only when stale). | |
| // On desktop, always refetch on mount for freshest data ('always' = refetch even if fresh). | |
| // This prevents unnecessary network requests when navigating between | |
| // routes, which was causing blank screen flickers on mobile. | |
| refetchOnMount: isMobileDevice ? true : 'always', | |
| // Keep previous data visible while refetching to prevent blank flashes. | |
| // This is especially important on mobile where network is slower. | |
| placeholderData: isMobileDevice ? keepPreviousData : undefined, | |
| }, | |
| mutations: { | |
| onError: handleMutationError, | |
| retry: false, // Don't auto-retry mutations | |
| }, | |
| }, | |
| }); | |
| /** | |
| * Set up global query error handling | |
| * This catches errors that aren't handled by individual queries | |
| */ | |
| queryClient.getQueryCache().subscribe((event) => { | |
| if (event.type === 'updated' && event.query.state.status === 'error') { | |
| const error = event.query.state.error; | |
| if (error instanceof Error) { | |
| handleQueryError(error); | |
| } | |
| } | |
| }); | |