feat(native): gracefully handle unreachable servers on app startup

Seed the Better Auth session atom from SecureStore cache before React
renders so the app shows cached data instantly instead of hanging on the
splash screen for 30-60s when the server is down. Add a server
reachability monitor that probes /api/health and displays an amber
"Can't reach server" banner (distinct from the offline banner). When the
server recovers, the session is re-validated automatically — if expired,
a toast explains the sign-out.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-14 13:52:38 -04:00
co-authored by Claude Opus 4.6
parent c6cd061175
commit 9b29da21bf
5 changed files with 375 additions and 1 deletions
+64 -1
View File
@@ -10,13 +10,15 @@ import {
requestTrackingPermissionsAsync,
} from "expo-tracking-transparency";
import { PostHogErrorBoundary, PostHogProvider } from "posthog-react-native";
import { useEffect, useState } from "react";
import { useEffect, useRef, useState } from "react";
import { GestureHandlerRootView } from "react-native-gesture-handler";
import { KeyboardProvider } from "react-native-keyboard-controller";
import { Uniwind, useResolveClassNames } from "uniwind";
import { OfflineBanner } from "@/components/ui/offline-banner";
import { ServerUnreachableBanner } from "@/components/ui/server-unreachable-banner";
import { authClient, rebuildAuthClient } from "@/lib/auth-client";
import { getCachedSession } from "@/lib/cached-session";
import {
clearStorageScope,
hasScopedStorage,
@@ -26,15 +28,37 @@ import {
} from "@/lib/mmkv";
import { applyTrackingTransparency, posthog } from "@/lib/posthog";
import { queryClient } from "@/lib/query-client";
import {
onServerReachabilityChange,
startReachabilityMonitor,
} from "@/lib/server-reachability";
import {
ensureInstanceId,
getCurrentInstanceId,
hasStoredServerUrl,
onServerUrlChange,
} from "@/lib/server-url";
import { toast } from "@/lib/toast";
SplashScreen.preventAutoHideAsync();
// Seed the session atom with cached data from SecureStore before React renders.
// This allows the app to show cached data immediately when the server is
// unreachable, instead of hanging on the splash screen or dumping the user
// on the login screen. Better Auth's background fetch will still fire and
// update the atom when the server responds.
const cachedSession = getCachedSession();
if (cachedSession) {
const sessionAtom = authClient.$store.atoms.session;
sessionAtom.set({
data: cachedSession,
error: null,
isPending: false,
isRefetching: true,
refetch: sessionAtom.get().refetch,
});
}
export const unstable_settings = {
initialRouteName: "(tabs)",
};
@@ -122,16 +146,55 @@ function AppContent() {
Uniwind.setTheme("dark");
}, []);
// --- Safety splash timeout (belt-and-suspenders if seeding fails) ---
useEffect(() => {
const timer = setTimeout(() => SplashScreen.hideAsync(), 3000);
return () => clearTimeout(timer);
}, []);
useEffect(() => {
if (!isPending || !hasServerUrl) {
SplashScreen.hideAsync();
}
}, [isPending, hasServerUrl]);
// --- Server reachability monitor ---
useEffect(() => {
if (!hasServerUrl) return;
return startReachabilityMonitor();
}, [hasServerUrl]);
// --- Session reconciliation: re-validate when server comes back ---
const hadOptimisticSession = useRef(!!cachedSession);
const prevSession = useRef(session);
useEffect(() => {
return onServerReachabilityChange((reachable) => {
if (reachable) {
// Server came back — trigger session re-validation
const atom = authClient.$store.atoms.session;
atom.get().refetch?.();
}
});
}, []);
// If session was seeded from cache and then invalidated by the server,
// show a toast so the user knows why they were signed out.
useEffect(() => {
if (prevSession.current && !session && hadOptimisticSession.current) {
toast.info("Session expired", {
description: "Please sign in again.",
});
hadOptimisticSession.current = false;
}
prevSession.current = session;
}, [session]);
return (
<>
<StatusBar style="light" />
<OfflineBanner />
<ServerUnreachableBanner />
<Stack
screenOptions={{
headerShown: false,
@@ -0,0 +1,100 @@
import { IconCloudOff } from "@tabler/icons-react-native";
import { GlassView, isLiquidGlassAvailable } from "expo-glass-effect";
import * as Network from "expo-network";
import { useEffect, useRef, useState } from "react";
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 { useServerReachability } from "@/lib/server-reachability";
import * as Haptics from "@/utils/haptics";
export function ServerUnreachableBanner() {
const { isReachable, retry } = useServerReachability();
const insets = useSafeAreaInsets();
const wasReachable = useRef(true);
// Track device connectivity so we don't double up with OfflineBanner
const [isDeviceOnline, setIsDeviceOnline] = useState(true);
useEffect(() => {
let mounted = true;
const handleState = (state: Network.NetworkState) => {
if (!mounted) return;
setIsDeviceOnline(!!state.isConnected && !!state.isInternetReachable);
};
Network.getNetworkStateAsync().then(handleState);
const subscription = Network.addNetworkStateListener(handleState);
return () => {
mounted = false;
subscription.remove();
};
}, []);
useEffect(() => {
if (!isReachable && wasReachable.current) {
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Warning);
}
wasReachable.current = isReachable;
}, [isReachable]);
// Only show when device has internet but server is unreachable
if (isReachable || !isDeviceOnline) return null;
const useGlass = isLiquidGlassAvailable();
const content = (
<>
<IconCloudOff size={16} color="white" />
<Text className="font-sans-medium text-[13px] text-white">
Can't reach server
</Text>
<Pressable
onPress={retry}
className="ml-1 rounded-md bg-white/20 px-2 py-0.5"
>
<Text className="font-sans-medium text-[12px] text-white">Retry</Text>
</Pressable>
</>
);
return (
<Animated.View
entering={SlideInUp.duration(300).springify().damping(18)}
exiting={SlideOutUp.duration(250)}
style={{
position: "absolute",
top: insets.top,
left: 0,
right: 0,
zIndex: 100,
}}
>
{useGlass ? (
<GlassView
glassEffectStyle="regular"
colorScheme="dark"
style={{
marginHorizontal: 16,
flexDirection: "row",
alignItems: "center",
justifyContent: "center",
gap: 8,
borderRadius: 12,
paddingHorizontal: 16,
paddingVertical: 10,
}}
>
{content}
</GlassView>
) : (
<View className="mx-4 flex-row items-center justify-center gap-2 rounded-xl bg-status-watching px-4 py-2.5">
{content}
</View>
)}
</Animated.View>
);
}
@@ -0,0 +1,72 @@
import { IconCloudOff } from "@tabler/icons-react-native";
import * as Network from "expo-network";
import { useEffect, useRef, useState } from "react";
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 { useServerReachability } from "@/lib/server-reachability";
import * as Haptics from "@/utils/haptics";
export function ServerUnreachableBanner() {
const { isReachable, retry } = useServerReachability();
const insets = useSafeAreaInsets();
const wasReachable = useRef(true);
// Track device connectivity so we don't double up with OfflineBanner
const [isDeviceOnline, setIsDeviceOnline] = useState(true);
useEffect(() => {
let mounted = true;
const handleState = (state: Network.NetworkState) => {
if (!mounted) return;
setIsDeviceOnline(!!state.isConnected && !!state.isInternetReachable);
};
Network.getNetworkStateAsync().then(handleState);
const subscription = Network.addNetworkStateListener(handleState);
return () => {
mounted = false;
subscription.remove();
};
}, []);
useEffect(() => {
if (!isReachable && wasReachable.current) {
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Warning);
}
wasReachable.current = isReachable;
}, [isReachable]);
// Only show when device has internet but server is unreachable
if (isReachable || !isDeviceOnline) return null;
return (
<Animated.View
entering={SlideInUp.duration(300).springify().damping(18)}
exiting={SlideOutUp.duration(250)}
style={{
position: "absolute",
top: insets.top,
left: 0,
right: 0,
zIndex: 100,
}}
>
<View className="mx-4 flex-row items-center justify-center gap-2 rounded-xl bg-status-watching px-4 py-2.5">
<IconCloudOff size={16} color="white" />
<Text className="font-sans-medium text-[13px] text-white">
Can't reach server
</Text>
<Pressable
onPress={retry}
className="ml-1 rounded-md bg-white/20 px-2 py-0.5"
>
<Text className="font-sans-medium text-[12px] text-white">Retry</Text>
</Pressable>
</View>
</Animated.View>
);
}
+40
View File
@@ -0,0 +1,40 @@
import * as SecureStore from "expo-secure-store";
import { getCurrentInstanceId } from "@/lib/server-url";
interface CachedSessionData {
session: { id: string; expiresAt?: string; [key: string]: unknown };
user: { id: string; [key: string]: unknown };
}
/**
* Synchronously reads the cached session from SecureStore.
* Better Auth's Expo plugin writes session data here on every successful
* /get-session response, but never reads it back as a fallback.
* We use it to seed the session atom before React renders so the app
* can show cached data immediately when the server is unreachable.
*/
export function getCachedSession(): CachedSessionData | null {
const instanceId = getCurrentInstanceId();
if (!instanceId) return null;
const key = `sofa_${instanceId}_session_data`;
try {
const raw = SecureStore.getItem(key);
if (!raw) return null;
const data = JSON.parse(raw);
if (!data?.session?.id || !data?.user?.id) return null;
// Reject obviously expired sessions
if (data.session.expiresAt) {
const expiry = new Date(data.session.expiresAt).getTime();
if (expiry < Date.now()) return null;
}
return data as CachedSessionData;
} catch {
return null;
}
}
@@ -0,0 +1,99 @@
import { useEffect, useState } from "react";
import { AppState } from "react-native";
import { getServerUrl, hasStoredServerUrl } 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
const listeners: Array<(reachable: boolean) => void> = [];
function notify() {
for (const l of listeners) l(isReachable);
}
async function probe(): Promise<boolean> {
if (!hasStoredServerUrl() && !process.env.EXPO_PUBLIC_SERVER_URL) return true;
try {
const res = await fetch(`${getServerUrl()}/api/health`, {
signal: AbortSignal.timeout(PROBE_TIMEOUT_MS),
});
return res.ok;
} catch {
return false;
}
}
export async function checkReachability(): Promise<boolean> {
const wasReachable = isReachable;
isReachable = await probe();
if (wasReachable !== isReachable) {
notify();
}
return isReachable;
}
export function getIsReachable(): boolean {
return isReachable;
}
export function onServerReachabilityChange(
callback: (reachable: boolean) => void,
): () => void {
listeners.push(callback);
return () => {
const idx = listeners.indexOf(callback);
if (idx !== -1) listeners.splice(idx, 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());
const sub = AppState.addEventListener("change", (nextState) => {
if (nextState === "active") {
checkReachability();
}
});
return () => {
if (pollTimer) {
clearTimeout(pollTimer);
pollTimer = null;
}
sub.remove();
};
}
// --- React hook ---
export function useServerReachability() {
const [reachable, setReachable] = useState(isReachable);
useEffect(() => {
setReachable(isReachable);
return onServerReachabilityChange(setReachable);
}, []);
return { isReachable: reachable, retry: checkReachability };
}