Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion apps/mobile/src/components/offline-banner.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 };

Expand Down Expand Up @@ -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();
Expand All @@ -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);
Expand All @@ -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();
Expand Down
12 changes: 5 additions & 7 deletions apps/mobile/src/lib/offline-banner-state.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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);
Expand All @@ -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();

Expand All @@ -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);
Expand All @@ -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', () => {
Expand Down
31 changes: 18 additions & 13 deletions apps/mobile/src/lib/offline-banner-state.ts
Original file line number Diff line number Diff line change
@@ -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 };
Expand All @@ -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;
Expand All @@ -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);
Expand Down
Loading