diff --git a/apps/native/src/app/_layout.tsx b/apps/native/src/app/_layout.tsx
index df62596..4360a03 100644
--- a/apps/native/src/app/_layout.tsx
+++ b/apps/native/src/app/_layout.tsx
@@ -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 (
<>
+
{
+ 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 = (
+ <>
+
+
+ Can't reach server
+
+
+ Retry
+
+ >
+ );
+
+ return (
+
+ {useGlass ? (
+
+ {content}
+
+ ) : (
+
+ {content}
+
+ )}
+
+ );
+}
diff --git a/apps/native/src/components/ui/server-unreachable-banner.tsx b/apps/native/src/components/ui/server-unreachable-banner.tsx
new file mode 100644
index 0000000..3d5c85a
--- /dev/null
+++ b/apps/native/src/components/ui/server-unreachable-banner.tsx
@@ -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 (
+
+
+
+
+ Can't reach server
+
+
+ Retry
+
+
+
+ );
+}
diff --git a/apps/native/src/lib/cached-session.ts b/apps/native/src/lib/cached-session.ts
new file mode 100644
index 0000000..4817b7a
--- /dev/null
+++ b/apps/native/src/lib/cached-session.ts
@@ -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;
+ }
+}
diff --git a/apps/native/src/lib/server-reachability.ts b/apps/native/src/lib/server-reachability.ts
new file mode 100644
index 0000000..b04dc62
--- /dev/null
+++ b/apps/native/src/lib/server-reachability.ts
@@ -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 {
+ 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 {
+ 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 | 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 };
+}