fix(ws): stabilization try 7
This commit is contained in:
parent
5dfb064c2c
commit
42d5b18b35
4 changed files with 154 additions and 8 deletions
|
|
@ -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", {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue