feat: lazy load and retry
This commit is contained in:
@@ -1,80 +1,128 @@
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
// src/components/ClubCard.tsx
|
||||
import React, { useState, useEffect, useCallback, useRef } from 'react';
|
||||
import type { ClubBasic, ClubDetail } from '@types';
|
||||
import { getClubDetails } from '@utils/apiService';
|
||||
import activityDetailLimiter from '@utils/concurrencyLimiter';
|
||||
|
||||
interface ClubCardProps {
|
||||
club: ClubBasic;
|
||||
initialDetails?: ClubDetail | null;
|
||||
onDetailsLoaded?: (clubId: string, details: ClubDetail) => void;
|
||||
onViewMore: (clubDetail: ClubDetail) => void; // Callback to open modal in parent
|
||||
onViewMore: (clubDetail: ClubDetail) => void;
|
||||
}
|
||||
|
||||
const ClubCard: React.FC<ClubCardProps> = ({ club, initialDetails = null, onDetailsLoaded, onViewMore }) => {
|
||||
const [details, setDetails] = useState<ClubDetail | null>(initialDetails);
|
||||
const [isLoadingDetails, setIsLoadingDetails] = useState<boolean>(!initialDetails);
|
||||
const [isLoadingDetails, setIsLoadingDetails] = useState<boolean>(false); // Don't set to true initially if not visible
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [isVisible, setIsVisible] = useState<boolean>(false);
|
||||
const cardRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
const fetchDetailsIfNeeded = useCallback(async () => {
|
||||
if (details || !club.id) return; // Already have details or no ID
|
||||
if (details || !club.id || !isVisible) return; // Already have details, no ID, or not visible
|
||||
|
||||
setIsLoadingDetails(true);
|
||||
setError(null);
|
||||
try {
|
||||
const fetchedDetails = await getClubDetails(club.id);
|
||||
// Run the API call through the concurrency limiter
|
||||
const fetchedDetails = await activityDetailLimiter.run(() => getClubDetails(club.id));
|
||||
setDetails(fetchedDetails);
|
||||
if (onDetailsLoaded) {
|
||||
onDetailsLoaded(club.id, fetchedDetails);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(`Failed to fetch details for club ${club.id}:`, err);
|
||||
setError('Could not load some details.'); // Less critical error now
|
||||
} catch (err: any) {
|
||||
console.error(`Failed to fetch details for club ${club.id} (concurrency controlled):`, err);
|
||||
setError(`Could not load some details. ${err.message || ''}`); // Show more specific error
|
||||
} finally {
|
||||
setIsLoadingDetails(false);
|
||||
}
|
||||
}, [club.id, details, onDetailsLoaded]);
|
||||
}, [club.id, details, onDetailsLoaded, isVisible]); // Add isVisible dependency
|
||||
|
||||
// Fetch details when component mounts if not already provided
|
||||
// IntersectionObserver for lazy loading
|
||||
useEffect(() => {
|
||||
fetchDetailsIfNeeded();
|
||||
}, [fetchDetailsIfNeeded]);
|
||||
const observer = new IntersectionObserver(
|
||||
([entry]) => {
|
||||
if (entry.isIntersecting) {
|
||||
setIsVisible(true);
|
||||
observer.unobserve(entry.target); // Stop observing once visible
|
||||
}
|
||||
},
|
||||
{
|
||||
rootMargin: '0px', // Can be adjusted, e.g., '100px' to load slightly before visible
|
||||
threshold: 0.1 // Trigger when 10% of the element is visible
|
||||
}
|
||||
);
|
||||
|
||||
const handleViewMoreClick = () => {
|
||||
if (details) {
|
||||
onViewMore(details);
|
||||
} else if (!isLoadingDetails) {
|
||||
fetchDetailsIfNeeded().then(() => {
|
||||
// Re-check details state after fetch attempt
|
||||
if(details) onViewMore(details);
|
||||
});
|
||||
if (cardRef.current) {
|
||||
observer.observe(cardRef.current);
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (cardRef.current) {
|
||||
observer.unobserve(cardRef.current);
|
||||
}
|
||||
};
|
||||
}, []); // Run once on mount
|
||||
|
||||
// Fetch details when component becomes visible and details are needed
|
||||
useEffect(() => {
|
||||
if (isVisible && !initialDetails) { // Only fetch if visible and no initial details
|
||||
fetchDetailsIfNeeded();
|
||||
}
|
||||
}, [isVisible, fetchDetailsIfNeeded, initialDetails]);
|
||||
|
||||
const handleViewMoreClick = async () => {
|
||||
let currentDetails = details;
|
||||
if (!currentDetails && !isLoadingDetails) {
|
||||
// If details somehow weren't loaded and not currently loading, try again then open
|
||||
// This attempt will also go through the limiter and retry logic
|
||||
setError(null); // Clear previous error before manual retry via button
|
||||
setIsLoadingDetails(true);
|
||||
try {
|
||||
currentDetails = await activityDetailLimiter.run(() => getClubDetails(club.id));
|
||||
setDetails(currentDetails);
|
||||
if (onDetailsLoaded && currentDetails) {
|
||||
onDetailsLoaded(club.id, currentDetails);
|
||||
}
|
||||
} catch (err: any) {
|
||||
console.error(`Manual fetch failed for club ${club.id}:`, err);
|
||||
setError(`Failed to load details. ${err.message || ''}`);
|
||||
} finally {
|
||||
setIsLoadingDetails(false);
|
||||
}
|
||||
}
|
||||
|
||||
if (currentDetails) {
|
||||
onViewMore(currentDetails);
|
||||
} else {
|
||||
// Optionally inform user that details couldn't be loaded even after click
|
||||
console.warn("Details not available to show in modal for club:", club.id);
|
||||
}
|
||||
};
|
||||
|
||||
const renderDetailItem = (label: string, value: string | undefined | null) => {
|
||||
if (isLoadingDetails && !details) return <p className="card-info-placeholder">{label}: Loading...</p>;
|
||||
// Check specifically for the case where details ARE loaded but the value is missing/null/undefined
|
||||
// Show "Loading..." only if it's visible and actively loading
|
||||
if (isVisible && isLoadingDetails && !details) return <p className="card-info-placeholder">{label}: Loading...</p>;
|
||||
if (details && !value) return <p className="card-info-placeholder">{label}: N/A</p>;
|
||||
// If details are not loaded yet, and we are not actively loading, show N/A or nothing? Let's stick to Loading... or the value
|
||||
if (!details && !isLoadingDetails) return <p className="card-info-placeholder">{label}: N/A</p> // Or maybe return null
|
||||
// If we have a value (implies details are loaded)
|
||||
if (!details && !isLoadingDetails && isVisible) return <p className="card-info-placeholder">{label}: N/A</p>;
|
||||
if (!isVisible && !initialDetails) return <p className="card-info-placeholder">{label}: (Scroll to load)</p>; // Placeholder before visible
|
||||
if (value) return <p><strong>{label}:</strong> {value}</p>;
|
||||
// Fallback for the loading state (already handled above, but as safety)
|
||||
return <p className="card-info-placeholder">{label}: Loading...</p>;
|
||||
}
|
||||
if (initialDetails && !value) return <p className="card-info-placeholder">{label}: N/A</p>; // For initialDetails case
|
||||
return <p className="card-info-placeholder">{label}: N/A</p>; // Default placeholder
|
||||
};
|
||||
|
||||
|
||||
return (
|
||||
<div className="card">
|
||||
<div className="card" ref={cardRef}>
|
||||
<div className="card-photo-container">
|
||||
{/* === Updated img tag below === */}
|
||||
<img
|
||||
src={club.photo}
|
||||
alt={club.name}
|
||||
className="card-photo"
|
||||
loading="lazy" // Defer loading until near viewport
|
||||
decoding="async" // Hint to decode off main thread
|
||||
fetchpriority="low" // Hint that it's not critical compared to other resources
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
fetchpriority="low"
|
||||
/>
|
||||
{/* === End of updated img tag === */}
|
||||
</div>
|
||||
<div className="card-content">
|
||||
<h3>{club.name}</h3>
|
||||
@@ -88,9 +136,9 @@ const ClubCard: React.FC<ClubCardProps> = ({ club, initialDetails = null, onDeta
|
||||
<button
|
||||
onClick={handleViewMoreClick}
|
||||
className="card-button"
|
||||
disabled={isLoadingDetails && !details} // Disable if actively loading initial essential details
|
||||
disabled={isLoadingDetails} // Disable if actively loading
|
||||
>
|
||||
{isLoadingDetails && !details ? 'Loading Details...' : 'View Full Details'}
|
||||
{isLoadingDetails ? 'Loading...' : 'View Full Details'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
// src/utils/apiService.ts
|
||||
import type {
|
||||
ApiClubListResponse,
|
||||
ClubDetail,
|
||||
@@ -10,19 +11,42 @@ import type {
|
||||
|
||||
const BASE_URL = 'https://dsas-cca.jamesflare.com/v1';
|
||||
|
||||
async function fetchWithErrorHandling<T>(url: string): Promise<T> {
|
||||
// Helper function for delaying
|
||||
const delay = (ms: number) => new Promise(resolve => setTimeout(resolve, ms));
|
||||
|
||||
async function fetchWithErrorHandling<T>(url: string, isRetryable: boolean = false, attempt: number = 1): Promise<T> {
|
||||
const MAX_RETRY_ATTEMPTS_FOR_NON_PERSISTENT = 3; // Max retries for general errors if not persistent
|
||||
const RETRY_DELAY_MS = 1000; // 1 second for persistent retry
|
||||
|
||||
try {
|
||||
const response = await fetch(url);
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({ error: "Failed to parse error response" }));
|
||||
// For 4xx errors, typically don't retry indefinitely as it's a client/request issue
|
||||
if (response.status >= 400 && response.status < 500 && isRetryable) {
|
||||
console.warn(`API Client Error for ${url}: ${response.status}. Not retrying.`);
|
||||
const errorData = await response.json().catch(() => ({ error: "Failed to parse client error response" }));
|
||||
throw new Error(`API Client Error: ${response.status} ${response.statusText}. ${errorData?.error || ''}`);
|
||||
}
|
||||
// For 5xx server errors or network issues, retry is more appropriate
|
||||
const errorData = await response.json().catch(() => ({ error: "Failed to parse server error response" }));
|
||||
throw new Error(
|
||||
`API Error: ${response.status} ${response.statusText}. ${errorData?.error || ''}`
|
||||
);
|
||||
}
|
||||
return response.json() as Promise<T>;
|
||||
} catch (error) {
|
||||
console.error(`Failed to fetch from ${url}:`, error);
|
||||
throw error; // Re-throw to be handled by the caller
|
||||
} catch (error: any) {
|
||||
if (isRetryable) {
|
||||
console.warn(`Attempt ${attempt} failed for ${url}: ${error.message}. Retrying in ${RETRY_DELAY_MS / 1000}s...`);
|
||||
await delay(RETRY_DELAY_MS);
|
||||
return fetchWithErrorHandling<T>(url, isRetryable, attempt + 1); // Persistent retry
|
||||
} else if (attempt < MAX_RETRY_ATTEMPTS_FOR_NON_PERSISTENT) {
|
||||
// Non-persistent retry for general errors (e.g., initial list fetches)
|
||||
console.warn(`Attempt ${attempt} failed for ${url}: ${error.message}. Retrying (non-persistent)...`);
|
||||
await delay(RETRY_DELAY_MS * attempt); // Simple increasing backoff
|
||||
return fetchWithErrorHandling<T>(url, isRetryable, attempt + 1);
|
||||
}
|
||||
console.error(`Failed to fetch from ${url} after multiple attempts:`, error);
|
||||
throw error; // Re-throw to be handled by the caller after max retries or if not retryable
|
||||
}
|
||||
}
|
||||
|
||||
@@ -46,8 +70,10 @@ export async function filterClubs(params: {
|
||||
return Object.entries(data).map(([id, club]) => ({ id, ...club }));
|
||||
}
|
||||
|
||||
// Make getClubDetails retryable persistently
|
||||
export async function getClubDetails(activityId: string): Promise<ClubDetail> {
|
||||
return fetchWithErrorHandling<ClubDetail>(`${BASE_URL}/activity/${activityId}`);
|
||||
// Pass `true` for isRetryable to enable persistent retries for this specific endpoint
|
||||
return fetchWithErrorHandling<ClubDetail>(`${BASE_URL}/activity/${activityId}`, true);
|
||||
}
|
||||
|
||||
export async function getAvailableCategories(): Promise<CategoryCount> {
|
||||
|
||||
44
src/utils/concurrencyLimiter.ts
Normal file
44
src/utils/concurrencyLimiter.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
// src/utils/concurrencyLimiter.ts
|
||||
type AsyncTask<T = any> = () => Promise<T>;
|
||||
|
||||
class ConcurrencyLimiter {
|
||||
private limit: number;
|
||||
private activeCount: number;
|
||||
private queue: Array<{ task: AsyncTask; resolve: (value: any) => void; reject: (reason?: any) => void }>;
|
||||
|
||||
constructor(limit: number) {
|
||||
this.limit = limit;
|
||||
this.activeCount = 0;
|
||||
this.queue = [];
|
||||
}
|
||||
|
||||
private async next() {
|
||||
if (this.activeCount >= this.limit || this.queue.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.activeCount++;
|
||||
const { task, resolve, reject } = this.queue.shift()!;
|
||||
|
||||
try {
|
||||
const result = await task();
|
||||
resolve(result);
|
||||
} catch (error) {
|
||||
reject(error);
|
||||
} finally {
|
||||
this.activeCount--;
|
||||
this.next();
|
||||
}
|
||||
}
|
||||
|
||||
public run<T>(task: AsyncTask<T>): Promise<T> {
|
||||
return new Promise((resolve, reject) => {
|
||||
this.queue.push({ task, resolve, reject });
|
||||
this.next();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Export a single instance
|
||||
const activityDetailLimiter = new ConcurrencyLimiter(16); // As requested
|
||||
export default activityDetailLimiter;
|
||||
Reference in New Issue
Block a user