diff --git a/mobile/src/transport/connection-revival-triggers.test.ts b/mobile/src/transport/connection-revival-triggers.test.ts index a349a2e5fa6..30efcd4f9bd 100644 --- a/mobile/src/transport/connection-revival-triggers.test.ts +++ b/mobile/src/transport/connection-revival-triggers.test.ts @@ -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 - 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) }) diff --git a/mobile/src/transport/connection-revival-triggers.ts b/mobile/src/transport/connection-revival-triggers.ts index 86244920dca..1391607d4bf 100644 --- a/mobile/src/transport/connection-revival-triggers.ts +++ b/mobile/src/transport/connection-revival-triggers.ts @@ -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 | 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() }