@@ -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} ) ;
0 commit comments