From 3e9d7efd94449dd9ef3f5b09e1535c90dd72d597 Mon Sep 17 00:00:00 2001 From: Jake Jarvis Date: Mon, 16 Mar 2026 11:03:24 -0400 Subject: [PATCH] refactor(native): replace polling-based server reachability with passive fetch interception MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Swap the periodic health-probe loop for a `serverFetch` wrapper that updates reachability state as a side-effect of every real request: any HTTP response marks the server reachable; a network-level error marks it unreachable. Wire TanStack Query's `onlineManager` and `focusManager` to `expo-network` and `AppState` so queries and mutations are automatically paused when the device goes offline and resumed on reconnect. Set `networkMode: "online"` on both queries and mutations to opt in to this behaviour. Move `isNetworkError` from `query-client.ts` into `server-reachability.ts` and extend its pattern list. Export `serverFetch` and use it as the custom fetch implementation in both the oRPC `RPCLink` and the Better Auth client so all network traffic flows through the same reachability tracker. Move the retry logic out of `useServerReachability` and into the banner components directly — on press, resume paused mutations, refetch active queries, and refresh the auth session. Add a unit test that verifies a transport failure sets the server unreachable and any subsequent HTTP response (even a 503) clears it. --- .../ui/server-unreachable-banner.ios.tsx | 18 ++- .../ui/server-unreachable-banner.tsx | 18 ++- apps/native/src/lib/auth-client.ts | 4 + apps/native/src/lib/orpc.ts | 8 +- apps/native/src/lib/query-client.ts | 17 +- .../src/lib/server-reachability.test.ts | 64 ++++++++ apps/native/src/lib/server-reachability.ts | 148 +++++++++++------- 7 files changed, 199 insertions(+), 78 deletions(-) create mode 100644 apps/native/src/lib/server-reachability.test.ts diff --git a/apps/native/src/components/ui/server-unreachable-banner.ios.tsx b/apps/native/src/components/ui/server-unreachable-banner.ios.tsx index bd9f915..616eda3 100644 --- a/apps/native/src/components/ui/server-unreachable-banner.ios.tsx +++ b/apps/native/src/components/ui/server-unreachable-banner.ios.tsx @@ -6,11 +6,13 @@ import { Pressable, View } from "react-native"; import Animated, { SlideInUp, SlideOutUp } from "react-native-reanimated"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import { Text } from "@/components/ui/text"; +import { authClient } from "@/lib/auth-client"; +import { queryClient } from "@/lib/query-client"; import { useServerReachability } from "@/lib/server-reachability"; import * as Haptics from "@/utils/haptics"; export function ServerUnreachableBanner() { - const { isReachable, retry } = useServerReachability(); + const { isReachable } = useServerReachability(); const insets = useSafeAreaInsets(); const wasReachable = useRef(true); @@ -41,6 +43,16 @@ export function ServerUnreachableBanner() { wasReachable.current = isReachable; }, [isReachable]); + const handleRetry = () => { + const refetchSession = authClient.$store.atoms.session.get().refetch; + + void Promise.allSettled([ + queryClient.resumePausedMutations(), + queryClient.refetchQueries({ type: "active" }), + refetchSession?.() ?? Promise.resolve(), + ]); + }; + // Only show when device has internet but server is unreachable if (isReachable || !isDeviceOnline) return null; @@ -53,7 +65,7 @@ export function ServerUnreachableBanner() { Can't reach server Retry @@ -63,7 +75,7 @@ export function ServerUnreachableBanner() { return ( { + const refetchSession = authClient.$store.atoms.session.get().refetch; + + void Promise.allSettled([ + queryClient.resumePausedMutations(), + queryClient.refetchQueries({ type: "active" }), + refetchSession?.() ?? Promise.resolve(), + ]); + }; + // Only show when device has internet but server is unreachable if (isReachable || !isDeviceOnline) return null; return ( Retry diff --git a/apps/native/src/lib/auth-client.ts b/apps/native/src/lib/auth-client.ts index b9d44f7..4a8bd8e 100644 --- a/apps/native/src/lib/auth-client.ts +++ b/apps/native/src/lib/auth-client.ts @@ -4,6 +4,7 @@ import { createAuthClient } from "better-auth/react"; import * as SecureStore from "expo-secure-store"; import { queryClient } from "@/lib/query-client"; +import { serverFetch } from "@/lib/server-reachability"; import { getCurrentInstanceId, getServerUrl, @@ -21,6 +22,9 @@ function getStoragePrefix(): string { function buildAuthClient() { return createAuthClient({ baseURL: getServerUrl(), + fetchOptions: { + customFetchImpl: serverFetch, + }, plugins: [ adminClient(), genericOAuthClient(), diff --git a/apps/native/src/lib/orpc.ts b/apps/native/src/lib/orpc.ts index ecf1d62..6804588 100644 --- a/apps/native/src/lib/orpc.ts +++ b/apps/native/src/lib/orpc.ts @@ -5,16 +5,16 @@ import { createTanstackQueryUtils } from "@orpc/tanstack-query"; import type { contract } from "@sofa/api/contract"; import { authClient } from "@/lib/auth-client"; +import { serverFetch } from "@/lib/server-reachability"; import { getServerUrl } from "@/lib/server-url"; export const link = new RPCLink({ url: () => `${getServerUrl()}/rpc`, - fetch: (url, options) => { - return fetch(url, { + fetch: (url, options) => + serverFetch(url, { ...options, credentials: process.env.EXPO_OS === "web" ? "include" : "omit", - }); - }, + }), headers() { if (process.env.EXPO_OS === "web") { return {}; diff --git a/apps/native/src/lib/query-client.ts b/apps/native/src/lib/query-client.ts index 608348e..fd81072 100644 --- a/apps/native/src/lib/query-client.ts +++ b/apps/native/src/lib/query-client.ts @@ -1,26 +1,16 @@ import { QueryCache, QueryClient } from "@tanstack/react-query"; import { posthog } from "@/lib/posthog"; -import { getIsReachable } from "@/lib/server-reachability"; +import { getIsReachable, isNetworkError } from "@/lib/server-reachability"; import { toast } from "@/lib/toast"; const ONE_DAY_MS = 1000 * 60 * 60 * 24; -function isNetworkError(error: Error): boolean { - const msg = error.message.toLowerCase(); - return ( - msg.includes("network request failed") || - msg.includes("fetch failed") || - msg.includes("unable to resolve host") || - msg.includes("could not connect") || - error.name === "TypeError" - ); -} - export const queryClient = new QueryClient({ defaultOptions: { queries: { gcTime: ONE_DAY_MS, + networkMode: "online", retry: (failureCount, error) => { // Don't retry network errors when server is unreachable — the // banner already tells the user and retries will just spam toasts. @@ -28,6 +18,9 @@ export const queryClient = new QueryClient({ return failureCount < 3; }, }, + mutations: { + networkMode: "online", + }, }, queryCache: new QueryCache({ onError: (error) => { diff --git a/apps/native/src/lib/server-reachability.test.ts b/apps/native/src/lib/server-reachability.test.ts new file mode 100644 index 0000000..54ce343 --- /dev/null +++ b/apps/native/src/lib/server-reachability.test.ts @@ -0,0 +1,64 @@ +import { afterEach, expect, mock, test } from "bun:test"; + +mock.module("expo-network", () => ({ + addNetworkStateListener() { + return { + remove() {}, + }; + }, + getNetworkStateAsync: async () => ({ + isConnected: true, + isInternetReachable: true, + }), +})); + +mock.module("react-native", () => ({ + AppState: { + currentState: "active", + addEventListener() { + return { + remove() {}, + }; + }, + }, +})); + +mock.module("@/lib/server-url", () => ({ + onServerUrlChange: () => () => {}, +})); + +afterEach(() => { + mock.restore(); +}); + +test("transport failures set unreachable and any server response clears it", async () => { + const originalFetch = globalThis.fetch; + let requestCount = 0; + + globalThis.fetch = mock(() => { + requestCount += 1; + + if (requestCount === 1) { + return Promise.reject(new TypeError("Network request failed")); + } + + return Promise.resolve(new Response(null, { status: 503 })); + }) as unknown as typeof fetch; + + try { + const { getIsReachable, serverFetch } = await import( + "./server-reachability" + ); + + await expect(serverFetch("http://sofa.test/rpc")).rejects.toThrow( + "Network request failed", + ); + expect(getIsReachable()).toBe(false); + + const response = await serverFetch("http://sofa.test/api/auth/session"); + expect(response.status).toBe(503); + expect(getIsReachable()).toBe(true); + } finally { + globalThis.fetch = originalFetch; + } +}); diff --git a/apps/native/src/lib/server-reachability.ts b/apps/native/src/lib/server-reachability.ts index b04dc62..8e1d055 100644 --- a/apps/native/src/lib/server-reachability.ts +++ b/apps/native/src/lib/server-reachability.ts @@ -1,43 +1,78 @@ +import { focusManager, onlineManager } from "@tanstack/react-query"; +import * as Network from "expo-network"; import { useEffect, useState } from "react"; -import { AppState } from "react-native"; +import { AppState, type AppStateStatus } from "react-native"; -import { getServerUrl, hasStoredServerUrl } from "@/lib/server-url"; +import { onServerUrlChange } from "@/lib/server-url"; -const REACHABLE_POLL_MS = 60_000; -const UNREACHABLE_POLL_MS = 15_000; -const PROBE_TIMEOUT_MS = 5_000; - -// --- Singleton state --- - -let isReachable = true; // Optimistic default +let isReachable = true; const listeners: Array<(reachable: boolean) => void> = []; function notify() { - for (const l of listeners) l(isReachable); + for (const listener of listeners) { + listener(isReachable); + } } -async function probe(): Promise { - if (!hasStoredServerUrl() && !process.env.EXPO_PUBLIC_SERVER_URL) return true; +function setReachable(nextReachable: boolean) { + if (isReachable === nextReachable) { + return; + } - try { - const res = await fetch(`${getServerUrl()}/api/health`, { - signal: AbortSignal.timeout(PROBE_TIMEOUT_MS), - }); - return res.ok; - } catch { + isReachable = nextReachable; + notify(); +} + +function isDeviceOnline(state: Network.NetworkState): boolean { + return !!state.isConnected && state.isInternetReachable !== false; +} + +function syncOnlineState(state: Network.NetworkState) { + onlineManager.setOnline(isDeviceOnline(state)); +} + +function syncAppFocus(state: AppStateStatus) { + focusManager.setFocused(state === "active"); +} + +export function isNetworkError(error: unknown): error is Error { + if (!(error instanceof Error)) { return false; } + + const message = error.message.toLowerCase(); + return ( + message.includes("network request failed") || + message.includes("fetch failed") || + message.includes("load failed") || + message.includes("unable to resolve host") || + message.includes("could not connect") || + message.includes("connection refused") || + message.includes("connection abort") || + message.includes("internet connection appears to be offline") || + message.includes("timed out") + ); } -export async function checkReachability(): Promise { - const wasReachable = isReachable; - isReachable = await probe(); +export async function serverFetch( + input: RequestInfo | URL, + init?: RequestInit, +): Promise { + try { + const response = await fetch(input, init); - if (wasReachable !== isReachable) { - notify(); + // Any HTTP response proves we reached the server, even if the + // endpoint itself returned a 4xx/5xx application error. + setReachable(true); + + return response; + } catch (error) { + if (isNetworkError(error)) { + setReachable(false); + } + + throw error; } - - return isReachable; } export function getIsReachable(): boolean { @@ -48,52 +83,53 @@ export function onServerReachabilityChange( callback: (reachable: boolean) => void, ): () => void { listeners.push(callback); + return () => { - const idx = listeners.indexOf(callback); - if (idx !== -1) listeners.splice(idx, 1); + const index = listeners.indexOf(callback); + if (index !== -1) { + listeners.splice(index, 1); + } }; } -// --- Polling + AppState lifecycle --- - -let pollTimer: ReturnType | null = null; - -function schedulePoll() { - if (pollTimer) clearTimeout(pollTimer); - const interval = isReachable ? REACHABLE_POLL_MS : UNREACHABLE_POLL_MS; - pollTimer = setTimeout(async () => { - await checkReachability(); - schedulePoll(); - }, interval); -} - export function startReachabilityMonitor(): () => void { - checkReachability().then(() => schedulePoll()); + syncAppFocus(AppState.currentState); + void Network.getNetworkStateAsync().then(syncOnlineState); - const sub = AppState.addEventListener("change", (nextState) => { - if (nextState === "active") { - checkReachability(); - } + const networkSubscription = Network.addNetworkStateListener(syncOnlineState); + + const appStateSubscription = AppState.addEventListener( + "change", + (nextState) => { + syncAppFocus(nextState); + + if (nextState === "active") { + void Network.getNetworkStateAsync().then(syncOnlineState); + } + }, + ); + + const removeServerUrlListener = onServerUrlChange(() => { + setReachable(true); + void Network.getNetworkStateAsync().then(syncOnlineState); }); return () => { - if (pollTimer) { - clearTimeout(pollTimer); - pollTimer = null; - } - sub.remove(); + networkSubscription.remove(); + appStateSubscription.remove(); + removeServerUrlListener(); + focusManager.setFocused(undefined); + onlineManager.setOnline(true); }; } -// --- React hook --- - export function useServerReachability() { - const [reachable, setReachable] = useState(isReachable); + const [reachable, setReachableState] = useState(isReachable); useEffect(() => { - setReachable(isReachable); - return onServerReachabilityChange(setReachable); + setReachableState(isReachable); + return onServerReachabilityChange(setReachableState); }, []); - return { isReachable: reachable, retry: checkReachability }; + return { isReachable: reachable }; }