|
| 1 | +# Network State Detection |
| 2 | + |
| 3 | +## Overview |
| 4 | + |
| 5 | +The app uses a two-layer detection model to determine connectivity status. Each layer feeds into a central **hard stop** state machine that decides whether the app is offline. When a hard stop is active, the app pauses outgoing requests and shows the offline UI. Recovery relies on NetInfo's built-in reachability polling (`isInternetReachable`) and the FailureTracker detecting a successful request. Once connectivity is confirmed, the app clears the hard stop and reconnects. |
| 6 | + |
| 7 | +## Architecture Diagram |
| 8 | + |
| 9 | +``` |
| 10 | +┌──────────────────────┐ |
| 11 | +│ Layer 1: OS Radio │ NetInfo isConnected |
| 12 | +│ (NetworkState) │──────────────────────────┐ |
| 13 | +└──────────────────────┘ │ |
| 14 | + ▼ |
| 15 | + ┌──────────────────┐ |
| 16 | +┌──────────────────────┐ │ │ |
| 17 | +│ Layer 2: Sustained │ FailureTracker│ NetworkState │──── isOffline ──▶ listeners (UI, SQ) |
| 18 | +│ Failures │───────────────▶│ (hard stop) │ |
| 19 | +│ (FailureTracking MW) │ │ │ |
| 20 | +└──────────────────────┘ └──────────────────┘ |
| 21 | + ▲ |
| 22 | + │ onReachabilityRestored |
| 23 | + │ |
| 24 | + ┌──────────────────┐ |
| 25 | + │ NetInfo listener │──── isInternetReachable |
| 26 | + │ (reachability │ transitions →true |
| 27 | + │ polling) │ |
| 28 | + └──────────────────┘ |
| 29 | + │ |
| 30 | + ▼ |
| 31 | + ┌──────────────────┐ |
| 32 | + │ Reconnect │──── openApp / reconnectApp |
| 33 | + │ │──── flush SequentialQueue |
| 34 | + └──────────────────┘ |
| 35 | +``` |
| 36 | + |
| 37 | +## The Hard Stop Model |
| 38 | + |
| 39 | +A **hard stop** means the app considers itself offline. When at least one trigger is active, the hard stop is ON. When all triggers are cleared, the hard stop is OFF. |
| 40 | + |
| 41 | +Five triggers can activate a hard stop: |
| 42 | + |
| 43 | +| Trigger | Source | Meaning | |
| 44 | +|---|---|---| |
| 45 | +| `noRadioActive` | OS radio detection | Device has no network interface (airplane mode, WiFi off) | |
| 46 | +| `internetUnreachable` | NetInfo reachability polling | `api/Ping` failed — server unreachable despite connected radio | |
| 47 | +| `sustainedFailuresActive` | Failure tracker | Requests have been failing consistently | |
| 48 | +| `shouldForceOffline` | Debug tool | Manually forced offline via TestToolMenu | |
| 49 | +| `simulatedOffline` | Test tool | Poor connection simulator toggling offline randomly | |
| 50 | + |
| 51 | +When a hard stop activates: |
| 52 | +1. `NetworkState` notifies all subscribers (via `subscribe()`) |
| 53 | +2. `SequentialQueue` guards against processing while offline (reads `getIsOffline()` synchronously) |
| 54 | +3. Components using `useNetwork()` re-render with `isOffline: true` |
| 55 | + |
| 56 | +When the hard stop clears: |
| 57 | +1. `NetworkState` notifies all subscribers |
| 58 | +2. `Reconnect.ts` detects the offline→online transition and calls `SequentialQueue.flush()` |
| 59 | +3. Components using `useNetwork()` re-render with `isOffline: false` |
| 60 | + |
| 61 | +## Layer 1: OS Radio Detection |
| 62 | + |
| 63 | +**File:** `src/libs/NetworkState.ts` |
| 64 | + |
| 65 | +This layer uses `@react-native-community/netinfo` to detect whether the device has an active network interface. The NetInfo listener lives inside `NetworkState.ts` — there is no separate module. |
| 66 | + |
| 67 | +- A module-level Onyx connection to `SESSION` triggers `configureAndSubscribe()` whenever accountID changes |
| 68 | +- The NetInfo listener reads `state.isConnected` and calls `setHasRadio()` internally |
| 69 | +- When `isConnected` is `false` → `setHasRadio(false)` → activates the `noRadioActive` hard stop |
| 70 | +- When `isConnected` returns to `true` → `setHasRadio(true)` → clears the `noRadioActive` hard stop |
| 71 | +- Detects: airplane mode, WiFi disabled, no cellular signal |
| 72 | +- Does **not** determine actual server reachability — a device can be connected to WiFi but have no internet |
| 73 | + |
| 74 | +### Reachability tracking |
| 75 | + |
| 76 | +The NetInfo listener tracks `isInternetReachable` transitions in both directions: |
| 77 | + |
| 78 | +- **any non-`false` → `false`** (`true→false`, `null→false`, `undefined→false`) — `api/Ping` failed. Sets the `internetUnreachable` hard stop. Only `false→false` is skipped (already offline). This covers cold start with no internet (`null→false` after the initial indeterminate event), post-recovery resets, and normal online→offline transitions. Without this, an idle user would never see the offline indicator because no API requests are failing. |
| 79 | +- **`false` → `true`** and **`null` → `true`** — `api/Ping` succeeded after a previous failure. Triggers `onReachabilityRestored()` which clears all hard stops and fires reconnect listeners. |
| 80 | +- **`undefined` → `true`** — the initial event on subscribe, delivering current state. This is **not** treated as a recovery to prevent duplicate `openApp()`/`reconnectApp()` calls on boot. |
| 81 | + |
| 82 | +**Platform behavior:** |
| 83 | + |
| 84 | +We configure `useNativeReachability: false` so that NetInfo uses JS fetch polling (`api/Ping`) on **all platforms** instead of trusting native OS reachability. This aligns behavior across web and mobile. NetInfo's default polling intervals apply (60s when reachable, 5s when unreachable). |
| 85 | + |
| 86 | +### Why `useNativeReachability: false` is required |
| 87 | + |
| 88 | +With `useNativeReachability: false`, NetInfo determines `isInternetReachable` by polling `api/Ping` via a real `fetch()` request — making it a genuine request outcome, consistent with the design principle that "request outcomes are the authority." |
| 89 | + |
| 90 | +If `useNativeReachability` were set to `true`, NetInfo would use native OS reachability heuristics instead of JS polling. The `isInternetReachable` value would no longer represent a real request outcome and should NOT be used as an offline trigger. The `internetUnreachable` hard stop depends on this configuration. |
| 91 | + |
| 92 | +How `isConnected` vs `isInternetReachable` are determined per platform (with `useNativeReachability: false`): |
| 93 | + |
| 94 | +| Platform | `isConnected` source | `isInternetReachable` source | |
| 95 | +|---|---|---| |
| 96 | +| Web | `navigator.onLine` | JS `fetch(api/Ping)` | |
| 97 | +| iOS | `SCNetworkReachability` flags | JS `fetch(api/Ping)` | |
| 98 | +| Android | `NetworkCapabilities` transport type | JS `fetch(api/Ping)` | |
| 99 | + |
| 100 | +Note: Android's native module computes its own `isInternetReachable` via `NET_CAPABILITY_VALIDATED`, but the JS `InternetReachability.update()` gate discards it when `useNativeReachability: false`. iOS doesn't send `isInternetReachable` from native at all. See the NetInfo source files `internetReachability.ts`, `nativeModule.web.ts`, `ConnectivityReceiver.java`, and `RNCNetInfo.mm` for implementation details. |
| 101 | + |
| 102 | +## Layer 2: Sustained Failure Detection |
| 103 | + |
| 104 | +**Files:** `src/libs/FailureTracker.ts`, `src/libs/Middleware/FailureTracking.ts` |
| 105 | + |
| 106 | +This layer detects connectivity loss through request outcomes, catching cases where the OS reports a connection but the server is unreachable (e.g., captive portal, DNS failure, server outage). |
| 107 | + |
| 108 | +### FailureTracking Middleware |
| 109 | + |
| 110 | +The middleware observes every API response: |
| 111 | + |
| 112 | +- Any resolved response (server responded at all) → calls `recordSuccess()` |
| 113 | +- `FAILED_TO_FETCH` error → calls `recordFailure()` (DNS failure, no internet, network timeout) |
| 114 | +- `EXPENSIFY_SERVICE_INTERRUPTED` error → calls `recordFailure()` (server down: 500/502/504/520) |
| 115 | +- All other errors (4xx, throttling, etc.) → **not tracked** as connectivity failures |
| 116 | + |
| 117 | +### FailureTracker |
| 118 | + |
| 119 | +Counts consecutive failures and applies a dual threshold: |
| 120 | + |
| 121 | +1. **Count threshold:** at least `SUSTAINED_FAILURE_THRESHOLD_COUNT` failures (default: 3) |
| 122 | +2. **Time threshold:** at least `SUSTAINED_FAILURE_WINDOW_MS` elapsed since the first failure (default: 10s) |
| 123 | + |
| 124 | +Both thresholds must be met simultaneously to trigger a sustained failure hard stop. This prevents brief transient errors from being misidentified as connectivity loss. |
| 125 | + |
| 126 | +One successful request resets everything — it proves the server is reachable and clears the `sustainedFailuresActive` flag. |
| 127 | + |
| 128 | +## Central State Machine |
| 129 | + |
| 130 | +**File:** `src/libs/NetworkState.ts` |
| 131 | + |
| 132 | +`NetworkState` is the single source of truth for offline status. It holds module-level boolean flags and uses a subscriber pattern — components use `useNetwork()` (backed by `useSyncExternalStore`) and `SequentialQueue` subscribes via `subscribe()`. Because the state is module-level (not persisted in Onyx), each browser tab detects connectivity independently. This is intentional — each tab has its own network conditions and should evaluate them on its own. |
| 133 | + |
| 134 | +``` |
| 135 | +hasRadio — set by NetInfo listener (Layer 1), inverted: !hasRadio = noRadio hard stop |
| 136 | +internetUnreachable — set by NetInfo listener when isInternetReachable transitions to false (any non-false→false) |
| 137 | +sustainedFailuresActive — set by FailureTracker (Layer 2) |
| 138 | +shouldForceOffline — set by debug tools (Onyx NETWORK key) |
| 139 | +simulatedOffline — set by poor connection simulator |
| 140 | +``` |
| 141 | + |
| 142 | +### Core logic |
| 143 | + |
| 144 | +`getIsOffline()` derives the offline status: |
| 145 | + |
| 146 | +```typescript |
| 147 | +const offline = !hasRadio || internetUnreachable || sustainedFailuresActive || shouldForceOffline || simulatedOffline; |
| 148 | +``` |
| 149 | + |
| 150 | +`updateState()` notifies all subscribers when the state changes. `Reconnect.ts` subscribes and calls `SequentialQueue.flush()` on offline→online transitions. `SequentialQueue` reads `getIsOffline()` synchronously for its guard checks but does not own the transition subscription. |
| 151 | + |
| 152 | +### Recovery flow |
| 153 | + |
| 154 | +`onReachabilityRestored()`: |
| 155 | +1. Sets `hasRadio = true` and `sustainedFailuresActive = false`, resets FailureTracker counters |
| 156 | +2. Calls `updateState()` (which notifies subscribers and clears the hard stop) |
| 157 | +3. Calls `notifyReconnectListeners()` — `Reconnect.ts` subscribes to this and triggers app data sync |
| 158 | + |
| 159 | +### App foreground handling |
| 160 | + |
| 161 | +`Reconnect.ts` registers an `AppStateMonitor.addBecameActiveListener` callback: |
| 162 | +- If in hard stop → calls `NetworkState.refresh()` which triggers `NetInfo.refresh()` (bypasses stale `isInternetReachable` cache, see NetInfo issue #326) |
| 163 | +- Always → calls `reconnect()` to catch up on missed Pusher events |
| 164 | + |
| 165 | +## Recovery & Reconnect |
| 166 | + |
| 167 | +**File:** `src/libs/actions/Reconnect.ts` |
| 168 | + |
| 169 | +Subscribes to `NetworkState.onReachabilityConfirmed()` and `AppStateMonitor.addBecameActiveListener()`. Handles data synchronization: |
| 170 | + |
| 171 | +- Skips reconnection if no active session (`currentAccountID` is undefined) |
| 172 | +- If `isLoadingApp` is true → calls `App.openApp()` (full initial load) |
| 173 | +- Otherwise → calls `App.reconnectApp(lastUpdateIDAppliedToClient)` (incremental sync) |
| 174 | +- Flushes `SequentialQueue` to send any pending write requests |
| 175 | + |
| 176 | +## Thundering Herd Protection |
| 177 | + |
| 178 | +When the server recovers after an outage, many clients detect reachability at roughly the same time. The queue does not add an artificial delay before flushing because the architecture has three layers of natural backoff: |
| 179 | + |
| 180 | +1. **Polling jitter** — each client's NetInfo polls `api/Ping` on its own 5-second cycle, so clients discover recovery at different times (up to 5 seconds of natural spread). |
| 181 | +2. **Per-request exponential backoff** — if the server is still overloaded when a client flushes, `RequestThrottle` applies jittered exponential backoff (10–100 ms initial, doubling up to 30 s cap) on each failed request. |
| 182 | +3. **Re-triggering hard stop** — if enough requests fail after recovery (3 failures over 10 seconds), `FailureTracker` puts the client back into a hard stop, preventing it from hammering the server further. |
| 183 | + |
| 184 | +## Configuration Constants |
| 185 | + |
| 186 | +All values are defined in `src/CONST/index.ts` under `CONST.NETWORK`: |
| 187 | + |
| 188 | +| Constant | Value | Description | |
| 189 | +|---|---|---| |
| 190 | +| `SUSTAINED_FAILURE_THRESHOLD_COUNT` | `3` | Minimum failures before triggering sustained failure hard stop | |
| 191 | +| `SUSTAINED_FAILURE_WINDOW_MS` | `10000` (10s) | Minimum elapsed time from first failure to trigger hard stop | |
| 192 | + |
| 193 | +## Debug Tools |
| 194 | + |
| 195 | +Two debug options are available via the TestToolMenu (accessible in dev builds): |
| 196 | + |
| 197 | +- **`shouldForceOffline`**: Forces the app into a hard stop. Flows through Onyx → `NetworkState.setForceOffline()`. Useful for testing offline UX patterns. |
| 198 | +- **`shouldSimulatePoorConnection`**: Randomly toggles the app between online and offline every 2–5 seconds. Handled in `NetworkState.ts`. Useful for testing flaky network behavior. |
| 199 | + |
| 200 | +## Key Files Reference |
| 201 | + |
| 202 | +| File | Role | |
| 203 | +|---|---| |
| 204 | +| `src/libs/NetworkState.ts` | Central hard stop state machine, NetInfo configuration/subscription, OS radio detection, and reachability tracking | |
| 205 | +| `src/libs/FailureTracker.ts` | Counts failures, triggers sustained failure hard stop via listener pattern | |
| 206 | +| `src/libs/Middleware/FailureTracking.ts` | Middleware that observes request outcomes and feeds FailureTracker | |
| 207 | +| `src/libs/actions/Reconnect.ts` | Subscribes to reachability + foreground events, syncs app data after recovery | |
| 208 | +| `src/libs/Network/SequentialQueue.ts` | Write request queue, reads `getIsOffline()` synchronously for guard checks | |
| 209 | +| `src/libs/actions/Network.ts` | Onyx actions for debug flags (forceOffline, simulatePoorConnection) | |
| 210 | +| `src/hooks/useNetwork.ts` | Hook for components — uses `useSyncExternalStore` with `NetworkState.subscribe()` | |
| 211 | + |
| 212 | +## Relationship to Offline UX Patterns |
| 213 | + |
| 214 | +This document covers **how** the app detects connectivity changes and determines offline status. For **how features respond** to being offline (optimistic updates, blocking forms, full-page blocking), see [Offline UX Patterns](philosophies/OFFLINE.md). |
0 commit comments