diff --git a/src/app/index.js b/src/app/index.js index 440afe5..6c3bd45 100644 --- a/src/app/index.js +++ b/src/app/index.js @@ -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 ( <> diff --git a/src/containers/AppLifecycleListener.js b/src/containers/AppLifecycleListener.js index 6d94306..cc9d3d7 100644 --- a/src/containers/AppLifecycleListener.js +++ b/src/containers/AppLifecycleListener.js @@ -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; }; diff --git a/src/network/wsLink.js b/src/network/wsLink.js index 24c7172..d07b4a4 100644 --- a/src/network/wsLink.js +++ b/src/network/wsLink.js @@ -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; } diff --git a/src/scenes/Profile/index.js b/src/scenes/Profile/index.js index bca57d4..b025fb7 100644 --- a/src/scenes/Profile/index.js +++ b/src/scenes/Profile/index.js @@ -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