From 42d5b18b35577eb438f64b38d309f65ea30b7e3f Mon Sep 17 00:00:00 2001 From: devthejo Date: Mon, 19 Jan 2026 22:34:48 +0100 Subject: [PATCH] fix(ws): stabilization try 7 --- src/app/useNetworkListener.js | 14 ++++- src/hooks/useLatestWithSubscription.js | 62 ++++++++++++++++++++- src/hooks/useStreamQueryWithSubscription.js | 62 ++++++++++++++++++++- src/network/wsLink.js | 24 +++++++- 4 files changed, 154 insertions(+), 8 deletions(-) diff --git a/src/app/useNetworkListener.js b/src/app/useNetworkListener.js index aab9a94..509d5a4 100644 --- a/src/app/useNetworkListener.js +++ b/src/app/useNetworkListener.js @@ -13,18 +13,28 @@ export default function useNetworkListener() { const { isConnected, type, isInternetReachable, details } = useNetInfo(); useEffect(() => { + // NetInfo's `isConnected` can be true while the network is not actually usable + // (e.g. captive portal / no route / transient DNS). Prefer `isInternetReachable` + // when it explicitly reports `false`, but keep "unknown" (null/undefined) + // optimistic to avoid false-offline on some devices. + const hasInternetConnection = + Boolean(isConnected) && isInternetReachable !== false; + networkLogger.info("Network connectivity changed", { isConnected, type, isInternetReachable, details, + hasInternetConnection, }); - networkActions.setHasInternetConnection(isConnected); + networkActions.setHasInternetConnection(hasInternetConnection); - if (!isConnected) { + if (!hasInternetConnection) { networkLogger.warn("Network connection lost", { lastConnectionType: type, + isConnected, + isInternetReachable, }); } else { networkLogger.debug("Network connection established", { diff --git a/src/hooks/useLatestWithSubscription.js b/src/hooks/useLatestWithSubscription.js index ac8156a..42c3fe0 100644 --- a/src/hooks/useLatestWithSubscription.js +++ b/src/hooks/useLatestWithSubscription.js @@ -54,6 +54,8 @@ export default function useLatestWithSubscription( // State to force re-render and retry subscription const [retryTrigger, setRetryTrigger] = useState(0); const [reconnectSyncTrigger, setReconnectSyncTrigger] = useState(0); + // State to force a resubscribe when returning to foreground (mobile lock/unlock). + const [foregroundKick, setForegroundKick] = useState(0); const variableHashRef = useRef(JSON.stringify(variables)); const highestIdRef = useRef(null); @@ -68,12 +70,16 @@ export default function useLatestWithSubscription( // We deliberately do NOT put these in the subscribe effect dependency array. const contextRef = useRef(context); const shouldIncludeItemRef = useRef(shouldIncludeItem); + const subscriptionKeyRef = useRef(subscriptionKey); useEffect(() => { contextRef.current = context; }, [context]); useEffect(() => { shouldIncludeItemRef.current = shouldIncludeItem; }, [shouldIncludeItem]); + useEffect(() => { + subscriptionKeyRef.current = subscriptionKey; + }, [subscriptionKey]); // Per-subscription liveness watchdog const lastSubscriptionDataAtRef = useRef(Date.now()); @@ -84,6 +90,8 @@ export default function useLatestWithSubscription( const wsLastHeartbeatDateRef = useRef(wsLastHeartbeatDate); const appStateRef = useRef(AppState.currentState); const wsLastRecoveryDateRef = useRef(wsLastRecoveryDate); + const lastBecameInactiveAtRef = useRef(null); + const lastForegroundKickAtRef = useRef(0); // Optional refetch-on-reconnect support. // Goal: if WS was reconnected (wsClosedDate changes), force a base refetch once before resubscribing @@ -101,12 +109,56 @@ export default function useLatestWithSubscription( useEffect(() => { const sub = AppState.addEventListener("change", (next) => { + const now = Date.now(); appStateRef.current = next; + + if (next === "background" || next === "inactive") { + lastBecameInactiveAtRef.current = now; + return; + } + if (next === "active") { + const becameInactiveAt = lastBecameInactiveAtRef.current; + const inactiveMs = becameInactiveAt ? now - becameInactiveAt : null; + // Timers may have been paused/throttled; reset stale timers to avoid false kicks. - lastSubscriptionDataAtRef.current = Date.now(); + lastSubscriptionDataAtRef.current = now; lastLivenessKickAtRef.current = 0; consecutiveStaleKicksRef.current = 0; + + // Some devices keep the WS transport "connected" after a lock/unlock, but the + // per-operation subscription stops delivering. Trigger a controlled resubscribe. + const FOREGROUND_KICK_MIN_INACTIVE_MS = 3_000; + const FOREGROUND_KICK_MIN_INTERVAL_MS = 15_000; + + if ( + typeof inactiveMs === "number" && + inactiveMs >= FOREGROUND_KICK_MIN_INACTIVE_MS + ) { + if ( + now - lastForegroundKickAtRef.current >= + FOREGROUND_KICK_MIN_INTERVAL_MS + ) { + lastForegroundKickAtRef.current = now; + try { + Sentry.addBreadcrumb({ + category: "graphql-subscription", + level: "info", + message: "foreground resubscribe kick", + data: { + subscriptionKey: subscriptionKeyRef.current, + inactiveMs, + }, + }); + } catch (_e) { + // ignore + } + console.log( + `[${subscriptionKeyRef.current}] Foreground resubscribe kick (inactiveMs=${inactiveMs})`, + ); + setForegroundKick((x) => x + 1); + } + } } }); return () => sub.remove(); @@ -469,7 +521,12 @@ export default function useLatestWithSubscription( // - either initial setup not done yet // - or a new wsClosedDate (WS reconnect) // - or a retry trigger - if (initialSetupDoneRef.current && !wsClosedDate && retryTrigger === 0) { + if ( + initialSetupDoneRef.current && + !wsClosedDate && + retryTrigger === 0 && + foregroundKick === 0 + ) { return; } @@ -678,6 +735,7 @@ export default function useLatestWithSubscription( cursorKey, subscriptionKey, retryTrigger, + foregroundKick, maxRetries, livenessStaleMs, livenessCheckEveryMs, diff --git a/src/hooks/useStreamQueryWithSubscription.js b/src/hooks/useStreamQueryWithSubscription.js index 4d0aa24..48e10ba 100644 --- a/src/hooks/useStreamQueryWithSubscription.js +++ b/src/hooks/useStreamQueryWithSubscription.js @@ -55,6 +55,8 @@ export default function useStreamQueryWithSubscription( // State to force re-render and retry subscription const [retryTrigger, setRetryTrigger] = useState(0); const [reconnectSyncTrigger, setReconnectSyncTrigger] = useState(0); + // State to force a resubscribe when returning to foreground (mobile lock/unlock). + const [foregroundKick, setForegroundKick] = useState(0); const variableHashRef = useRef(JSON.stringify(variables)); const lastCursorRef = useRef(initialCursor); @@ -69,12 +71,16 @@ export default function useStreamQueryWithSubscription( // We deliberately do NOT put these in the subscribe effect dependency array. const contextRef = useRef(context); const shouldIncludeItemRef = useRef(shouldIncludeItem); + const subscriptionKeyRef = useRef(subscriptionKey); useEffect(() => { contextRef.current = context; }, [context]); useEffect(() => { shouldIncludeItemRef.current = shouldIncludeItem; }, [shouldIncludeItem]); + useEffect(() => { + subscriptionKeyRef.current = subscriptionKey; + }, [subscriptionKey]); // Per-subscription liveness watchdog: if WS is connected but this subscription // hasn't delivered any payload for some time, trigger a resubscribe. @@ -87,6 +93,8 @@ export default function useStreamQueryWithSubscription( const wsLastHeartbeatDateRef = useRef(wsLastHeartbeatDate); const appStateRef = useRef(AppState.currentState); const wsLastRecoveryDateRef = useRef(wsLastRecoveryDate); + const lastBecameInactiveAtRef = useRef(null); + const lastForegroundKickAtRef = useRef(0); // Optional refetch-on-reconnect support. // Goal: if WS was reconnected (wsClosedDate changes), force a base refetch once before resubscribing @@ -104,12 +112,56 @@ export default function useStreamQueryWithSubscription( useEffect(() => { const sub = AppState.addEventListener("change", (next) => { + const now = Date.now(); appStateRef.current = next; + + if (next === "background" || next === "inactive") { + lastBecameInactiveAtRef.current = now; + return; + } + if (next === "active") { + const becameInactiveAt = lastBecameInactiveAtRef.current; + const inactiveMs = becameInactiveAt ? now - becameInactiveAt : null; + // Timers may have been paused/throttled; reset stale timers to avoid false kicks. - lastSubscriptionDataAtRef.current = Date.now(); + lastSubscriptionDataAtRef.current = now; lastLivenessKickAtRef.current = 0; consecutiveStaleKicksRef.current = 0; + + // Some devices keep the WS transport "connected" after a lock/unlock, but the + // per-operation subscription stops delivering. Trigger a controlled resubscribe. + const FOREGROUND_KICK_MIN_INACTIVE_MS = 3_000; + const FOREGROUND_KICK_MIN_INTERVAL_MS = 15_000; + + if ( + typeof inactiveMs === "number" && + inactiveMs >= FOREGROUND_KICK_MIN_INACTIVE_MS + ) { + if ( + now - lastForegroundKickAtRef.current >= + FOREGROUND_KICK_MIN_INTERVAL_MS + ) { + lastForegroundKickAtRef.current = now; + try { + Sentry.addBreadcrumb({ + category: "graphql-subscription", + level: "info", + message: "foreground resubscribe kick", + data: { + subscriptionKey: subscriptionKeyRef.current, + inactiveMs, + }, + }); + } catch (_e) { + // ignore + } + console.log( + `[${subscriptionKeyRef.current}] Foreground resubscribe kick (inactiveMs=${inactiveMs})`, + ); + setForegroundKick((x) => x + 1); + } + } } }); return () => sub.remove(); @@ -513,7 +565,12 @@ export default function useStreamQueryWithSubscription( // - either initial setup not done yet // - or a new wsClosedDate (WS reconnect) // - or a retry trigger - if (initialSetupDoneRef.current && !wsClosedDate && retryTrigger === 0) { + if ( + initialSetupDoneRef.current && + !wsClosedDate && + retryTrigger === 0 && + foregroundKick === 0 + ) { return; } @@ -794,6 +851,7 @@ export default function useStreamQueryWithSubscription( cursorKey, subscriptionKey, retryTrigger, + foregroundKick, maxRetries, livenessStaleMs, livenessCheckEveryMs, diff --git a/src/network/wsLink.js b/src/network/wsLink.js index d07b4a4..5758286 100644 --- a/src/network/wsLink.js +++ b/src/network/wsLink.js @@ -11,8 +11,15 @@ export default function createWsLink({ store, GRAPHQL_WS_URL }) { service: "graphql", }); + const getTokenFingerprint = (token) => { + if (!token || typeof token !== "string") return null; + // Avoid logging full secrets; this is only for comparing changes. + return token.slice(-8); + }; + let activeSocket, pingTimeout; let lastConnectionHadToken = false; + let lastConnectionTokenFingerprint = null; let lastTokenRestartAt = 0; // If we connect before auth is ready, Hasura will treat the whole WS session as unauthenticated @@ -35,6 +42,7 @@ export default function createWsLink({ store, GRAPHQL_WS_URL }) { const headers = {}; lastConnectionHadToken = !!userToken; + lastConnectionTokenFingerprint = getTokenFingerprint(userToken); // Important: only attach Authorization when we have a real token. // Sending `Authorization: Bearer undefined` breaks WS auth on some backends. @@ -176,18 +184,30 @@ export default function createWsLink({ store, GRAPHQL_WS_URL }) { (s) => s?.userToken, (userToken) => { if (!userToken) return; - if (lastConnectionHadToken) return; if (!activeSocket || activeSocket.readyState !== WebSocket.OPEN) return; + const nextFingerprint = getTokenFingerprint(userToken); + const tokenChanged = + !!nextFingerprint && + nextFingerprint !== lastConnectionTokenFingerprint; + + // If we are connected without a token OR the token changed while the socket is open, + // restart the transport so the new token is applied at `connection_init`. + if (lastConnectionHadToken && !tokenChanged) return; + const now = Date.now(); if (now - lastTokenRestartAt < TOKEN_RESTART_MIN_INTERVAL_MS) return; lastTokenRestartAt = now; networkActions.WSRecoveryTouch(); wsLogger.warn( - "Auth token became available; restarting unauthenticated WS", + tokenChanged + ? "Auth token changed; restarting WS to apply new token" + : "Auth token became available; restarting unauthenticated WS", { url: GRAPHQL_WS_URL, + previousTokenFingerprint: lastConnectionTokenFingerprint, + nextTokenFingerprint: nextFingerprint, }, ); try {