From 6ccd417f2ad4f588fe88218fb53df5f1695534f4 Mon Sep 17 00:00:00 2001 From: Marcus Rehbock Date: Fri, 7 Aug 2026 23:50:34 -0700 Subject: [PATCH] Offline UX: network failures show quiet 'offline, n queued' not errors; auto-sync on connectivity regain + 45s heartbeat Co-Authored-By: Claude Fable 5 --- app/package-lock.json | 11 +++++++++++ app/package.json | 1 + app/src/app/_layout.tsx | 19 ++++++++++++++++--- app/src/app/index.tsx | 6 ++++++ app/src/lib/store.ts | 11 +++++++++-- 5 files changed, 43 insertions(+), 5 deletions(-) diff --git a/app/package-lock.json b/app/package-lock.json index da8f13a..c83f790 100644 --- a/app/package-lock.json +++ b/app/package-lock.json @@ -10,6 +10,7 @@ "dependencies": { "@expo/ui": "~57.0.9", "@react-native-async-storage/async-storage": "2.2.0", + "@react-native-community/netinfo": "12.0.1", "expo": "~57.0.11", "expo-constants": "~57.0.9", "expo-crypto": "~57.0.1", @@ -2194,6 +2195,16 @@ "react-native": "^0.0.0-0 || >=0.65 <1.0" } }, + "node_modules/@react-native-community/netinfo": { + "version": "12.0.1", + "resolved": "https://registry.npmjs.org/@react-native-community/netinfo/-/netinfo-12.0.1.tgz", + "integrity": "sha512-P/3caXIvfYSJG8AWJVefukg+ZGRPs+M4Lp3pNJtgcTYoJxCjWrKQGNnCkj/Cz//zWa/avGed0i/wzm0T8vV2IQ==", + "license": "MIT", + "peerDependencies": { + "react": "*", + "react-native": ">=0.59" + } + }, "node_modules/@react-native-masked-view/masked-view": { "version": "0.3.2", "resolved": "https://registry.npmjs.org/@react-native-masked-view/masked-view/-/masked-view-0.3.2.tgz", diff --git a/app/package.json b/app/package.json index 28f0dcc..e49905b 100644 --- a/app/package.json +++ b/app/package.json @@ -5,6 +5,7 @@ "dependencies": { "@expo/ui": "~57.0.9", "@react-native-async-storage/async-storage": "2.2.0", + "@react-native-community/netinfo": "12.0.1", "expo": "~57.0.11", "expo-constants": "~57.0.9", "expo-crypto": "~57.0.1", diff --git a/app/src/app/_layout.tsx b/app/src/app/_layout.tsx index 47e62c6..69948af 100644 --- a/app/src/app/_layout.tsx +++ b/app/src/app/_layout.tsx @@ -1,19 +1,32 @@ +import NetInfo from "@react-native-community/netinfo"; import { Link, Stack } from "expo-router"; import { StatusBar } from "expo-status-bar"; import { useEffect } from "react"; import { AppState } from "react-native"; import { GestureHandlerRootView } from "react-native-gesture-handler"; -import { hydrate, sync } from "../lib/store"; +import { getState, hydrate, sync } from "../lib/store"; import { C } from "../lib/theme"; export default function RootLayout() { useEffect(() => { void hydrate(); // Re-sync whenever the app returns to the foreground. - const sub = AppState.addEventListener("change", (s) => { + const appState = AppState.addEventListener("change", (s) => { if (s === "active") void sync(); }); - return () => sub.remove(); + // Drain the queue the moment connectivity returns (wifi/cellular). + const net = NetInfo.addEventListener((s) => { + if (s.isConnected) void sync(); + }); + // Slow heartbeat as a belt-and-braces retry while changes are queued. + const heartbeat = setInterval(() => { + if (getState().pending > 0 || getState().offline) void sync(); + }, 45_000); + return () => { + appState.remove(); + net(); + clearInterval(heartbeat); + }; }, []); return ( diff --git a/app/src/app/index.tsx b/app/src/app/index.tsx index ef33161..4bb83d8 100644 --- a/app/src/app/index.tsx +++ b/app/src/app/index.tsx @@ -38,6 +38,11 @@ export default function Home() { {overdue.length} overdue → )} + {store.offline && ( + + offline{store.pending > 0 ? ` — ${store.pending} change${store.pending === 1 ? "" : "s"} queued` : ""} + + )} {store.syncError && {store.syncError}} @@ -76,6 +81,7 @@ const s = StyleSheet.create({ viewAll: { color: C.muted, fontSize: 14, padding: 16 }, overdue: { color: C.danger, fontSize: 14, paddingHorizontal: 16, paddingBottom: 8 }, error: { color: C.danger, paddingHorizontal: 16, paddingTop: 8 }, + offline: { color: C.muted, fontSize: 13, paddingHorizontal: 16, paddingTop: 8 }, menu: { flexDirection: "row", borderTopWidth: StyleSheet.hairlineWidth, diff --git a/app/src/lib/store.ts b/app/src/lib/store.ts index 9308820..5ddd70a 100644 --- a/app/src/lib/store.ts +++ b/app/src/lib/store.ts @@ -18,6 +18,8 @@ export interface State { hydrated: boolean; syncing: boolean; pending: number; + /** True when the last sync failed for network reasons — expected offline. */ + offline: boolean; syncError: string | null; } @@ -34,6 +36,7 @@ let state: State = { hydrated: false, syncing: false, pending: 0, + offline: false, syncError: null, }; let queue: Op[] = []; @@ -193,9 +196,13 @@ export async function sync() { setState({}); } await pull(); - setState({ syncError: null }); + setState({ syncError: null, offline: false }); } catch (e) { - setState({ syncError: e instanceof Error ? e.message : String(e) }); + const msg = e instanceof Error ? e.message : String(e); + // fetch() network failures (airplane mode, no wifi, DNS) are the expected + // offline case, not an error — the queue simply waits for connectivity. + const isNetwork = e instanceof TypeError || /network request failed|unable to resolve|fetch failed|timeout/i.test(msg); + setState(isNetwork ? { offline: true, syncError: null } : { syncError: msg }); } finally { syncRunning = false; setState({ syncing: false });