fix: seed network baseline so first change after subscribe can nudge

addNetworkStateListener only fires on changes; with a null baseline the
first event after subscribing (app launched offline, network returns)
was swallowed by the previous == null guard. Seed via
getNetworkStateAsync with a disposed guard (CodeRabbit review).

Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
Jinwoo-H
2026-06-09 17:44:50 -07:00
co-authored by Orca
parent 087095828d
commit 7c63cdcdea
2 changed files with 44 additions and 9 deletions
@@ -2,10 +2,12 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
import { subscribeConnectionRevivalTriggers } from './connection-revival-triggers'
type AppStateListener = (next: string) => void
type NetworkListener = (state: { isConnected?: boolean; type?: string }) => void
type NetworkSnapshot = { isConnected?: boolean; type?: string }
type NetworkListener = (state: NetworkSnapshot) => void
let appStateListener: AppStateListener | null = null
let networkListener: NetworkListener | null = null
let seededNetworkState: NetworkSnapshot = { isConnected: true, type: 'WIFI' }
const appStateRemove = vi.fn()
const networkRemove = vi.fn()
@@ -19,52 +21,73 @@ vi.mock('react-native', () => ({
}))
vi.mock('expo-network', () => ({
getNetworkStateAsync: () => Promise.resolve(seededNetworkState),
addNetworkStateListener: (listener: NetworkListener) => {
networkListener = listener
return { remove: networkRemove }
}
}))
// Why: the baseline seed resolves on a microtask; flush it so listener
// events in the test observe the same ordering as a real subscription.
async function subscribeAndSeed(nudge: () => void): Promise<() => void> {
const unsubscribe = subscribeConnectionRevivalTriggers(nudge)
await Promise.resolve()
return unsubscribe
}
describe('subscribeConnectionRevivalTriggers', () => {
let nudge: ReturnType<typeof vi.fn>
let unsubscribe: () => void
beforeEach(() => {
vi.clearAllMocks()
appStateListener = null
networkListener = null
seededNetworkState = { isConnected: true, type: 'WIFI' }
nudge = vi.fn()
unsubscribe = subscribeConnectionRevivalTriggers(nudge)
})
it('nudges when the app returns to the foreground, not on background', () => {
it('nudges when the app returns to the foreground, not on background', async () => {
await subscribeAndSeed(nudge)
appStateListener?.('background')
expect(nudge).not.toHaveBeenCalled()
appStateListener?.('active')
expect(nudge).toHaveBeenCalledTimes(1)
})
it('nudges when the network comes back online', () => {
it('nudges when the network comes back online', async () => {
await subscribeAndSeed(nudge)
networkListener?.({ isConnected: false, type: 'NONE' })
expect(nudge).not.toHaveBeenCalled()
networkListener?.({ isConnected: true, type: 'WIFI' })
expect(nudge).toHaveBeenCalledTimes(1)
})
it('nudges on a Wi-Fi to cellular handoff that never reports offline', () => {
it('nudges when the app started offline and the first event is the recovery', async () => {
seededNetworkState = { isConnected: false, type: 'NONE' }
await subscribeAndSeed(nudge)
networkListener?.({ isConnected: true, type: 'WIFI' })
expect(nudge).toHaveBeenCalledTimes(1)
})
it('nudges on a Wi-Fi to cellular handoff that never reports offline', async () => {
await subscribeAndSeed(nudge)
networkListener?.({ isConnected: true, type: 'CELLULAR' })
expect(nudge).toHaveBeenCalledTimes(1)
})
it('stays quiet on the initial report and on repeated identical states', () => {
it('stays quiet when the network state matches the seeded baseline', async () => {
await subscribeAndSeed(nudge)
networkListener?.({ isConnected: true, type: 'WIFI' })
networkListener?.({ isConnected: true, type: 'WIFI' })
expect(nudge).not.toHaveBeenCalled()
})
it('removes both OS listeners on unsubscribe', () => {
it('ignores a stale seed that resolves after unsubscribe', async () => {
seededNetworkState = { isConnected: false, type: 'NONE' }
const unsubscribe = subscribeConnectionRevivalTriggers(nudge)
unsubscribe()
await Promise.resolve()
expect(appStateRemove).toHaveBeenCalledTimes(1)
expect(networkRemove).toHaveBeenCalledTimes(1)
})
@@ -1,5 +1,5 @@
import { AppState } from 'react-native'
import { addNetworkStateListener, type NetworkState } from 'expo-network'
import { addNetworkStateListener, getNetworkStateAsync, type NetworkState } from 'expo-network'
// Why: Android/iOS suspend JS timers and silently kill sockets while the app
// is backgrounded, and network handoffs (Wi-Fi → cellular) kill the TCP path
@@ -13,6 +13,17 @@ export function subscribeConnectionRevivalTriggers(nudge: () => void): () => voi
}
})
let lastNetwork: Pick<NetworkState, 'isConnected' | 'type'> | null = null
let disposed = false
// Why: the listener only fires on *changes*; without a seeded baseline the
// first change after subscribing (app launched offline, network returns)
// would be swallowed by the previous == null guard below.
void getNetworkStateAsync()
.then((state) => {
if (!disposed && lastNetwork == null) {
lastNetwork = { isConnected: state.isConnected, type: state.type }
}
})
.catch(() => {})
const networkSub = addNetworkStateListener((state) => {
const previous = lastNetwork
lastNetwork = { isConnected: state.isConnected, type: state.type }
@@ -32,6 +43,7 @@ export function subscribeConnectionRevivalTriggers(nudge: () => void): () => voi
}
})
return () => {
disposed = true
appStateSub.remove()
networkSub.remove()
}