diff --git a/src/app/index.js b/src/app/index.js
index 228ad6c..440afe5 100644
--- a/src/app/index.js
+++ b/src/app/index.js
@@ -29,6 +29,7 @@ import {
} from "react-native-safe-area-context";
import useTrackLocation from "~/hooks/useTrackLocation";
+import useWsWatchdog from "~/hooks/useWsWatchdog";
// import { initializeBackgroundFetch } from "~/services/backgroundFetch";
import useMount from "~/hooks/useMount";
@@ -224,6 +225,7 @@ function AppContent() {
useUpdates();
useNetworkListener();
useTrackLocation();
+ useWsWatchdog();
// useMount(() => {
// const setupBackgroundFetch = async () => {
diff --git a/src/hooks/useLatestWithSubscription.js b/src/hooks/useLatestWithSubscription.js
index e0c2574..71bb564 100644
--- a/src/hooks/useLatestWithSubscription.js
+++ b/src/hooks/useLatestWithSubscription.js
@@ -47,6 +47,7 @@ export default function useLatestWithSubscription(
const retryCountRef = useRef(0);
const subscriptionErrorRef = useRef(null);
const timeoutIdRef = useRef(null);
+ const unsubscribeRef = useRef(null);
useEffect(() => {
const currentVarsHash = JSON.stringify(variables);
@@ -134,6 +135,17 @@ export default function useLatestWithSubscription(
if (!subscribeToMore) return;
if (highestIdRef.current === null) return; // Wait until we have the highest ID
+ // Always cleanup any previous active subscription before creating a new one.
+ // React only runs the cleanup returned directly from the effect.
+ if (unsubscribeRef.current) {
+ try {
+ unsubscribeRef.current();
+ } catch (_e) {
+ // ignore
+ }
+ unsubscribeRef.current = null;
+ }
+
// Check if max retries reached and we have an error
if (retryCountRef.current >= maxRetries && subscriptionErrorRef.current) {
console.error(
@@ -283,15 +295,7 @@ export default function useLatestWithSubscription(
},
});
- // Cleanup on unmount or re-run
- return () => {
- console.log(`[${subscriptionKey}] Cleaning up subscription`);
- if (timeoutIdRef.current) {
- clearTimeout(timeoutIdRef.current);
- timeoutIdRef.current = null;
- }
- unsubscribe();
- };
+ unsubscribeRef.current = unsubscribe;
} catch (error) {
// Handle setup errors (like malformed queries)
console.error(
@@ -331,22 +335,24 @@ export default function useLatestWithSubscription(
console.error("Failed to report to Sentry:", sentryError);
}
}
-
- return () => {
- if (timeoutIdRef.current) {
- clearTimeout(timeoutIdRef.current);
- timeoutIdRef.current = null;
- }
- };
}
}, backoffDelay);
// Cleanup function that will run when component unmounts or effect re-runs
return () => {
+ console.log(`[${subscriptionKey}] Cleaning up subscription`);
if (timeoutIdRef.current) {
clearTimeout(timeoutIdRef.current);
timeoutIdRef.current = null;
}
+ if (unsubscribeRef.current) {
+ try {
+ unsubscribeRef.current();
+ } catch (_e) {
+ // ignore
+ }
+ unsubscribeRef.current = null;
+ }
};
}, [
skip,
diff --git a/src/hooks/useStreamQueryWithSubscription.js b/src/hooks/useStreamQueryWithSubscription.js
index 9b86fa2..1474dfe 100644
--- a/src/hooks/useStreamQueryWithSubscription.js
+++ b/src/hooks/useStreamQueryWithSubscription.js
@@ -40,6 +40,7 @@ export default function useStreamQueryWithSubscription(
const retryCountRef = useRef(0);
const subscriptionErrorRef = useRef(null);
const timeoutIdRef = useRef(null);
+ const unsubscribeRef = useRef(null);
useEffect(() => {
const currentVarsHash = JSON.stringify(variables);
@@ -124,6 +125,18 @@ export default function useStreamQueryWithSubscription(
if (skip) return; // If skipping, do nothing
if (!subscribeToMore) return;
+ // If we're about to (re)subscribe, always cleanup any previous subscription first.
+ // This is critical because React effect cleanups must be returned synchronously
+ // from the effect, not from inside async callbacks.
+ if (unsubscribeRef.current) {
+ try {
+ unsubscribeRef.current();
+ } catch (_e) {
+ // ignore
+ }
+ unsubscribeRef.current = null;
+ }
+
// Check if max retries reached and we have an error - this check must be done regardless of other conditions
if (retryCountRef.current >= maxRetries && subscriptionErrorRef.current) {
console.error(
@@ -289,15 +302,7 @@ export default function useStreamQueryWithSubscription(
},
});
- // Cleanup on unmount or re-run
- return () => {
- console.log(`[${subscriptionKey}] Cleaning up subscription`);
- if (timeoutIdRef.current) {
- clearTimeout(timeoutIdRef.current);
- timeoutIdRef.current = null;
- }
- unsubscribe();
- };
+ unsubscribeRef.current = unsubscribe;
} catch (error) {
// Handle setup errors (like malformed queries)
console.error(
@@ -337,22 +342,24 @@ export default function useStreamQueryWithSubscription(
console.error("Failed to report to Sentry:", sentryError);
}
}
-
- return () => {
- if (timeoutIdRef.current) {
- clearTimeout(timeoutIdRef.current);
- timeoutIdRef.current = null;
- }
- };
}
}, backoffDelay);
// Cleanup function that will run when component unmounts or effect re-runs
return () => {
+ console.log(`[${subscriptionKey}] Cleaning up subscription`);
if (timeoutIdRef.current) {
clearTimeout(timeoutIdRef.current);
timeoutIdRef.current = null;
}
+ if (unsubscribeRef.current) {
+ try {
+ unsubscribeRef.current();
+ } catch (_e) {
+ // ignore
+ }
+ unsubscribeRef.current = null;
+ }
};
}, [
skip,
diff --git a/src/hooks/useWsWatchdog.js b/src/hooks/useWsWatchdog.js
new file mode 100644
index 0000000..dec52c5
--- /dev/null
+++ b/src/hooks/useWsWatchdog.js
@@ -0,0 +1,74 @@
+import { useEffect, useRef } from "react";
+import { useNetworkState, networkActions } from "~/stores";
+import network from "~/network";
+import { createLogger } from "~/lib/logger";
+import { NETWORK_SCOPES } from "~/lib/logger/scopes";
+
+const watchdogLogger = createLogger({
+ module: NETWORK_SCOPES.WEBSOCKET,
+ feature: "watchdog",
+});
+
+const HEARTBEAT_STALE_MS = 45_000;
+const CHECK_EVERY_MS = 10_000;
+const MIN_RESTART_INTERVAL_MS = 30_000;
+
+export default function useWsWatchdog({ enabled = true } = {}) {
+ const { wsConnected, wsLastHeartbeatDate, hasInternetConnection } =
+ useNetworkState([
+ "wsConnected",
+ "wsLastHeartbeatDate",
+ "hasInternetConnection",
+ ]);
+
+ const lastRestartRef = useRef(0);
+
+ useEffect(() => {
+ if (!enabled) return;
+
+ const interval = setInterval(() => {
+ if (!hasInternetConnection) return;
+ if (!wsConnected) return;
+ if (!wsLastHeartbeatDate) return;
+
+ const last = Date.parse(wsLastHeartbeatDate);
+ if (!Number.isFinite(last)) return;
+
+ const age = Date.now() - last;
+ if (age < HEARTBEAT_STALE_MS) return;
+
+ const now = Date.now();
+ if (now - lastRestartRef.current < MIN_RESTART_INTERVAL_MS) return;
+ lastRestartRef.current = now;
+
+ watchdogLogger.warn("WS heartbeat stale, triggering recovery", {
+ ageMs: age,
+ lastHeartbeatDate: wsLastHeartbeatDate,
+ });
+
+ try {
+ // First line recovery: restart websocket transport
+ network.apolloClient?.restartWS?.();
+ } catch (error) {
+ watchdogLogger.error("WS restart failed", { error });
+ }
+
+ // Second line recovery: if WS stays stale, do a full client reload
+ setTimeout(() => {
+ const last2 = Date.parse(wsLastHeartbeatDate);
+ const age2 = Number.isFinite(last2) ? Date.now() - last2 : Infinity;
+ if (age2 >= HEARTBEAT_STALE_MS) {
+ watchdogLogger.warn(
+ "WS still stale after restart, triggering reload",
+ {
+ ageMs: age2,
+ },
+ );
+ networkActions.triggerReload();
+ }
+ }, 10_000);
+ }, CHECK_EVERY_MS);
+
+ return () => clearInterval(interval);
+ }, [enabled, hasInternetConnection, wsConnected, wsLastHeartbeatDate]);
+}
diff --git a/src/network/wsLink.js b/src/network/wsLink.js
index 655be13..2d4a9bd 100644
--- a/src/network/wsLink.js
+++ b/src/network/wsLink.js
@@ -81,6 +81,7 @@ export default function createWsLink({ store, GRAPHQL_WS_URL }) {
activeSocket = socket;
reconnectAttempts = 0; // Reset attempts on successful connection
networkActions.WSConnected();
+ networkActions.WSTouch();
cancelReconnect(); // Cancel any pending reconnects
// Clear any lingering ping timeouts
@@ -114,6 +115,7 @@ export default function createWsLink({ store, GRAPHQL_WS_URL }) {
},
ping: (received) => {
// wsLogger.debug("WebSocket ping", { received });
+ networkActions.WSTouch();
if (!received) {
// Clear any existing ping timeout
if (pingTimeout) {
@@ -138,6 +140,7 @@ export default function createWsLink({ store, GRAPHQL_WS_URL }) {
},
pong: (received) => {
// wsLogger.debug("WebSocket pong", { received });
+ networkActions.WSTouch();
if (received) {
clearTimeout(pingTimeout); // pong is received, clear connection close timeout
}
diff --git a/src/scenes/Params/index.js b/src/scenes/Params/index.js
index 9639475..976f105 100644
--- a/src/scenes/Params/index.js
+++ b/src/scenes/Params/index.js
@@ -17,11 +17,14 @@ export default withConnectivity(function Params() {
deviceId,
},
});
- if (loading || !data) {
+ if (loading) {
return ;
}
if (error) {
return ;
}
+ if (!data) {
+ return ;
+ }
return ;
});
diff --git a/src/scenes/Profile/index.js b/src/scenes/Profile/index.js
index a4ddb2d..5c8e734 100644
--- a/src/scenes/Profile/index.js
+++ b/src/scenes/Profile/index.js
@@ -4,6 +4,7 @@ import { ScrollView, View } from "react-native";
import Loader from "~/components/Loader";
import { useSubscription } from "@apollo/client";
+import Error from "~/components/Error";
import { LOAD_PROFILE_SUBSCRIPTION } from "./gql";
@@ -23,7 +24,7 @@ const profileLogger = createLogger({
export default withConnectivity(function Profile({ navigation, route }) {
const { userId } = useSessionState(["userId"]);
// profileLogger.debug("Profile user ID", { userId });
- const { data, loading, restart } = useSubscription(
+ const { data, loading, error, restart } = useSubscription(
LOAD_PROFILE_SUBSCRIPTION,
{
variables: {
@@ -44,10 +45,21 @@ export default withConnectivity(function Profile({ navigation, route }) {
});
}, [navigation]);
- if (loading || !data?.selectOneUser) {
+ if (loading) {
return ;
}
+ if (error) {
+ profileLogger.error("Profile subscription error", { error });
+ return ;
+ }
+
+ if (!data?.selectOneUser) {
+ // No error surfaced, but no payload either. Avoid infinite loader.
+ profileLogger.error("Profile subscription returned no user", { userId });
+ return ;
+ }
+
return (
{
wsConnected: false,
wsConnectedDate: null,
wsClosedDate: null,
+ wsLastHeartbeatDate: null,
triggerReload: false,
initialized: true,
hasInternetConnection: true,
@@ -27,6 +28,7 @@ export default createAtom(({ merge, get }) => {
merge({
wsConnected: true,
wsConnectedDate: new Date().toISOString(),
+ wsLastHeartbeatDate: new Date().toISOString(),
});
},
WSClosed: () => {
@@ -40,6 +42,12 @@ export default createAtom(({ merge, get }) => {
wsClosedDate: new Date().toISOString(),
});
},
+ WSTouch: () => {
+ // Update whenever we get any WS-level signal: connected, ping/pong, or a message.
+ merge({
+ wsLastHeartbeatDate: new Date().toISOString(),
+ });
+ },
setHasInternetConnection: (status) =>
merge({ hasInternetConnection: status }),
},