diff --git a/apps/mobile/src/components/offline-banner.tsx b/apps/mobile/src/components/offline-banner.tsx index 47bbe832c6..eb4bb3285b 100644 --- a/apps/mobile/src/components/offline-banner.tsx +++ b/apps/mobile/src/components/offline-banner.tsx @@ -21,7 +21,8 @@ export function OfflineBanner() { // Announce committed transitions only, never the initial state: the first // run records the current value without announcing. A cold-start offline - // device announces once when the first NetInfo commit lands (~1 s in). + // device announces once when the first NetInfo commit lands, one + // OFFLINE_BANNER_SHOW_DELAY_MS after launch. useEffect(() => { if (prevRef.current !== null && prevRef.current !== isOffline) { announceForA11y(isOffline ? 'No internet connection' : 'Internet connection restored'); diff --git a/apps/mobile/src/lib/hooks/use-offline-banner-state.mounted.test.tsx b/apps/mobile/src/lib/hooks/use-offline-banner-state.mounted.test.tsx index f27179a79d..b23061f194 100644 --- a/apps/mobile/src/lib/hooks/use-offline-banner-state.mounted.test.tsx +++ b/apps/mobile/src/lib/hooks/use-offline-banner-state.mounted.test.tsx @@ -4,6 +4,7 @@ import TestRenderer, { act } from 'react-test-renderer'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { useOfflineBannerState } from '@/lib/hooks/use-offline-banner-state'; +import { OFFLINE_BANNER_SHOW_DELAY_MS } from '@/lib/offline-banner-state'; type ConnectivityState = { isConnected: boolean | null; isInternetReachable: boolean | null }; @@ -71,7 +72,7 @@ describe('useOfflineBannerState mounted', () => { vi.useRealTimers(); }); - it('subscribes once, flips after the debounce in both directions, and cleans up on unmount', async () => { + it('subscribes once, shows after the delay, hides at once, and cleans up on unmount', async () => { vi.useFakeTimers(); const renderer = await renderProbe(); @@ -85,18 +86,13 @@ describe('useOfflineBannerState mounted', () => { expect(textChildren(renderer)).toEqual(['false']); act(() => { - vi.advanceTimersByTime(1000); + vi.advanceTimersByTime(OFFLINE_BANNER_SHOW_DELAY_MS); }); expect(textChildren(renderer)).toEqual(['true']); act(() => { netinfo.emit({ isConnected: true, isInternetReachable: true }); }); - expect(textChildren(renderer)).toEqual(['true']); - - act(() => { - vi.advanceTimersByTime(1000); - }); expect(textChildren(renderer)).toEqual(['false']); const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined); @@ -109,7 +105,7 @@ describe('useOfflineBannerState mounted', () => { act(() => { netinfo.emit({ isConnected: false, isInternetReachable: false }); - vi.advanceTimersByTime(1000); + vi.advanceTimersByTime(OFFLINE_BANNER_SHOW_DELAY_MS); }); expect(renderer.toJSON()).toBeNull(); diff --git a/apps/mobile/src/lib/offline-banner-state.test.ts b/apps/mobile/src/lib/offline-banner-state.test.ts index eba78ef285..e1c512ea15 100644 --- a/apps/mobile/src/lib/offline-banner-state.test.ts +++ b/apps/mobile/src/lib/offline-banner-state.test.ts @@ -4,7 +4,7 @@ import { type ConnectivityState } from '@/lib/connectivity-online'; import { type ConnectivitySource, createOfflineBannerStore, - OFFLINE_BANNER_DEBOUNCE_MS, + OFFLINE_BANNER_SHOW_DELAY_MS, type OfflineBannerStore, type OfflineBannerTimer, } from '@/lib/offline-banner-state'; @@ -91,7 +91,7 @@ describe('createOfflineBannerStore', () => { expect(store.isOffline()).toBe(false); }); - it('commits offline only after the debounce and notifies once', () => { + it('commits offline only after the show delay and notifies once', () => { const { store, source, timer } = createStore(); const listener = vi.fn(() => undefined); store.subscribe(listener); @@ -101,7 +101,7 @@ describe('createOfflineBannerStore', () => { expect(store.isOffline()).toBe(false); expect(listener).not.toHaveBeenCalled(); - expect(timer.scheduled[0]?.delayMs).toBe(OFFLINE_BANNER_DEBOUNCE_MS); + expect(timer.scheduled[0]?.delayMs).toBe(OFFLINE_BANNER_SHOW_DELAY_MS); timer.firePending(); @@ -123,7 +123,7 @@ describe('createOfflineBannerStore', () => { expect(listener).not.toHaveBeenCalled(); }); - it('stays offline until the debounce after a committed offline, then notifies once', () => { + it('hides immediately when the connection returns after a committed offline', () => { const { store, source, timer } = createStore(); const listener = vi.fn(() => undefined); store.subscribe(listener); @@ -134,12 +134,10 @@ describe('createOfflineBannerStore', () => { expect(listener).toHaveBeenCalledTimes(1); source.emit(onlineState); - expect(store.isOffline()).toBe(true); - - timer.firePending(); expect(store.isOffline()).toBe(false); expect(listener).toHaveBeenCalledTimes(2); + expect(timer.scheduled.filter(entry => !entry.cancelled)).toEqual([]); }); it('commits exactly once for rapid alternation, matching the final quiet state', () => { diff --git a/apps/mobile/src/lib/offline-banner-state.ts b/apps/mobile/src/lib/offline-banner-state.ts index 72e3a58c38..412028e4ca 100644 --- a/apps/mobile/src/lib/offline-banner-state.ts +++ b/apps/mobile/src/lib/offline-banner-state.ts @@ -1,6 +1,12 @@ import { type ConnectivityState, isOnline } from '@/lib/connectivity-online'; -export const OFFLINE_BANNER_DEBOUNCE_MS = 1000; +/** + * How long the connection must stay down before the banner appears. NetInfo + * reports a false `offline` for a moment after a long background, so a short + * window flashed the banner on every foreground. Hiding stays immediate: a + * banner that is up when the connection works is the worse error. + */ +export const OFFLINE_BANNER_SHOW_DELAY_MS = 5000; export type OfflineBannerTimer = { set(callback: () => void, delayMs: number): { cancel(): void }; @@ -19,10 +25,10 @@ export type OfflineBannerStore = { export function createOfflineBannerStore(options: { source: ConnectivitySource; timer: OfflineBannerTimer; - debounceMs?: number; + showDelayMs?: number; }): OfflineBannerStore { const { source, timer } = options; - const debounceMs = options.debounceMs ?? OFFLINE_BANNER_DEBOUNCE_MS; + const showDelayMs = options.showDelayMs ?? OFFLINE_BANNER_SHOW_DELAY_MS; let committedOnline = true; let pending: { cancel(): void } | null = null; @@ -40,21 +46,20 @@ export function createOfflineBannerStore(options: { } } - function schedule(online: boolean): void { - cancelPending(); - pending = timer.set(() => { - pending = null; - commit(online); - }, debounceMs); - } - function handleSourceState(state: ConnectivityState): void { const online = isOnline(state); + cancelPending(); if (online === committedOnline) { - cancelPending(); return; } - schedule(online); + if (online) { + commit(true); + return; + } + pending = timer.set(() => { + pending = null; + commit(false); + }, showDelayMs); } const unsubscribeSource = source.subscribe(handleSourceState);