mirror of
https://github.com/jakejarvis/sofa.git
synced 2026-07-14 18:15:56 -04:00
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:
@@ -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,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(),
|
||||
|
||||
@@ -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 {};
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
});
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user