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();
|
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", {
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue