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 { 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
</Text>
<Pressable
onPress={retry}
onPress={handleRetry}
className="ml-1 rounded-md bg-white/20 px-2 py-0.5"
>
<Text className="font-sans-medium text-[12px] text-white">Retry</Text>
@@ -63,7 +75,7 @@ export function ServerUnreachableBanner() {
return (
<Animated.View
entering={SlideInUp.duration(300).springify().damping(18)}
entering={SlideInUp.duration(300)}
exiting={SlideOutUp.duration(250)}
style={{
position: "absolute",
@@ -5,11 +5,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);
@@ -40,12 +42,22 @@ 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;
return (
<Animated.View
entering={SlideInUp.duration(300).damping(18)}
entering={SlideInUp.duration(300)}
exiting={SlideOutUp.duration(250)}
style={{
position: "absolute",
@@ -61,7 +73,7 @@ export function ServerUnreachableBanner() {
Can't reach server
</Text>
<Pressable
onPress={retry}
onPress={handleRetry}
className="ml-1 rounded-md bg-white/20 px-2 py-0.5"
>
<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 { 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(),
+4 -4
View File
@@ -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 {};
+5 -12
View File
@@ -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) => {
@@ -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 { 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<boolean> {
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<boolean> {
const wasReachable = isReachable;
isReachable = await probe();
export async function serverFetch(
input: RequestInfo | URL,
init?: RequestInit,
): Promise<Response> {
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<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 {
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 };
}