fix(ws): stabilization try 5

This commit is contained in:
devthejo 2026-01-17 23:44:00 +01:00
parent f7656beb1a
commit ef643f77cb
No known key found for this signature in database
GPG key ID: 00CCA7A92B1D5351
4 changed files with 169 additions and 53 deletions

View file

@ -43,47 +43,6 @@ const lifecycleLogger = createLogger({
feature: "error-handling",
});
// Initialize stores with error handling
const initializeStores = () => {
try {
appLogger.info("Initializing core stores and subscriptions");
// Initialize each store with error handling
const initializeStore = async (name, initFn) => {
try {
await initFn();
appLogger.debug(`${name} initialized successfully`);
} catch (error) {
lifecycleLogger.error(`Failed to initialize ${name}`, {
error: error?.message,
store: name,
});
errorHandler(error);
}
};
// Initialize memory stores first
initializeStore("memorySecureStore", secureStore.init);
initializeStore("memoryAsyncStorage", memoryAsyncStorage.init);
// Then initialize other stores sequentially
initializeStore("authActions", authActions.init);
initializeStore("permissionWizard", permissionWizardActions.init);
initializeStore("paramsActions", paramsActions.init);
initializeStore("storeSubscriptions", storeSubscriptions.init);
appLogger.info("Core initialization complete");
} catch (error) {
lifecycleLogger.error("Critical: Store initialization failed", {
error: error?.message,
});
errorHandler(error);
}
};
// Initialize stores immediately
initializeStores();
// Enhanced error handler with comprehensive error normalization and handling
const errorHandler = (error, stackTrace) => {
try {
@ -219,8 +178,53 @@ const setupGlobalErrorHandlers = () => {
// Initialize error handlers immediately
setupGlobalErrorHandlers();
// Initialize stores with error handling
const initializeStores = async () => {
try {
appLogger.info("Initializing core stores and subscriptions");
// Initialize each store with error handling
const initializeStore = async (name, initFn) => {
try {
await initFn();
appLogger.debug(`${name} initialized successfully`);
} catch (error) {
lifecycleLogger.error(`Failed to initialize ${name}`, {
error: error?.message,
store: name,
});
errorHandler(error);
}
};
// Initialize memory stores first (needed by auth/params stores)
await initializeStore("memorySecureStore", secureStore.init);
await initializeStore("memoryAsyncStorage", memoryAsyncStorage.init);
// Then initialize other stores sequentially
await initializeStore("authActions", authActions.init);
await initializeStore("permissionWizard", permissionWizardActions.init);
await initializeStore("paramsActions", paramsActions.init);
await initializeStore("storeSubscriptions", storeSubscriptions.init);
appLogger.info("Core initialization complete");
} catch (error) {
lifecycleLogger.error("Critical: Store initialization failed", {
error: error?.message,
});
errorHandler(error);
}
};
// Initialize stores immediately (after errorHandler is defined)
void initializeStores();
function AppContent() {
appLogger.info("Initializing app features");
// Avoid logging inside render (this component re-renders frequently due to store updates).
useEffect(() => {
appLogger.info("Initializing app features");
}, []);
useFcm();
useUpdates();
useNetworkListener();
@ -293,7 +297,7 @@ function AppContent() {
};
}, []);
appLogger.info("App initialization complete");
// Avoid logging inside render (see effect above).
return (
<>
<AppLifecycleListener />

View file

@ -180,22 +180,41 @@ const AppLifecycleListener = () => {
const lastWsRestartAtRef = useRef(0);
const MIN_WS_RESTART_INTERVAL_MS = 15_000;
const { completed } = usePermissionWizardState(["completed"]);
// Important: don't put rapidly-changing network state in the main effect deps,
// otherwise we re-register the AppState listener and rerun the "initial permission check"
// in a tight loop (causing freezes + log spam).
const { hasInternetConnection, wsConnected, wsLastHeartbeatDate } =
useNetworkState([
"hasInternetConnection",
"wsConnected",
"wsLastHeartbeatDate",
]);
const hasInternetConnectionRef = useRef(hasInternetConnection);
const wsConnectedRef = useRef(wsConnected);
const wsLastHeartbeatDateRef = useRef(wsLastHeartbeatDate);
useEffect(() => {
hasInternetConnectionRef.current = hasInternetConnection;
}, [hasInternetConnection]);
useEffect(() => {
wsConnectedRef.current = wsConnected;
}, [wsConnected]);
useEffect(() => {
wsLastHeartbeatDateRef.current = wsLastHeartbeatDate;
}, [wsLastHeartbeatDate]);
useEffect(() => {
const handleAppStateChange = (nextAppState) => {
lifecycleLogger.debug("App state changing", {
from: appState.current,
to: nextAppState,
hasInternet: hasInternetConnection,
hasInternet: hasInternetConnectionRef.current,
});
if (!hasInternetConnection) {
if (!hasInternetConnectionRef.current) {
lifecycleLogger.debug("Skipping state change handling - no internet");
return;
}
@ -248,17 +267,30 @@ const AppLifecycleListener = () => {
return;
}
const hbMs = wsLastHeartbeatDate
? Date.parse(wsLastHeartbeatDate)
const hbMs = wsLastHeartbeatDateRef.current
? Date.parse(wsLastHeartbeatDateRef.current)
: NaN;
const heartbeatAgeMs = Number.isFinite(hbMs) ? now - hbMs : null;
lifecycleLogger.info("Foreground WS check", {
inactiveTime: timeSinceLastActive,
wsConnected,
wsConnected: wsConnectedRef.current,
heartbeatAgeMs,
});
// Only restart the WS transport when it looks unhealthy.
// Restarting while already connected causes unnecessary 4205 closes
// and cascades into subscription teardown/resubscribe loops.
const shouldRestart =
!wsConnectedRef.current ||
heartbeatAgeMs === null ||
heartbeatAgeMs > 45_000 ||
timeSinceLastActive > 30_000;
if (!shouldRestart) {
return;
}
lastWsRestartAtRef.current = now;
lifecycleLogger.info("Restarting WebSocket connection");
networkActions.WSRecoveryTouch();
@ -291,7 +323,7 @@ const AppLifecycleListener = () => {
activeTimeout.current = null;
}
};
}, [completed, hasInternetConnection, wsConnected, wsLastHeartbeatDate]);
}, [completed]);
return null;
};

View file

@ -12,6 +12,13 @@ export default function createWsLink({ store, GRAPHQL_WS_URL }) {
});
let activeSocket, pingTimeout;
let lastConnectionHadToken = false;
let lastTokenRestartAt = 0;
// If we connect before auth is ready, Hasura will treat the whole WS session as unauthenticated
// (auth is evaluated on `connection_init`). When the user token becomes available later,
// we must restart the WS transport once so `connectionParams` includes the token.
const TOKEN_RESTART_MIN_INTERVAL_MS = 10_000;
const PING_INTERVAL = 10_000;
const PING_TIMEOUT = 5_000;
@ -27,6 +34,8 @@ export default function createWsLink({ store, GRAPHQL_WS_URL }) {
const { userToken } = getAuthState();
const headers = {};
lastConnectionHadToken = !!userToken;
// Important: only attach Authorization when we have a real token.
// Sending `Authorization: Bearer undefined` breaks WS auth on some backends.
if (userToken) {
@ -48,11 +57,11 @@ export default function createWsLink({ store, GRAPHQL_WS_URL }) {
lazy: false,
keepAlive: PING_INTERVAL,
retryAttempts: MAX_RECONNECT_ATTEMPTS,
retryWait: async () => {
// `graphql-ws` passes the retry count to `retryWait(retries)`.
retryWait: async (retries = 0) => {
// `graphql-ws` calls `retryWait(retries)`.
// Use a jittered exponential backoff, capped.
const retries = arguments[0] ?? 0;
const base = Math.min(1000 * Math.pow(2, retries), 30_000);
const safeRetries = Number.isFinite(retries) ? retries : 0;
const base = Math.min(1000 * Math.pow(2, safeRetries), 30_000);
const delay = base * (0.5 + Math.random());
await new Promise((resolve) => setTimeout(resolve, delay));
},
@ -69,6 +78,33 @@ export default function createWsLink({ store, GRAPHQL_WS_URL }) {
networkActions.WSConnected();
networkActions.WSTouch();
// If we connected without a token, and a token is now available, restart once.
// This avoids `ApolloError: no subscriptions exist` caused by an unauthenticated WS session.
const { userToken } = getAuthState();
if (!lastConnectionHadToken && userToken) {
const now = Date.now();
if (now - lastTokenRestartAt >= TOKEN_RESTART_MIN_INTERVAL_MS) {
lastTokenRestartAt = now;
networkActions.WSRecoveryTouch();
wsLogger.warn(
"WS connected before auth; restarting to apply user token",
{
url: GRAPHQL_WS_URL,
},
);
try {
wsLink.client?.restart?.();
} catch (error) {
wsLogger.error(
"Failed to restart WS after token became available",
{
error,
},
);
}
}
}
// Clear any lingering ping timeouts
if (pingTimeout) {
clearTimeout(pingTimeout);
@ -131,5 +167,43 @@ export default function createWsLink({ store, GRAPHQL_WS_URL }) {
},
});
// Also listen to token changes and restart if we already have an unauthenticated WS session.
// This catches the common startup sequence:
// - WS opens/CONNECTS with no token
// - auth store later obtains userToken
if (typeof store?.subscribeAuthState === "function") {
store.subscribeAuthState(
(s) => s?.userToken,
(userToken) => {
if (!userToken) return;
if (lastConnectionHadToken) return;
if (!activeSocket || activeSocket.readyState !== WebSocket.OPEN) 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",
{
url: GRAPHQL_WS_URL,
},
);
try {
wsLink.client?.restart?.();
} catch (error) {
wsLogger.error("Failed to restart WS on auth token change", {
error,
});
}
},
);
} else {
wsLogger.warn("WS link could not subscribe to auth changes", {
reason: "store.subscribeAuthState is not a function",
});
}
return wsLink;
}

View file

@ -46,12 +46,17 @@ export default withConnectivity(function Profile({ navigation, route }) {
);
useEffect(() => {
// If the subscription is currently skipped (no userId yet),
// `restart` might not be available depending on Apollo version.
if (!userId) return;
if (typeof restart !== "function") return;
restart();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [userId]);
useEffect(() => {
if (!wsClosedDate) return;
if (typeof restart !== "function") return;
// WS was closed/reconnected; restart the subscription to avoid being stuck.
try {
profileLogger.info(
@ -92,6 +97,7 @@ export default withConnectivity(function Profile({ navigation, route }) {
});
try {
lastDataAtRef.current = Date.now();
if (typeof restart !== "function") return;
restart();
} catch (_e) {
// ignore