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