Skip to content

Commit ea8019e

Browse files
committed
fix(predict): race condition
1 parent 8e1d099 commit ea8019e

File tree

2 files changed

+157
-1
lines changed

2 files changed

+157
-1
lines changed

app/components/UI/Predict/hooks/usePredictEligibility.test.ts

Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -463,4 +463,144 @@ describe('usePredictEligibility', () => {
463463
expect(mockRefreshEligibility).toHaveBeenCalledTimes(2);
464464
});
465465
});
466+
467+
describe('race condition prevention', () => {
468+
it('reuses in-flight promise when refresh is already in progress', async () => {
469+
let resolveRefresh: (() => void) | undefined;
470+
const refreshPromise = new Promise<void>((resolve) => {
471+
resolveRefresh = resolve;
472+
});
473+
mockRefreshEligibility.mockReturnValueOnce(refreshPromise);
474+
475+
renderHook(() => usePredictEligibility({ providerId: 'polymarket' }));
476+
477+
const handleAppStateChange = mockAppStateAddEventListener.mock
478+
.calls[0][1] as (nextState: AppStateStatus) => void;
479+
480+
await act(async () => {
481+
handleAppStateChange('background');
482+
handleAppStateChange('active');
483+
});
484+
485+
expect(mockRefreshEligibility).toHaveBeenCalledTimes(1);
486+
487+
await act(async () => {
488+
handleAppStateChange('background');
489+
handleAppStateChange('active');
490+
});
491+
492+
expect(mockRefreshEligibility).toHaveBeenCalledTimes(1);
493+
expect(mockDevLogger).toHaveBeenCalledWith(
494+
'PredictController: Refresh already in progress, reusing promise',
495+
expect.objectContaining({
496+
providerId: 'polymarket',
497+
}),
498+
);
499+
500+
resolveRefresh?.();
501+
});
502+
503+
it('prevents concurrent API calls when multiple state changes occur rapidly', async () => {
504+
let resolveRefresh: (() => void) | undefined;
505+
const refreshPromise = new Promise<void>((resolve) => {
506+
resolveRefresh = resolve;
507+
});
508+
mockRefreshEligibility.mockReturnValueOnce(refreshPromise);
509+
510+
renderHook(() => usePredictEligibility({ providerId: 'polymarket' }));
511+
512+
const handleAppStateChange = mockAppStateAddEventListener.mock
513+
.calls[0][1] as (nextState: AppStateStatus) => void;
514+
515+
await act(async () => {
516+
handleAppStateChange('background');
517+
handleAppStateChange('active');
518+
handleAppStateChange('background');
519+
handleAppStateChange('active');
520+
handleAppStateChange('background');
521+
handleAppStateChange('active');
522+
});
523+
524+
expect(mockRefreshEligibility).toHaveBeenCalledTimes(1);
525+
526+
resolveRefresh?.();
527+
await act(async () => {
528+
await refreshPromise;
529+
});
530+
});
531+
532+
it('allows new refresh after previous one completes', async () => {
533+
let resolveFirstRefresh: (() => void) | undefined;
534+
const firstRefreshPromise = new Promise<void>((resolve) => {
535+
resolveFirstRefresh = resolve;
536+
});
537+
mockRefreshEligibility.mockReturnValueOnce(firstRefreshPromise);
538+
539+
renderHook(() => usePredictEligibility({ providerId: 'polymarket' }));
540+
541+
const handleAppStateChange = mockAppStateAddEventListener.mock
542+
.calls[0][1] as (nextState: AppStateStatus) => void;
543+
544+
await act(async () => {
545+
handleAppStateChange('background');
546+
handleAppStateChange('active');
547+
});
548+
549+
expect(mockRefreshEligibility).toHaveBeenCalledTimes(1);
550+
551+
resolveFirstRefresh?.();
552+
await act(async () => {
553+
await firstRefreshPromise;
554+
});
555+
556+
mockRefreshEligibility.mockResolvedValueOnce(undefined);
557+
jest.advanceTimersByTime(60000);
558+
559+
await act(async () => {
560+
handleAppStateChange('background');
561+
handleAppStateChange('active');
562+
});
563+
564+
expect(mockRefreshEligibility).toHaveBeenCalledTimes(2);
565+
});
566+
567+
it('clears in-flight promise after error', async () => {
568+
let rejectRefresh: ((error: Error) => void) | undefined;
569+
const refreshPromise = new Promise<void>((_resolve, reject) => {
570+
rejectRefresh = reject;
571+
});
572+
mockRefreshEligibility.mockReturnValueOnce(refreshPromise);
573+
574+
renderHook(() => usePredictEligibility({ providerId: 'polymarket' }));
575+
576+
const handleAppStateChange = mockAppStateAddEventListener.mock
577+
.calls[0][1] as (nextState: AppStateStatus) => void;
578+
579+
await act(async () => {
580+
handleAppStateChange('background');
581+
handleAppStateChange('active');
582+
});
583+
584+
expect(mockRefreshEligibility).toHaveBeenCalledTimes(1);
585+
586+
rejectRefresh?.(new Error('Network error'));
587+
await act(async () => {
588+
try {
589+
await refreshPromise;
590+
} catch {
591+
// Expected error
592+
}
593+
});
594+
595+
mockRefreshEligibility.mockResolvedValueOnce(undefined);
596+
jest.advanceTimersByTime(60000);
597+
598+
await act(async () => {
599+
handleAppStateChange('background');
600+
handleAppStateChange('active');
601+
});
602+
603+
expect(mockRefreshEligibility).toHaveBeenCalledTimes(2);
604+
});
605+
});
466606
});

app/components/UI/Predict/hooks/usePredictEligibility.ts

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ export const usePredictEligibility = ({
2929
}) => {
3030
const eligibility = useSelector(selectPredictEligibility);
3131
const lastRefreshTimeRef = useRef<number>(0);
32+
const refreshPromiseRef = useRef<Promise<void> | null>(null);
3233

3334
// Manual refresh - bypasses debounce and updates timestamp
3435
const refreshEligibility = useCallback(async () => {
@@ -38,7 +39,19 @@ export const usePredictEligibility = ({
3839
}, []);
3940

4041
// Auto-refresh with debouncing - used by AppState listener
42+
// Prevents race conditions by reusing in-flight promises
4143
const autoRefreshEligibility = useCallback(async () => {
44+
// If a refresh is already in progress, reuse that promise
45+
if (refreshPromiseRef.current) {
46+
DevLogger.log(
47+
'PredictController: Refresh already in progress, reusing promise',
48+
{
49+
providerId,
50+
},
51+
);
52+
return refreshPromiseRef.current;
53+
}
54+
4255
const now = Date.now();
4356
const timeSinceLastRefresh = now - lastRefreshTimeRef.current;
4457

@@ -56,12 +69,15 @@ export const usePredictEligibility = ({
5669
DevLogger.log('PredictController: Auto-refreshing eligibility', {
5770
providerId,
5871
});
59-
await refreshEligibility();
72+
refreshPromiseRef.current = refreshEligibility();
73+
await refreshPromiseRef.current;
6074
} catch (error) {
6175
DevLogger.log('PredictController: Auto-refresh failed', {
6276
providerId,
6377
error: error instanceof Error ? error.message : 'Unknown',
6478
});
79+
} finally {
80+
refreshPromiseRef.current = null;
6581
}
6682
}, [providerId, refreshEligibility]);
6783

0 commit comments

Comments
 (0)