refactor(native): replace polling-based server reachability with passive fetch interception

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.
This commit is contained in:
2026-03-16 11:03:24 -04:00
parent 88780c337e
commit 3e9d7efd94
7 changed files with 199 additions and 78 deletions
@@ -6,11 +6,13 @@ import { Pressable, View } from "react-native";
import Animated, { SlideInUp, SlideOutUp } from "react-native-reanimated"; import Animated, { SlideInUp, SlideOutUp } from "react-native-reanimated";
import { useSafeAreaInsets } from "react-native-safe-area-context"; import { useSafeAreaInsets } from "react-native-safe-area-context";
import { Text } from "@/components/ui/text"; 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 { useServerReachability } from "@/lib/server-reachability";
import * as Haptics from "@/utils/haptics"; import * as Haptics from "@/utils/haptics";
export function ServerUnreachableBanner() { export function ServerUnreachableBanner() {
const { isReachable, retry } = useServerReachability(); const { isReachable } = useServerReachability();
const insets = useSafeAreaInsets(); const insets = useSafeAreaInsets();
const wasReachable = useRef(true); const wasReachable = useRef(true);
@@ -41,6 +43,16 @@ export function ServerUnreachableBanner() {
wasReachable.current = isReachable; wasReachable.current = isReachable;
}, [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 // Only show when device has internet but server is unreachable
if (isReachable || !isDeviceOnline) return null; if (isReachable || !isDeviceOnline) return null;
@@ -53,7 +65,7 @@ export function ServerUnreachableBanner() {
Can't reach server Can't reach server
</Text> </Text>
<Pressable <Pressable
onPress={retry} onPress={handleRetry}
className="ml-1 rounded-md bg-white/20 px-2 py-0.5" className="ml-1 rounded-md bg-white/20 px-2 py-0.5"
> >
<Text className="font-sans-medium text-[12px] text-white">Retry</Text> <Text className="font-sans-medium text-[12px] text-white">Retry</Text>
@@ -63,7 +75,7 @@ export function ServerUnreachableBanner() {
return ( return (
<Animated.View <Animated.View
entering={SlideInUp.duration(300).springify().damping(18)} entering={SlideInUp.duration(300)}
exiting={SlideOutUp.duration(250)} exiting={SlideOutUp.duration(250)}
style={{ style={{
position: "absolute", position: "absolute",
@@ -5,11 +5,13 @@ import { Pressable, View } from "react-native";
import Animated, { SlideInUp, SlideOutUp } from "react-native-reanimated"; import Animated, { SlideInUp, SlideOutUp } from "react-native-reanimated";
import { useSafeAreaInsets } from "react-native-safe-area-context"; import { useSafeAreaInsets } from "react-native-safe-area-context";
import { Text } from "@/components/ui/text"; 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 { useServerReachability } from "@/lib/server-reachability";
import * as Haptics from "@/utils/haptics"; import * as Haptics from "@/utils/haptics";
export function ServerUnreachableBanner() { export function ServerUnreachableBanner() {
const { isReachable, retry } = useServerReachability(); const { isReachable } = useServerReachability();
const insets = useSafeAreaInsets(); const insets = useSafeAreaInsets();
const wasReachable = useRef(true); const wasReachable = useRef(true);
@@ -40,12 +42,22 @@ export function ServerUnreachableBanner() {
wasReachable.current = isReachable; wasReachable.current = isReachable;
}, [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 // Only show when device has internet but server is unreachable
if (isReachable || !isDeviceOnline) return null; if (isReachable || !isDeviceOnline) return null;
return ( return (
<Animated.View <Animated.View
entering={SlideInUp.duration(300).damping(18)} entering={SlideInUp.duration(300)}
exiting={SlideOutUp.duration(250)} exiting={SlideOutUp.duration(250)}
style={{ style={{
position: "absolute", position: "absolute",
@@ -61,7 +73,7 @@ export function ServerUnreachableBanner() {
Can't reach server Can't reach server
</Text> </Text>
<Pressable <Pressable
onPress={retry} onPress={handleRetry}
className="ml-1 rounded-md bg-white/20 px-2 py-0.5" className="ml-1 rounded-md bg-white/20 px-2 py-0.5"
> >
<Text className="font-sans-medium text-[12px] text-white">Retry</Text> <Text className="font-sans-medium text-[12px] text-white">Retry</Text>
+4
View File
@@ -4,6 +4,7 @@ import { createAuthClient } from "better-auth/react";
import * as SecureStore from "expo-secure-store"; import * as SecureStore from "expo-secure-store";
import { queryClient } from "@/lib/query-client"; import { queryClient } from "@/lib/query-client";
import { serverFetch } from "@/lib/server-reachability";
import { import {
getCurrentInstanceId, getCurrentInstanceId,
getServerUrl, getServerUrl,
@@ -21,6 +22,9 @@ function getStoragePrefix(): string {
function buildAuthClient() { function buildAuthClient() {
return createAuthClient({ return createAuthClient({
baseURL: getServerUrl(), baseURL: getServerUrl(),
fetchOptions: {
customFetchImpl: serverFetch,
},
plugins: [ plugins: [
adminClient(), adminClient(),
genericOAuthClient(), genericOAuthClient(),
+4 -4
View File
@@ -5,16 +5,16 @@ import { createTanstackQueryUtils } from "@orpc/tanstack-query";
import type { contract } from "@sofa/api/contract"; import type { contract } from "@sofa/api/contract";
import { authClient } from "@/lib/auth-client"; import { authClient } from "@/lib/auth-client";
import { serverFetch } from "@/lib/server-reachability";
import { getServerUrl } from "@/lib/server-url"; import { getServerUrl } from "@/lib/server-url";
export const link = new RPCLink({ export const link = new RPCLink({
url: () => `${getServerUrl()}/rpc`, url: () => `${getServerUrl()}/rpc`,
fetch: (url, options) => { fetch: (url, options) =>
return fetch(url, { serverFetch(url, {
...options, ...options,
credentials: process.env.EXPO_OS === "web" ? "include" : "omit", credentials: process.env.EXPO_OS === "web" ? "include" : "omit",
}); }),
},
headers() { headers() {
if (process.env.EXPO_OS === "web") { if (process.env.EXPO_OS === "web") {
return {}; return {};
+5 -12
View File
@@ -1,26 +1,16 @@
import { QueryCache, QueryClient } from "@tanstack/react-query"; import { QueryCache, QueryClient } from "@tanstack/react-query";
import { posthog } from "@/lib/posthog"; import { posthog } from "@/lib/posthog";
import { getIsReachable } from "@/lib/server-reachability"; import { getIsReachable, isNetworkError } from "@/lib/server-reachability";
import { toast } from "@/lib/toast"; import { toast } from "@/lib/toast";
const ONE_DAY_MS = 1000 * 60 * 60 * 24; 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({ export const queryClient = new QueryClient({
defaultOptions: { defaultOptions: {
queries: { queries: {
gcTime: ONE_DAY_MS, gcTime: ONE_DAY_MS,
networkMode: "online",
retry: (failureCount, error) => { retry: (failureCount, error) => {
// Don't retry network errors when server is unreachable — the // Don't retry network errors when server is unreachable — the
// banner already tells the user and retries will just spam toasts. // banner already tells the user and retries will just spam toasts.
@@ -28,6 +18,9 @@ export const queryClient = new QueryClient({
return failureCount < 3; return failureCount < 3;
}, },
}, },
mutations: {
networkMode: "online",
},
}, },
queryCache: new QueryCache({ queryCache: new QueryCache({
onError: (error) => { onError: (error) => {
@@ -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;
}
});
+92 -56
View File
@@ -1,43 +1,78 @@
import { focusManager, onlineManager } from "@tanstack/react-query";
import * as Network from "expo-network";
import { useEffect, useState } from "react"; 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; let isReachable = true;
const UNREACHABLE_POLL_MS = 15_000;
const PROBE_TIMEOUT_MS = 5_000;
// --- Singleton state ---
let isReachable = true; // Optimistic default
const listeners: Array<(reachable: boolean) => void> = []; const listeners: Array<(reachable: boolean) => void> = [];
function notify() { function notify() {
for (const l of listeners) l(isReachable); for (const listener of listeners) {
listener(isReachable);
}
} }
async function probe(): Promise<boolean> { function setReachable(nextReachable: boolean) {
if (!hasStoredServerUrl() && !process.env.EXPO_PUBLIC_SERVER_URL) return true; if (isReachable === nextReachable) {
return;
}
try { isReachable = nextReachable;
const res = await fetch(`${getServerUrl()}/api/health`, { notify();
signal: AbortSignal.timeout(PROBE_TIMEOUT_MS), }
});
return res.ok; function isDeviceOnline(state: Network.NetworkState): boolean {
} catch { 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; 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<boolean> { export async function serverFetch(
const wasReachable = isReachable; input: RequestInfo | URL,
isReachable = await probe(); init?: RequestInit,
): Promise<Response> {
try {
const response = await fetch(input, init);
if (wasReachable !== isReachable) { // Any HTTP response proves we reached the server, even if the
notify(); // 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 { export function getIsReachable(): boolean {
@@ -48,52 +83,53 @@ export function onServerReachabilityChange(
callback: (reachable: boolean) => void, callback: (reachable: boolean) => void,
): () => void { ): () => void {
listeners.push(callback); listeners.push(callback);
return () => { return () => {
const idx = listeners.indexOf(callback); const index = listeners.indexOf(callback);
if (idx !== -1) listeners.splice(idx, 1); if (index !== -1) {
listeners.splice(index, 1);
}
}; };
} }
// --- Polling + AppState lifecycle ---
let pollTimer: ReturnType<typeof setTimeout> | 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 { export function startReachabilityMonitor(): () => void {
checkReachability().then(() => schedulePoll()); syncAppFocus(AppState.currentState);
void Network.getNetworkStateAsync().then(syncOnlineState);
const sub = AppState.addEventListener("change", (nextState) => { const networkSubscription = Network.addNetworkStateListener(syncOnlineState);
if (nextState === "active") {
checkReachability(); 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 () => { return () => {
if (pollTimer) { networkSubscription.remove();
clearTimeout(pollTimer); appStateSubscription.remove();
pollTimer = null; removeServerUrlListener();
} focusManager.setFocused(undefined);
sub.remove(); onlineManager.setOnline(true);
}; };
} }
// --- React hook ---
export function useServerReachability() { export function useServerReachability() {
const [reachable, setReachable] = useState(isReachable); const [reachable, setReachableState] = useState(isReachable);
useEffect(() => { useEffect(() => {
setReachable(isReachable); setReachableState(isReachable);
return onServerReachabilityChange(setReachable); return onServerReachabilityChange(setReachableState);
}, []); }, []);
return { isReachable: reachable, retry: checkReachability }; return { isReachable: reachable };
} }