@@ -124,6 +124,8 @@ const MyProfileTabs: React.FC<MyProfileTabsProps> = ({
124124
125125 // Use React Query mutation for returning book
126126 const returnBookMutation = useReturnBook ( ) ;
127+ // CRITICAL: Track which record is currently being returned to prevent multiple clicks
128+ const returningRecordIdRef = React . useRef < string | null > ( null ) ;
127129
128130 // Use React Query to fetch all user borrows (no status filter to get all)
129131 // The API returns borrow records WITH book details (from /api/borrow-records)
@@ -132,7 +134,6 @@ const MyProfileTabs: React.FC<MyProfileTabsProps> = ({
132134 const {
133135 data : reactQueryBorrows ,
134136 isLoading,
135- isFetching,
136137 isError,
137138 error,
138139 } = useUserBorrows (
@@ -173,80 +174,63 @@ const MyProfileTabs: React.FC<MyProfileTabsProps> = ({
173174 // The API returns borrow records WITH book details (the 'book' field is included)
174175 // initial/legacy data is only used as fallback during initial load
175176 // Transform React Query data to BorrowRecordWithBook[] format (API includes book details)
176- // CRITICAL: Use ref to store stable transformed data and only update when hash changes
177- // This prevents flicker from multiple re-renders during React Query refetch cycles
178- const stableDataRef = React . useRef < BorrowRecordWithBook [ ] > ( [ ] ) ;
179- const previousDataHashRef = React . useRef < string > ( "" ) ;
180- // CRITICAL: Store latest reactQueryBorrows in ref to avoid stale closures
181- // This allows us to access latest data in effect without adding it to dependency array
182- const latestDataRef = React . useRef ( reactQueryBorrows ) ;
183- React . useEffect ( ( ) => {
184- latestDataRef . current = reactQueryBorrows ;
185- } , [ reactQueryBorrows ] ) ;
186-
187- // Calculate hash from current data (only when data exists)
188- const currentDataHash = React . useMemo ( ( ) => {
189- if ( ! reactQueryBorrows || reactQueryBorrows . length === 0 ) {
190- return "" ;
191- }
192- return JSON . stringify (
193- ( reactQueryBorrows as BorrowRecord [ ] ) . map ( ( r ) => ( {
194- id : r . id ,
195- status : r . status ,
196- dueDate : r . dueDate ,
197- bookId : r . bookId ,
198- bookCoverUrl : ( r as BorrowRecord & { book ?: Book } ) . book ?. coverUrl ,
199- bookCoverColor : ( r as BorrowRecord & { book ?: Book } ) . book ?. coverColor ,
200- } ) )
201- ) ;
202- } , [ reactQueryBorrows ] ) ;
203-
204- // Transform data only when hash actually changes
205- React . useEffect ( ( ) => {
177+ // CRITICAL: Optimized to prevent flicker by maintaining stable array references
178+ // Store previous transformed records to reuse Date objects for reference stability
179+ const previousTransformedRef = React . useRef <
180+ Map < string , BorrowRecordWithBook >
181+ > ( new Map ( ) ) ;
182+ // Store previous array to maintain reference equality when data hasn't changed
183+ const previousArrayRef = React . useRef < BorrowRecordWithBook [ ] > ( [ ] ) ;
184+
185+ // Transform data using useMemo - this will recalculate when reactQueryBorrows changes
186+ // but we maintain stable array references to prevent unnecessary re-renders
187+ const allBorrowsFromQuery : BorrowRecordWithBook [ ] = React . useMemo ( ( ) => {
206188 // CRITICAL: Skip updates during logout to prevent flickering
207- // Check for logout-in-progress cookie to prevent unnecessary updates
208- const isLoggingOut = document . cookie
209- . split ( "; " )
210- . find ( ( row ) => row . startsWith ( "logout-in-progress=" ) )
211- ?. split ( "=" ) [ 1 ] === "true" ;
189+ // Check if we're in browser environment before accessing document
190+ const isLoggingOut =
191+ typeof window !== "undefined" &&
192+ document . cookie
193+ . split ( "; " )
194+ . find ( ( row ) => row . startsWith ( "logout-in-progress=" ) )
195+ ?. split ( "=" ) [ 1 ] === "true" ;
212196
213197 if ( isLoggingOut ) {
214- // During logout, preserve existing data and skip all updates
215- // This prevents flickering/blinking during logout transition
216- return ;
198+ // During logout, return previous array to prevent flicker
199+ return previousArrayRef . current ;
217200 }
218201
219- const currentData = latestDataRef . current ;
220- if ( ! currentData || currentData . length === 0 ) {
221- if ( stableDataRef . current . length === 0 ) {
222- return ; // No data to show, keep empty
202+ if ( ! reactQueryBorrows || reactQueryBorrows . length === 0 ) {
203+ // If no data, return previous array if available, otherwise empty array
204+ if ( previousArrayRef . current . length === 0 ) {
205+ return [ ] ;
223206 }
224- // CRITICAL: Don't clear data when query becomes empty (e.g., during logout)
225- // Preserve existing data to prevent UI flicker/disappearance during transitions
226- // The data will naturally be replaced when new data arrives or component unmounts
227- return ; // Keep existing data intact
228- }
229-
230- // If hash hasn't changed, keep previous data (prevents flicker)
231- if (
232- currentDataHash === previousDataHashRef . current &&
233- previousDataHashRef . current !== ""
234- ) {
235- return ; // Data unchanged, keep stable reference
207+ return previousArrayRef . current ;
236208 }
237209
238- // Hash changed, transform the data
239- previousDataHashRef . current = currentDataHash ;
240-
241- const transformed = ( currentData as BorrowRecord [ ] ) . map ( ( record ) => {
210+ // Transform the data
211+ const transformed = ( reactQueryBorrows as BorrowRecord [ ] ) . map ( ( record ) => {
242212 const recordWithBook = record as BorrowRecord & { book ?: Book } ;
243213
244- // CRITICAL: Reuse existing Date objects from previous transformation if timestamps match
245- // This prevents unnecessary re-renders when data hasn't actually changed
246- const existingRecord = stableDataRef . current ?. find (
247- ( r ) => r . id === record . id
248- ) ;
214+ // CRITICAL: Reuse existing transformed record if it exists and data hasn't changed
215+ // This maintains reference equality for Date objects and prevents unnecessary re-renders
216+ const existingRecord = previousTransformedRef . current . get ( record . id ) ;
217+
218+ // Check if record data has actually changed
219+ const dataChanged =
220+ ! existingRecord ||
221+ existingRecord . status !== record . status ||
222+ existingRecord . bookId !== record . bookId ||
223+ ( existingRecord . dueDate ?. getTime ( ) || 0 ) !==
224+ ( record . dueDate ? new Date ( record . dueDate ) . getTime ( ) : 0 ) ||
225+ ( existingRecord . returnDate ?. getTime ( ) || 0 ) !==
226+ ( record . returnDate ? new Date ( record . returnDate ) . getTime ( ) : 0 ) ;
227+
228+ // If data hasn't changed, reuse existing record (maintains reference equality)
229+ if ( ! dataChanged && existingRecord ) {
230+ return existingRecord ;
231+ }
249232
233+ // Data changed or record is new, create new transformed record
250234 const getStableDate = (
251235 dateString : string | Date | null | undefined ,
252236 existingDate : Date | null | undefined
@@ -265,7 +249,7 @@ const MyProfileTabs: React.FC<MyProfileTabsProps> = ({
265249 return new Date ( timestamp ) ;
266250 } ;
267251
268- return {
252+ const transformedRecord : BorrowRecordWithBook = {
269253 id : record . id ,
270254 userId : record . userId ,
271255 bookId : record . bookId ,
@@ -312,15 +296,56 @@ const MyProfileTabs: React.FC<MyProfileTabsProps> = ({
312296 updatedAt : null ,
313297 } ,
314298 } ;
299+
300+ // Store in map for next comparison
301+ previousTransformedRef . current . set ( record . id , transformedRecord ) ;
302+
303+ return transformedRecord ;
315304 } ) ;
316305
317- // Update stable reference only when data actually changes
318- stableDataRef . current = transformed ;
319- } , [ currentDataHash ] ) ; // CRITICAL: Only depend on hash, not reactQueryBorrows reference
320- // This prevents effect from running on every refetch when data hasn't actually changed
306+ // Clean up map - remove records that no longer exist
307+ const currentIds = new Set ( transformed . map ( ( r ) => r . id ) ) ;
308+ for ( const [ id ] of previousTransformedRef . current ) {
309+ if ( ! currentIds . has ( id ) ) {
310+ previousTransformedRef . current . delete ( id ) ;
311+ }
312+ }
313+
314+ // CRITICAL: Compare with previous array to maintain reference equality
315+ // Only return new array if records actually changed
316+ const previousArray = previousArrayRef . current ;
317+ if (
318+ previousArray . length === transformed . length &&
319+ previousArray . every (
320+ ( prevRecord , index ) =>
321+ prevRecord . id === transformed [ index ] ?. id &&
322+ prevRecord . status === transformed [ index ] ?. status &&
323+ prevRecord . bookId === transformed [ index ] ?. bookId
324+ )
325+ ) {
326+ // Array contents are the same, return previous array to maintain reference equality
327+ return previousArray ;
328+ }
321329
322- // Use stable ref data - this reference only changes when data actually changes
323- const allBorrowsFromQuery : BorrowRecordWithBook [ ] = stableDataRef . current ;
330+ // Array contents changed, update ref and return new array
331+ previousArrayRef . current = transformed ;
332+ return transformed ;
333+ } , [ reactQueryBorrows ] ) ; // Transform whenever reactQueryBorrows changes
334+ // React Query's placeholderData ensures smooth transitions without flicker
335+
336+ // CRITICAL: Clear returningRecordIdRef when record status changes to RETURNED
337+ // This ensures the button becomes enabled again after the UI updates
338+ React . useEffect ( ( ) => {
339+ if ( returningRecordIdRef . current ) {
340+ const returnedRecord = allBorrowsFromQuery . find (
341+ ( r ) => r . id === returningRecordIdRef . current && r . status === "RETURNED"
342+ ) ;
343+ if ( returnedRecord ) {
344+ // Record has been returned, clear the ref to re-enable button
345+ returningRecordIdRef . current = null ;
346+ }
347+ }
348+ } , [ allBorrowsFromQuery ] ) ;
324349
325350 // Use React Query data if available, otherwise fall back to initial/legacy data
326351 // CRITICAL: Memoize to prevent unnecessary recalculations in filtered arrays
@@ -485,11 +510,66 @@ const MyProfileTabs: React.FC<MyProfileTabsProps> = ({
485510 } ;
486511
487512 const handleReturnBook = ( ) => {
513+ console . log ( "[MyProfileTabs] Return book clicked" , {
514+ recordId : record . id ,
515+ bookTitle : record . book . title ,
516+ currentStatus : record . status ,
517+ isPending : returnBookMutation . isPending ,
518+ returningRecordId : returningRecordIdRef . current ,
519+ } ) ;
520+
521+ // CRITICAL: Prevent multiple clicks on the same record
522+ // Check if this specific record is already being returned
523+ if ( returningRecordIdRef . current === record . id ) {
524+ console . log (
525+ "[MyProfileTabs] Record already being returned, ignoring click"
526+ ) ;
527+ return ; // This record is already being returned, ignore click
528+ }
529+
530+ // CRITICAL: Prevent multiple clicks - check if any mutation is pending
531+ if ( returnBookMutation . isPending ) {
532+ console . log (
533+ "[MyProfileTabs] Mutation already pending, ignoring click"
534+ ) ;
535+ return ; // Already processing a return, ignore additional clicks
536+ }
537+
538+ // Mark this record as being returned
539+ returningRecordIdRef . current = record . id ;
540+ console . log ( "[MyProfileTabs] Starting return mutation" , {
541+ recordId : record . id ,
542+ bookTitle : record . book . title ,
543+ } ) ;
544+
488545 // Use mutation to return book
489546 returnBookMutation . mutate (
490547 {
491548 recordId : record . id ,
492549 bookTitle : record . book . title ,
550+ } ,
551+ {
552+ onSuccess : ( data ) => {
553+ console . log ( "[MyProfileTabs] Return mutation success" , {
554+ recordId : record . id ,
555+ data,
556+ } ) ;
557+ } ,
558+ onError : ( error ) => {
559+ console . error ( "[MyProfileTabs] Return mutation error" , {
560+ recordId : record . id ,
561+ error,
562+ } ) ;
563+ } ,
564+ onSettled : ( ) => {
565+ console . log ( "[MyProfileTabs] Return mutation settled" , {
566+ recordId : record . id ,
567+ } ) ;
568+ // CRITICAL: Don't clear returningRecordIdRef immediately
569+ // Keep it set until the record status actually changes to RETURNED
570+ // This ensures the button stays disabled until UI updates
571+ // The ref will be cleared when the record status changes in the next render
572+ } ,
493573 }
494574 // CRITICAL: No onSuccess callback needed here
495575 // The useReturnBook mutation already handles all cache invalidation
@@ -752,14 +832,23 @@ const MyProfileTabs: React.FC<MyProfileTabsProps> = ({
752832 { record . status === "BORROWED" && (
753833 < button
754834 onClick = { handleReturnBook }
755- className = { `flex items-center gap-1 rounded px-3 py-1.5 text-sm font-medium transition-colors ${
835+ disabled = {
836+ ( returnBookMutation . isPending &&
837+ returningRecordIdRef . current === record . id ) ||
838+ returningRecordIdRef . current === record . id
839+ }
840+ className = { `flex items-center gap-1 rounded px-3 py-1.5 text-sm font-medium transition-colors disabled:cursor-not-allowed disabled:opacity-50 ${
756841 isOverdue
757842 ? "bg-red-600 text-white hover:bg-red-700"
758843 : "bg-orange-600 text-white hover:bg-orange-700"
759844 } `}
760845 >
761846 < RotateCcw className = "size-4" />
762- < span > Return Book</ span >
847+ < span >
848+ { returningRecordIdRef . current === record . id
849+ ? "Returning..."
850+ : "Return Book" }
851+ </ span >
763852 </ button >
764853 ) }
765854
@@ -794,10 +883,12 @@ const MyProfileTabs: React.FC<MyProfileTabsProps> = ({
794883 // React.memo comparison returns TRUE if props are EQUAL (skip re-render)
795884 // Returns FALSE if props are DIFFERENT (re-render)
796885 // Compare all critical fields that affect rendering
797-
886+
798887 // Quick reference equality check first (fastest)
799- if ( prevProps . record === nextProps . record &&
800- prevProps . showCountdown === nextProps . showCountdown ) {
888+ if (
889+ prevProps . record === nextProps . record &&
890+ prevProps . showCountdown === nextProps . showCountdown
891+ ) {
801892 return true ; // Same reference, skip re-render
802893 }
803894
0 commit comments