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 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,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(),
|
||||||
|
|||||||
@@ -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 {};
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -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 };
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user