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 type { ClubBasic, ClubDetail } from '@types';
|
||||||
import { getClubDetails } from '@utils/apiService';
|
import { getClubDetails } from '@utils/apiService';
|
||||||
|
import activityDetailLimiter from '@utils/concurrencyLimiter';
|
||||||
|
|
||||||
interface ClubCardProps {
|
interface ClubCardProps {
|
||||||
club: ClubBasic;
|
club: ClubBasic;
|
||||||
initialDetails?: ClubDetail | null;
|
initialDetails?: ClubDetail | null;
|
||||||
onDetailsLoaded?: (clubId: string, details: ClubDetail) => void;
|
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 ClubCard: React.FC<ClubCardProps> = ({ club, initialDetails = null, onDetailsLoaded, onViewMore }) => {
|
||||||
const [details, setDetails] = useState<ClubDetail | null>(initialDetails);
|
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 [error, setError] = useState<string | null>(null);
|
||||||
|
const [isVisible, setIsVisible] = useState<boolean>(false);
|
||||||
|
const cardRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
|
||||||
const fetchDetailsIfNeeded = useCallback(async () => {
|
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);
|
setIsLoadingDetails(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
try {
|
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);
|
setDetails(fetchedDetails);
|
||||||
if (onDetailsLoaded) {
|
if (onDetailsLoaded) {
|
||||||
onDetailsLoaded(club.id, fetchedDetails);
|
onDetailsLoaded(club.id, fetchedDetails);
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err: any) {
|
||||||
console.error(`Failed to fetch details for club ${club.id}:`, err);
|
console.error(`Failed to fetch details for club ${club.id} (concurrency controlled):`, err);
|
||||||
setError('Could not load some details.'); // Less critical error now
|
setError(`Could not load some details. ${err.message || ''}`); // Show more specific error
|
||||||
} finally {
|
} finally {
|
||||||
setIsLoadingDetails(false);
|
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(() => {
|
useEffect(() => {
|
||||||
fetchDetailsIfNeeded();
|
const observer = new IntersectionObserver(
|
||||||
}, [fetchDetailsIfNeeded]);
|
([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 (cardRef.current) {
|
||||||
if (details) {
|
observer.observe(cardRef.current);
|
||||||
onViewMore(details);
|
}
|
||||||
} else if (!isLoadingDetails) {
|
|
||||||
fetchDetailsIfNeeded().then(() => {
|
return () => {
|
||||||
// Re-check details state after fetch attempt
|
if (cardRef.current) {
|
||||||
if(details) onViewMore(details);
|
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) => {
|
const renderDetailItem = (label: string, value: string | undefined | null) => {
|
||||||
if (isLoadingDetails && !details) return <p className="card-info-placeholder">{label}: Loading...</p>;
|
// Show "Loading..." only if it's visible and actively loading
|
||||||
// Check specifically for the case where details ARE loaded but the value is missing/null/undefined
|
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 && !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 && isVisible) return <p className="card-info-placeholder">{label}: N/A</p>;
|
||||||
if (!details && !isLoadingDetails) return <p className="card-info-placeholder">{label}: N/A</p> // Or maybe return null
|
if (!isVisible && !initialDetails) return <p className="card-info-placeholder">{label}: (Scroll to load)</p>; // Placeholder before visible
|
||||||
// If we have a value (implies details are loaded)
|
|
||||||
if (value) return <p><strong>{label}:</strong> {value}</p>;
|
if (value) return <p><strong>{label}:</strong> {value}</p>;
|
||||||
// Fallback for the loading state (already handled above, but as safety)
|
if (initialDetails && !value) return <p className="card-info-placeholder">{label}: N/A</p>; // For initialDetails case
|
||||||
return <p className="card-info-placeholder">{label}: Loading...</p>;
|
return <p className="card-info-placeholder">{label}: N/A</p>; // Default placeholder
|
||||||
}
|
};
|
||||||
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="card">
|
<div className="card" ref={cardRef}>
|
||||||
<div className="card-photo-container">
|
<div className="card-photo-container">
|
||||||
{/* === Updated img tag below === */}
|
|
||||||
<img
|
<img
|
||||||
src={club.photo}
|
src={club.photo}
|
||||||
alt={club.name}
|
alt={club.name}
|
||||||
className="card-photo"
|
className="card-photo"
|
||||||
loading="lazy" // Defer loading until near viewport
|
loading="lazy"
|
||||||
decoding="async" // Hint to decode off main thread
|
decoding="async"
|
||||||
fetchpriority="low" // Hint that it's not critical compared to other resources
|
fetchpriority="low"
|
||||||
/>
|
/>
|
||||||
{/* === End of updated img tag === */}
|
|
||||||
</div>
|
</div>
|
||||||
<div className="card-content">
|
<div className="card-content">
|
||||||
<h3>{club.name}</h3>
|
<h3>{club.name}</h3>
|
||||||
@@ -88,9 +136,9 @@ const ClubCard: React.FC<ClubCardProps> = ({ club, initialDetails = null, onDeta
|
|||||||
<button
|
<button
|
||||||
onClick={handleViewMoreClick}
|
onClick={handleViewMoreClick}
|
||||||
className="card-button"
|
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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
// src/utils/apiService.ts
|
||||||
import type {
|
import type {
|
||||||
ApiClubListResponse,
|
ApiClubListResponse,
|
||||||
ClubDetail,
|
ClubDetail,
|
||||||
@@ -10,19 +11,42 @@ import type {
|
|||||||
|
|
||||||
const BASE_URL = 'https://dsas-cca.jamesflare.com/v1';
|
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 {
|
try {
|
||||||
const response = await fetch(url);
|
const response = await fetch(url);
|
||||||
if (!response.ok) {
|
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(
|
throw new Error(
|
||||||
`API Error: ${response.status} ${response.statusText}. ${errorData?.error || ''}`
|
`API Error: ${response.status} ${response.statusText}. ${errorData?.error || ''}`
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return response.json() as Promise<T>;
|
return response.json() as Promise<T>;
|
||||||
} catch (error) {
|
} catch (error: any) {
|
||||||
console.error(`Failed to fetch from ${url}:`, error);
|
if (isRetryable) {
|
||||||
throw error; // Re-throw to be handled by the caller
|
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 }));
|
return Object.entries(data).map(([id, club]) => ({ id, ...club }));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Make getClubDetails retryable persistently
|
||||||
export async function getClubDetails(activityId: string): Promise<ClubDetail> {
|
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> {
|
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