@@ -2,6 +2,7 @@ import type {NetInfoState} from '@react-native-community/netinfo';
22import type * as NetworkState from '@src/libs/NetworkState' ;
33
44let netInfoListener : ( ( state : NetInfoState ) => void ) | null = null ;
5+ const mockOnyxCallbacks = new Map < string , ( value : unknown ) => void > ( ) ;
56
67jest . mock ( '@react-native-community/netinfo' , ( ) => ( {
78 addEventListener : jest . fn ( ( cb : ( state : NetInfoState ) => void ) => {
@@ -17,7 +18,9 @@ jest.mock('@react-native-community/netinfo', () => ({
1718
1819jest . mock ( '@src/libs/Log' ) ;
1920jest . mock ( 'react-native-onyx' , ( ) => ( {
20- connectWithoutView : jest . fn ( ) ,
21+ connectWithoutView : jest . fn ( ( { key, callback} : { key : string ; callback : ( value : unknown ) => void } ) => {
22+ mockOnyxCallbacks . set ( key , callback ) ;
23+ } ) ,
2124} ) ) ;
2225
2326function fireNetInfoState ( overrides : Partial < NetInfoState > ) {
@@ -33,12 +36,21 @@ function fireNetInfoState(overrides: Partial<NetInfoState>) {
3336 } as NetInfoState ) ;
3437}
3538
39+ function fireSessionChange ( accountID : number ) {
40+ const cb = mockOnyxCallbacks . get ( 'session' ) ;
41+ if ( ! cb ) {
42+ throw new Error ( 'SESSION callback not registered' ) ;
43+ }
44+ cb ( { accountID} ) ;
45+ }
46+
3647describe ( 'NetworkState — internetUnreachable hard stop via NetInfo' , ( ) => {
3748 let getIsOffline : typeof NetworkState . getIsOffline ;
3849
3950 beforeEach ( ( ) => {
4051 jest . resetModules ( ) ;
4152 netInfoListener = null ;
53+ mockOnyxCallbacks . clear ( ) ;
4254
4355 const mod = require < typeof NetworkState > ( '@src/libs/NetworkState' ) ;
4456 getIsOffline = mod . getIsOffline ;
@@ -81,6 +93,7 @@ describe('NetworkState — reachability recovery triggers reconnect', () => {
8193 beforeEach ( ( ) => {
8294 jest . resetModules ( ) ;
8395 netInfoListener = null ;
96+ mockOnyxCallbacks . clear ( ) ;
8497
8598 // Fresh import each test so prevIsInternetReachable resets
8699 const mod = require < typeof NetworkState > ( '@src/libs/NetworkState' ) ;
@@ -143,6 +156,69 @@ describe('NetworkState — reachability recovery triggers reconnect', () => {
143156 expect ( reconnectListener ) . not . toHaveBeenCalled ( ) ;
144157 } ) ;
145158
159+ test ( 'SESSION accountID change does NOT fire reconnect listener via the post-reconfigure synthetic transition' , ( ) => {
160+ // Repro for the doubled-OpenApp bug on delegate switch: the SESSION accountID change
161+ // re-runs configureAndSubscribe(), which tears down and re-subscribes to NetInfo. The
162+ // new subscription emits null then true, which would look like a recovery. The fix
163+ // resets prev to undefined on reconfigure so the new subscription's first transitions
164+ // are treated like boot, not recovery.
165+ const reconnectListener = jest . fn ( ) ;
166+ onReachabilityConfirmed ( reconnectListener ) ;
167+
168+ // Establish a baseline reachable state (boot)
169+ fireNetInfoState ( { isInternetReachable : true } ) ;
170+ expect ( reconnectListener ) . not . toHaveBeenCalled ( ) ;
171+
172+ // Delegate switch: SESSION accountID changes → reconfigure → new NetInfo subscription
173+ fireSessionChange ( 42 ) ;
174+
175+ // New subscription's initial events: null while the first Ping is in flight, then true
176+ fireNetInfoState ( { isInternetReachable : null } ) ;
177+ fireNetInfoState ( { isInternetReachable : true } ) ;
178+
179+ expect ( reconnectListener ) . not . toHaveBeenCalled ( ) ;
180+ } ) ;
181+
182+ test ( 'genuine offline→online after a SESSION reconfigure still fires reconnect listener' , ( ) => {
183+ // Make sure the reconfigure suppression doesn't swallow real recoveries that happen
184+ // afterwards — only the synthetic post-reconfigure transition should be ignored.
185+ const reconnectListener = jest . fn ( ) ;
186+ onReachabilityConfirmed ( reconnectListener ) ;
187+
188+ fireNetInfoState ( { isInternetReachable : true } ) ;
189+ fireSessionChange ( 42 ) ;
190+
191+ // Settle on the reconfigured subscription
192+ fireNetInfoState ( { isInternetReachable : true } ) ;
193+ expect ( reconnectListener ) . not . toHaveBeenCalled ( ) ;
194+
195+ // Now a real outage and recovery
196+ fireNetInfoState ( { isInternetReachable : false } ) ;
197+ fireNetInfoState ( { isInternetReachable : true } ) ;
198+
199+ expect ( reconnectListener ) . toHaveBeenCalledTimes ( 1 ) ;
200+ } ) ;
201+
202+ test ( 'genuine offline before reconfigure still recovers on the next true' , ( ) => {
203+ // Boot offline scenario: NetInfo confirms unreachable BEFORE SESSION hydrates and triggers
204+ // a reconfigure. The post-reconfigure true must NOT be suppressed — otherwise the app would
205+ // remain stuck with internetUnreachable=true until a brand new outage cycle.
206+ const reconnectListener = jest . fn ( ) ;
207+ onReachabilityConfirmed ( reconnectListener ) ;
208+
209+ // Cold boot: null then false → app is genuinely offline, prev=false
210+ fireNetInfoState ( { isInternetReachable : null } ) ;
211+ fireNetInfoState ( { isInternetReachable : false } ) ;
212+
213+ // SESSION hydrates → reconfigure happens while we are still offline
214+ fireSessionChange ( 42 ) ;
215+
216+ // New subscription's first definitive event recovers
217+ fireNetInfoState ( { isInternetReachable : true } ) ;
218+
219+ expect ( reconnectListener ) . toHaveBeenCalledTimes ( 1 ) ;
220+ } ) ;
221+
146222 test ( 'turning off force-offline resets prevIsInternetReachable so next refresh triggers reconnect' , ( ) => {
147223 const reconnectListener = jest . fn ( ) ;
148224 onReachabilityConfirmed ( reconnectListener ) ;
0 commit comments