diff --git a/hooks/useActivitySSE.ts b/hooks/useActivitySSE.ts index 9129e95b..c840e55a 100644 --- a/hooks/useActivitySSE.ts +++ b/hooks/useActivitySSE.ts @@ -3,7 +3,7 @@ import { AppState, AppStateStatus, Platform } from 'react-native'; import * as Sentry from '@sentry/react-native'; import { queryClient } from '@/app/_layout'; -import { fetchActivityEvents, getActivityStreamUrl, refreshToken } from '@/lib/api'; +import { fetchActivityEvents, getActivityStreamUrl } from '@/lib/api'; import { refreshRewardsAfterSavings } from '@/lib/refreshRewardsAfterSavings'; import { ActivityEvent, @@ -13,7 +13,7 @@ import { SSEEventData, SSEPingData, } from '@/lib/types'; -import { withRefreshToken } from '@/lib/utils'; +import { ensureTokenRefreshed, withRefreshToken } from '@/lib/utils'; import { useActivityStore } from '@/store/useActivityStore'; import { useUserStore } from '@/store/useUserStore'; @@ -308,38 +308,17 @@ class SSEConnectionManager { return this.refreshPromise; } - // Create a new refresh promise to prevent race conditions + // Delegate to the app-wide shared token-refresh mechanism so that a + // concurrent SSE 401 and API 401 share a single HTTP refresh request + // rather than firing two independent ones. this.refreshPromise = (async () => { try { - const { users, updateUser } = useUserStore.getState(); - const currentUser = users.find(user => user.selected); - - if (!currentUser?.tokens?.refreshToken) { - return false; - } - - // refreshToken() gets the refresh token internally and returns a Response - const response = await refreshToken(); - const data = (await response.json()) as { - tokens: { accessToken: string; refreshToken: string }; - }; - - if (data?.tokens?.accessToken && data?.tokens?.refreshToken) { - // Update tokens in store - updateUser({ - ...currentUser, - tokens: { - accessToken: data.tokens.accessToken, - refreshToken: data.tokens.refreshToken, - }, - }); - + const tokens = await ensureTokenRefreshed(); + if (tokens) { // Properly disconnect and reconnect with new token - // This ensures clean state and prevents race conditions this.reconnect(); return true; } - return false; } catch (error) { Sentry.captureException(error, { diff --git a/lib/utils/utils.ts b/lib/utils/utils.ts index bfb8dfcf..b9afb678 100644 --- a/lib/utils/utils.ts +++ b/lib/utils/utils.ts @@ -123,6 +123,32 @@ let globalLogoutHandler: (() => void) | null = null; let refreshTokenPromise: Promise | null = null; +/** + * Ensures a single token-refresh HTTP request is in flight at any time. + * Tokens are persisted to the store exactly once by the originating call. + * Any concurrent caller attaches to the same promise instead of firing a + * second HTTP request. + */ +export const ensureTokenRefreshed = (): Promise => { + if (!refreshTokenPromise) { + refreshTokenPromise = refreshToken() + .then(async response => { + const data: { tokens: AuthTokens } = await response.json(); + const tokens = data.tokens; + // Save tokens exactly once, here inside the chain, so that every + // concurrent waiter on this promise does NOT call saveNewTokens again. + if ((Platform.OS === 'ios' || Platform.OS === 'android') && tokens) { + saveNewTokens(tokens); + } + return tokens; + }) + .finally(() => { + refreshTokenPromise = null; + }); + } + return refreshTokenPromise; +}; + // Flag to suppress session-expired handler during intentional logout let isLoggingOut = false; @@ -164,28 +190,22 @@ export const withRefreshToken = async ( } try { - // Use existing refresh token promise if one is in progress + // Track whether this caller is the one starting the refresh so we can + // stagger non-originating waiters after the promise resolves. const isNewRefresh = !refreshTokenPromise; - if (isNewRefresh) { - refreshTokenPromise = refreshToken() - .then(async response => { - const data: { tokens: AuthTokens } = await response.json(); - return data.tokens; - }) - .finally(() => { - refreshTokenPromise = null; - }); - } else { + if (!isNewRefresh) { console.warn('[TokenRefresh] Reusing in-flight token refresh'); } - const tokens = await refreshTokenPromise; + // ensureTokenRefreshed deduplicates the HTTP call and saves tokens once. + await ensureTokenRefreshed(); - // Only save new tokens on mobile platforms - // On web, we don't need to save new tokens - // because the browser will handle it - if ((Platform.OS === 'ios' || Platform.OS === 'android') && tokens) { - saveNewTokens(tokens); + // Non-originating callers yield one event-loop tick before retrying their + // original request. This spreads the fan-out of concurrent retries across + // multiple ticks, preventing a single microtask avalanche on the JS thread + // that can cause GC pressure and an ANR on the Android main thread. + if (!isNewRefresh) { + await new Promise(r => setTimeout(r, 0)); } } catch (refreshTokenError) { if (onError) {