fix(ws): stabilization try 7

This commit is contained in:
devthejo 2026-01-19 22:34:48 +01:00
parent 5dfb064c2c
commit 42d5b18b35
No known key found for this signature in database
GPG key ID: 00CCA7A92B1D5351
4 changed files with 154 additions and 8 deletions

View file

@ -13,18 +13,28 @@ export default function useNetworkListener() {
const { isConnected, type, isInternetReachable, details } = useNetInfo(); const { isConnected, type, isInternetReachable, details } = useNetInfo();
useEffect(() => { 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", { networkLogger.info("Network connectivity changed", {
isConnected, isConnected,
type, type,
isInternetReachable, isInternetReachable,
details, details,
hasInternetConnection,
}); });
networkActions.setHasInternetConnection(isConnected); networkActions.setHasInternetConnection(hasInternetConnection);
if (!isConnected) { if (!hasInternetConnection) {
networkLogger.warn("Network connection lost", { networkLogger.warn("Network connection lost", {
lastConnectionType: type, lastConnectionType: type,
isConnected,
isInternetReachable,
}); });
} else { } else {
networkLogger.debug("Network connection established", { networkLogger.debug("Network connection established", {

View file

@ -54,6 +54,8 @@ export default function useLatestWithSubscription(
// State to force re-render and retry subscription // State to force re-render and retry subscription
const [retryTrigger, setRetryTrigger] = useState(0); const [retryTrigger, setRetryTrigger] = useState(0);
const [reconnectSyncTrigger, setReconnectSyncTrigger] = 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 variableHashRef = useRef(JSON.stringify(variables));
const highestIdRef = useRef(null); const highestIdRef = useRef(null);
@ -68,12 +70,16 @@ export default function useLatestWithSubscription(
// We deliberately do NOT put these in the subscribe effect dependency array. // We deliberately do NOT put these in the subscribe effect dependency array.
const contextRef = useRef(context); const contextRef = useRef(context);
const shouldIncludeItemRef = useRef(shouldIncludeItem); const shouldIncludeItemRef = useRef(shouldIncludeItem);
const subscriptionKeyRef = useRef(subscriptionKey);
useEffect(() => { useEffect(() => {
contextRef.current = context; contextRef.current = context;
}, [context]); }, [context]);
useEffect(() => { useEffect(() => {
shouldIncludeItemRef.current = shouldIncludeItem; shouldIncludeItemRef.current = shouldIncludeItem;
}, [shouldIncludeItem]); }, [shouldIncludeItem]);
useEffect(() => {
subscriptionKeyRef.current = subscriptionKey;
}, [subscriptionKey]);
// Per-subscription liveness watchdog // Per-subscription liveness watchdog
const lastSubscriptionDataAtRef = useRef(Date.now()); const lastSubscriptionDataAtRef = useRef(Date.now());
@ -84,6 +90,8 @@ export default function useLatestWithSubscription(
const wsLastHeartbeatDateRef = useRef(wsLastHeartbeatDate); const wsLastHeartbeatDateRef = useRef(wsLastHeartbeatDate);
const appStateRef = useRef(AppState.currentState); const appStateRef = useRef(AppState.currentState);
const wsLastRecoveryDateRef = useRef(wsLastRecoveryDate); const wsLastRecoveryDateRef = useRef(wsLastRecoveryDate);
const lastBecameInactiveAtRef = useRef(null);
const lastForegroundKickAtRef = useRef(0);
// Optional refetch-on-reconnect support. // Optional refetch-on-reconnect support.
// Goal: if WS was reconnected (wsClosedDate changes), force a base refetch once before resubscribing // Goal: if WS was reconnected (wsClosedDate changes), force a base refetch once before resubscribing
@ -101,12 +109,56 @@ export default function useLatestWithSubscription(
useEffect(() => { useEffect(() => {
const sub = AppState.addEventListener("change", (next) => { const sub = AppState.addEventListener("change", (next) => {
const now = Date.now();
appStateRef.current = next; appStateRef.current = next;
if (next === "background" || next === "inactive") {
lastBecameInactiveAtRef.current = now;
return;
}
if (next === "active") { 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. // Timers may have been paused/throttled; reset stale timers to avoid false kicks.
lastSubscriptionDataAtRef.current = Date.now(); lastSubscriptionDataAtRef.current = now;
lastLivenessKickAtRef.current = 0; lastLivenessKickAtRef.current = 0;
consecutiveStaleKicksRef.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(); return () => sub.remove();
@ -469,7 +521,12 @@ export default function useLatestWithSubscription(
// - either initial setup not done yet // - either initial setup not done yet
// - or a new wsClosedDate (WS reconnect) // - or a new wsClosedDate (WS reconnect)
// - or a retry trigger // - or a retry trigger
if (initialSetupDoneRef.current && !wsClosedDate && retryTrigger === 0) { if (
initialSetupDoneRef.current &&
!wsClosedDate &&
retryTrigger === 0 &&
foregroundKick === 0
) {
return; return;
} }
@ -678,6 +735,7 @@ export default function useLatestWithSubscription(
cursorKey, cursorKey,
subscriptionKey, subscriptionKey,
retryTrigger, retryTrigger,
foregroundKick,
maxRetries, maxRetries,
livenessStaleMs, livenessStaleMs,
livenessCheckEveryMs, livenessCheckEveryMs,

View file

@ -55,6 +55,8 @@ export default function useStreamQueryWithSubscription(
// State to force re-render and retry subscription // State to force re-render and retry subscription
const [retryTrigger, setRetryTrigger] = useState(0); const [retryTrigger, setRetryTrigger] = useState(0);
const [reconnectSyncTrigger, setReconnectSyncTrigger] = 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 variableHashRef = useRef(JSON.stringify(variables));
const lastCursorRef = useRef(initialCursor); const lastCursorRef = useRef(initialCursor);
@ -69,12 +71,16 @@ export default function useStreamQueryWithSubscription(
// We deliberately do NOT put these in the subscribe effect dependency array. // We deliberately do NOT put these in the subscribe effect dependency array.
const contextRef = useRef(context); const contextRef = useRef(context);
const shouldIncludeItemRef = useRef(shouldIncludeItem); const shouldIncludeItemRef = useRef(shouldIncludeItem);
const subscriptionKeyRef = useRef(subscriptionKey);
useEffect(() => { useEffect(() => {
contextRef.current = context; contextRef.current = context;
}, [context]); }, [context]);
useEffect(() => { useEffect(() => {
shouldIncludeItemRef.current = shouldIncludeItem; shouldIncludeItemRef.current = shouldIncludeItem;
}, [shouldIncludeItem]); }, [shouldIncludeItem]);
useEffect(() => {
subscriptionKeyRef.current = subscriptionKey;
}, [subscriptionKey]);
// Per-subscription liveness watchdog: if WS is connected but this subscription // Per-subscription liveness watchdog: if WS is connected but this subscription
// hasn't delivered any payload for some time, trigger a resubscribe. // hasn't delivered any payload for some time, trigger a resubscribe.
@ -87,6 +93,8 @@ export default function useStreamQueryWithSubscription(
const wsLastHeartbeatDateRef = useRef(wsLastHeartbeatDate); const wsLastHeartbeatDateRef = useRef(wsLastHeartbeatDate);
const appStateRef = useRef(AppState.currentState); const appStateRef = useRef(AppState.currentState);
const wsLastRecoveryDateRef = useRef(wsLastRecoveryDate); const wsLastRecoveryDateRef = useRef(wsLastRecoveryDate);
const lastBecameInactiveAtRef = useRef(null);
const lastForegroundKickAtRef = useRef(0);
// Optional refetch-on-reconnect support. // Optional refetch-on-reconnect support.
// Goal: if WS was reconnected (wsClosedDate changes), force a base refetch once before resubscribing // Goal: if WS was reconnected (wsClosedDate changes), force a base refetch once before resubscribing
@ -104,12 +112,56 @@ export default function useStreamQueryWithSubscription(
useEffect(() => { useEffect(() => {
const sub = AppState.addEventListener("change", (next) => { const sub = AppState.addEventListener("change", (next) => {
const now = Date.now();
appStateRef.current = next; appStateRef.current = next;
if (next === "background" || next === "inactive") {
lastBecameInactiveAtRef.current = now;
return;
}
if (next === "active") { 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. // Timers may have been paused/throttled; reset stale timers to avoid false kicks.
lastSubscriptionDataAtRef.current = Date.now(); lastSubscriptionDataAtRef.current = now;
lastLivenessKickAtRef.current = 0; lastLivenessKickAtRef.current = 0;
consecutiveStaleKicksRef.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(); return () => sub.remove();
@ -513,7 +565,12 @@ export default function useStreamQueryWithSubscription(
// - either initial setup not done yet // - either initial setup not done yet
// - or a new wsClosedDate (WS reconnect) // - or a new wsClosedDate (WS reconnect)
// - or a retry trigger // - or a retry trigger
if (initialSetupDoneRef.current && !wsClosedDate && retryTrigger === 0) { if (
initialSetupDoneRef.current &&
!wsClosedDate &&
retryTrigger === 0 &&
foregroundKick === 0
) {
return; return;
} }
@ -794,6 +851,7 @@ export default function useStreamQueryWithSubscription(
cursorKey, cursorKey,
subscriptionKey, subscriptionKey,
retryTrigger, retryTrigger,
foregroundKick,
maxRetries, maxRetries,
livenessStaleMs, livenessStaleMs,
livenessCheckEveryMs, livenessCheckEveryMs,

View file

@ -11,8 +11,15 @@ export default function createWsLink({ store, GRAPHQL_WS_URL }) {
service: "graphql", 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 activeSocket, pingTimeout;
let lastConnectionHadToken = false; let lastConnectionHadToken = false;
let lastConnectionTokenFingerprint = null;
let lastTokenRestartAt = 0; let lastTokenRestartAt = 0;
// If we connect before auth is ready, Hasura will treat the whole WS session as unauthenticated // 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 = {}; const headers = {};
lastConnectionHadToken = !!userToken; lastConnectionHadToken = !!userToken;
lastConnectionTokenFingerprint = getTokenFingerprint(userToken);
// Important: only attach Authorization when we have a real token. // Important: only attach Authorization when we have a real token.
// Sending `Authorization: Bearer undefined` breaks WS auth on some backends. // 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, (s) => s?.userToken,
(userToken) => { (userToken) => {
if (!userToken) return; if (!userToken) return;
if (lastConnectionHadToken) return;
if (!activeSocket || activeSocket.readyState !== WebSocket.OPEN) 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(); const now = Date.now();
if (now - lastTokenRestartAt < TOKEN_RESTART_MIN_INTERVAL_MS) return; if (now - lastTokenRestartAt < TOKEN_RESTART_MIN_INTERVAL_MS) return;
lastTokenRestartAt = now; lastTokenRestartAt = now;
networkActions.WSRecoveryTouch(); networkActions.WSRecoveryTouch();
wsLogger.warn( 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, url: GRAPHQL_WS_URL,
previousTokenFingerprint: lastConnectionTokenFingerprint,
nextTokenFingerprint: nextFingerprint,
}, },
); );
try { try {