File size: 7,144 Bytes
1dbc34b
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
/**
 * 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);
    }
  }
});