Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 7 additions & 28 deletions hooks/useActivitySSE.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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';

Expand Down Expand Up @@ -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, {
Expand Down
54 changes: 37 additions & 17 deletions lib/utils/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,32 @@ let globalLogoutHandler: (() => void) | null = null;

let refreshTokenPromise: Promise<AuthTokens | null> | 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<AuthTokens | null> => {
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;

Expand Down Expand Up @@ -164,28 +190,22 @@ export const withRefreshToken = async <T>(
}

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<void>(r => setTimeout(r, 0));
}
} catch (refreshTokenError) {
if (onError) {
Expand Down
Loading