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();
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", {

View file

@ -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,

View file

@ -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,

View file

@ -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 {