Skip to content

Commit 19d70c2

Browse files
authored
Merge pull request Expensify#84760 from callstack-internal/fix-network-state-detection
2 parents 34f8fc8 + e8db0bc commit 19d70c2

75 files changed

Lines changed: 2270 additions & 1127 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.storybook/preview.tsx

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -17,9 +17,6 @@ import './fonts.css';
1717

1818
Onyx.init({
1919
keys: ONYXKEYS,
20-
initialKeyStates: {
21-
[ONYXKEYS.NETWORK]: {isOffline: false},
22-
},
2320
});
2421

2522
IntlStore.load(CONST.LOCALES.EN);
Lines changed: 214 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,214 @@
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).

contributingGuides/philosophies/OFFLINE.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,10 @@
1111
* [UX Pattern Flowchart](#ux-pattern-flow-chart)
1212
- [Answering Questions on the Flow Chart](#answering-questions-on-the-flow-chart)
1313

14+
## How Offline State Is Detected
15+
16+
This document covers UX patterns for handling offline state. For the underlying architecture of how the app detects connectivity changes (hard stop model, failure tracking, recovery probes), see [Network State Detection](../NETWORK_STATE_DETECTION.md).
17+
1418
## Motivation & Philosophy
1519

1620
Understanding the offline behavior of our app is vital to becoming a productive contributor to the Expensify codebase. Our mission is to support our users in every possible environment, and often our app is used in places where a stable internet connection is not guaranteed.

src/CONST/index.ts

Lines changed: 3 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -2232,22 +2232,18 @@ const CONST = {
22322232
MAX_RETRY_WAIT_TIME_MS: 10 * 1000,
22332233
PROCESS_REQUEST_DELAY_MS: 1000,
22342234
MAX_PENDING_TIME_MS: 10 * 1000,
2235-
RECHECK_INTERVAL_MS: 60 * 1000,
22362235
MAX_REQUEST_RETRIES: 10,
22372236
MAX_OPEN_APP_REQUEST_RETRIES: 2,
2238-
NETWORK_STATUS: {
2239-
ONLINE: 'online',
2240-
OFFLINE: 'offline',
2241-
UNKNOWN: 'unknown',
2242-
},
2237+
SUSTAINED_FAILURE_THRESHOLD_COUNT: 3,
2238+
SUSTAINED_FAILURE_WINDOW_MS: 10 * 1000,
2239+
RECONNECT_STAMPEDE_JITTER_MS: 5000,
22432240
},
22442241
// The number of milliseconds for an idle session to expire
22452242
SESSION_EXPIRATION_TIME_MS: 2 * 3600 * 1000, // 2 hours
22462243
WEEK_STARTS_ON: 1, // Monday
22472244
DEFAULT_TIME_ZONE: {automatic: true, selected: 'America/Los_Angeles'},
22482245
DEFAULT_ACCOUNT_DATA: {errors: null, success: '', isLoading: false},
22492246
DEFAULT_CLOSE_ACCOUNT_DATA: {errors: null, success: '', isLoading: false},
2250-
DEFAULT_NETWORK_DATA: {isOffline: false},
22512247
FORMS: {
22522248
LOGIN_FORM: 'LoginForm',
22532249
VALIDATE_CODE_FORM: 'ValidateCodeForm',

src/Expensify.tsx

Lines changed: 3 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,8 @@ import Log from './libs/Log';
2424
import migrateOnyx from './libs/migrateOnyx';
2525
import Navigation from './libs/Navigation/Navigation';
2626
import NavigationRoot from './libs/Navigation/NavigationRoot';
27-
import NetworkConnection from './libs/NetworkConnection';
27+
// This lib needs to be imported for its module-level NetInfo and Onyx subscriptions
28+
import './libs/NetworkState';
2829
import PushNotification from './libs/Notification/PushNotification';
2930
import {endSpan, getSpan, startSpan} from './libs/telemetry/activeSpans';
3031
import type {BootsplashGateStatus} from './libs/telemetry/bootsplashTelemetry';
@@ -34,7 +35,6 @@ import Visibility from './libs/Visibility';
3435
import ONYXKEYS from './ONYXKEYS';
3536
import PriorityModeHandler from './PriorityModeHandler';
3637
import type {Route} from './ROUTES';
37-
import {accountIDSelector} from './selectors/Session';
3838
import {useSplashScreenActions, useSplashScreenState} from './SplashScreenStateContext';
3939

4040
Onyx.registerLogger(({level, message, parameters}) => {
@@ -56,7 +56,6 @@ function Expensify() {
5656
const {setSplashScreenState} = useSplashScreenActions();
5757
const [hasAttemptedToOpenPublicRoom, setAttemptedToOpenPublicRoom] = useState(false);
5858
const {preferredLocale} = useLocalize();
59-
const [accountID] = useOnyx(ONYXKEYS.SESSION, {selector: accountIDSelector});
6059
const [lastRoute] = useOnyx(ONYXKEYS.LAST_ROUTE);
6160
const [isCheckingPublicRoom = true] = useOnyx(ONYXKEYS.IS_CHECKING_PUBLIC_ROOM, {initWithStoredValues: false});
6261
const [updateRequired] = useOnyx(ONYXKEYS.RAM_ONLY_UPDATE_REQUIRED);
@@ -210,12 +209,7 @@ function Expensify() {
210209
useLayoutEffect(() => {
211210
// Initialize this client as being an active client
212211
ActiveClientManager.init();
213-
214-
// Used for the offline indicator appearing when someone is offline
215-
const unsubscribeNetInfo = NetworkConnection.subscribeToNetInfo(accountID);
216-
217-
return unsubscribeNetInfo;
218-
}, [accountID]);
212+
}, []);
219213

220214
// Log the platform and config to debug .env issues
221215
useEffect(() => {

0 commit comments

Comments
 (0)