@@ -20,46 +20,69 @@ interface UserPopoverContentProps {
2020 user : UserNameUser ;
2121}
2222
23- function UserPopoverContent ( { user } : UserPopoverContentProps ) {
24- // TODO: store stats in global redux state
25- const dispatch = useDispatch < AppDispatch > ( ) ;
23+ type UserStatsData = React . ComponentProps < typeof UserStats > [ 'data' ] ;
2624
27- // eslint-disable-next-line @typescript-eslint/no-explicit-any
28- const [ stats , setStats ] = useState < any > ( null ) ;
25+ const userStatsCache = new Map < UserNameUser [ 'id' ] , UserStatsData > ( ) ;
26+ const userStatsRequests = new Map < UserNameUser [ 'id' ] , Promise < UserStatsData > > ( ) ;
2927
30- useEffect ( ( ) => {
31- const userId = user . id ;
32- const controller = new AbortController ( ) ;
28+ const fetchUserStats = ( userId : UserNameUser [ 'id' ] ) => {
29+ const cached = userStatsCache . get ( userId ) ;
30+
31+ if ( cached ) {
32+ return Promise . resolve ( cached ) ;
33+ }
34+
35+ const pendingRequest = userStatsRequests . get ( userId ) ;
36+
37+ if ( pendingRequest ) {
38+ return pendingRequest ;
39+ }
3340
34- fetch ( `/api/v1/user/${ userId } /achievements` , {
35- signal : controller . signal ,
41+ const request = fetch ( `/api/v1/user/${ userId } /achievements` )
42+ . then ( async ( response ) => {
43+ if ( ! response . ok ) {
44+ throw new Error ( `Request failed with status ${ response . status } ` ) ;
45+ }
46+
47+ return camelizeKeys ( await response . json ( ) ) as UserStatsData ;
3648 } )
37- . then ( async ( response ) => {
38- if ( ! response . ok ) {
39- throw new Error ( `Request failed with status ${ response . status } ` ) ;
40- }
49+ . then ( ( data ) => {
50+ userStatsCache . set ( userId , data ) ;
51+ return data ;
52+ } )
53+ . finally ( ( ) => {
54+ userStatsRequests . delete ( userId ) ;
55+ } ) ;
56+
57+ userStatsRequests . set ( userId , request ) ;
58+ return request ;
59+ } ;
60+
61+ function UserPopoverContent ( { user } : UserPopoverContentProps ) {
62+ const dispatch = useDispatch < AppDispatch > ( ) ;
63+ const [ stats , setStats ] = useState < UserStatsData > ( ( ) => userStatsCache . get ( user . id ) ) ;
4164
42- const data = await response . json ( ) ;
65+ useEffect ( ( ) => {
66+ const userId = user . id ;
67+ let mounted = true ;
4368
44- if ( ! controller . signal . aborted ) {
45- setStats ( camelizeKeys ( data ) ) ;
69+ setStats ( userStatsCache . get ( userId ) ) ;
70+ fetchUserStats ( userId )
71+ . then ( ( data ) => {
72+ if ( mounted ) {
73+ setStats ( data ) ;
4674 }
4775 } )
4876 . catch ( ( error ) => {
49- // Aborting the in-flight request on hover-out (below) rejects with an
50- // AbortError — that's expected teardown, not a real failure, so don't
51- // surface it as a global error.
52- if ( controller . signal . aborted || ( error as { name ?: string } ) ?. name === 'AbortError' ) {
53- return ;
77+ if ( mounted ) {
78+ dispatch ( actions . setError ( error ) ) ;
5479 }
55-
56- dispatch ( actions . setError ( error ) ) ;
5780 } ) ;
5881
5982 return ( ) => {
60- controller . abort ( ) ;
83+ mounted = false ;
6184 } ;
62- } , [ dispatch , setStats , user . id ] ) ;
85+ } , [ dispatch , user . id ] ) ;
6386
6487 // UserStats expects a stricter user shape (numeric id); UserNameUser is broader.
6588 // eslint-disable-next-line @typescript-eslint/no-explicit-any
@@ -141,7 +164,12 @@ function UserInfo({
141164 }
142165
143166 return (
144- < PopoverStickOnHover id = { `user-info-${ user ?. id } ` } placement = { placement } component = { content } >
167+ < PopoverStickOnHover
168+ id = { `user-info-${ user ?. id } ` }
169+ delay = { 150 }
170+ placement = { placement }
171+ component = { content }
172+ >
145173 < div >
146174 < UserName
147175 className = { userClassName }
0 commit comments