Skip to content

Commit fa0cee4

Browse files
committed
fix: borrow book and return book blink issue
1 parent e4952e4 commit fa0cee4

3 files changed

Lines changed: 341 additions & 139 deletions

File tree

components/MyProfileTabs.tsx

Lines changed: 167 additions & 76 deletions
Original file line numberDiff line numberDiff line change
@@ -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

Comments
 (0)