mirror of
https://github.com/stablyai/orca.git
synced 2026-10-29 16:17:51 +00:00
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:
@@ -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()
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user