feat: resilient thumbnail loading with backoff retries

Backend generates thumbnails on-demand via Celery, so the first request
often 404s while the worker runs. Auto-retry with 1.5s/3.5s/6s backoff,
manual retry fallback, and proper timer cleanup so fast-scrolling a
virtualized timeline doesn't setState on unmounted components.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-07 07:59:03 +02:00
parent 78e12e8309
commit 72d301a9c7
2 changed files with 95 additions and 24 deletions

View File

@@ -1,6 +1,11 @@
import { useState, useEffect } from 'react' import { useState, useEffect, useCallback, useRef } from 'react'
import { Star, Check, X } from 'lucide-react' import { Star, Check, X, RefreshCw } from 'lucide-react'
import clsx from 'clsx' import clsx from 'clsx'
import { photos as photosApi } from '../../services/api'
// Auto-retry schedule (ms). Backend generates thumbs on-demand via Celery, so
// first hit often 404s. Try a few times with backoff before giving up.
const AUTO_RETRY_DELAYS = [1500, 3500, 6000]
interface Photo { interface Photo {
id: string id: string
@@ -26,28 +31,70 @@ interface PhotoThumbnailProps {
export function PhotoThumbnail({ photo, size, isSelected, onClick }: PhotoThumbnailProps) { export function PhotoThumbnail({ photo, size, isSelected, onClick }: PhotoThumbnailProps) {
const [imageError, setImageError] = useState(false) const [imageError, setImageError] = useState(false)
const [imageLoaded, setImageLoaded] = useState(false) const [imageLoaded, setImageLoaded] = useState(false)
const [retryCount, setRetryCount] = useState(0)
const [isRetrying, setIsRetrying] = useState(false)
const retryTimerRef = useRef<number | null>(null)
// Generate thumbnail URL - assuming backend serves thumbnails at /api/photos/{id}/thumbnail // Cache-bust on retry so the browser actually re-requests instead of
const thumbnailUrl = `http://localhost:8001/api/v1/photos/${photo.id}/thumb/medium` // serving the cached 404.
const baseUrl = photosApi.getThumbnailUrl(photo.id, 'medium')
const thumbnailUrl = retryCount > 0 ? `${baseUrl}?retry=${retryCount}` : baseUrl
// Calculate aspect ratio for proper sizing (default to 1:1 if dimensions unknown) // Calculate aspect ratio for proper sizing (default to 1:1 if dimensions unknown)
const aspectRatio = (photo.height && photo.width) ? photo.height / photo.width : 1 const aspectRatio = (photo.height && photo.width) ? photo.height / photo.width : 1
const displayHeight = size * Math.min(aspectRatio, 1.5) // Cap height at 1.5x width const displayHeight = size * Math.min(aspectRatio, 1.5) // Cap height at 1.5x width
const clearRetryTimer = () => {
if (retryTimerRef.current !== null) {
window.clearTimeout(retryTimerRef.current)
retryTimerRef.current = null
}
}
const handleImageLoad = () => { const handleImageLoad = () => {
setImageLoaded(true) setImageLoaded(true)
setIsRetrying(false)
} }
const handleImageError = () => { const handleImageError = () => {
// Schedule next auto-retry if attempts remain.
const nextDelay = AUTO_RETRY_DELAYS[retryCount]
if (nextDelay !== undefined) {
setIsRetrying(true)
clearRetryTimer()
retryTimerRef.current = window.setTimeout(() => {
retryTimerRef.current = null
setRetryCount(prev => prev + 1)
}, nextDelay)
} else {
setImageError(true) setImageError(true)
setIsRetrying(false)
}
} }
// Reset state when photo changes const handleManualRetry = useCallback((e: React.MouseEvent) => {
useEffect(() => { e.stopPropagation() // Prevent selection when clicking retry
clearRetryTimer()
setRetryCount(prev => prev + 1)
setImageError(false) setImageError(false)
setImageLoaded(false) setImageLoaded(false)
setIsRetrying(true)
}, [])
// Reset state when photo changes (component is reused across rows when virtualized)
useEffect(() => {
clearRetryTimer()
setImageError(false)
setImageLoaded(false)
setRetryCount(0)
setIsRetrying(false)
}, [photo.id]) }, [photo.id])
// Clear pending timer on unmount to avoid setState-after-unmount.
useEffect(() => {
return () => clearRetryTimer()
}, [])
return ( return (
<div <div
className={clsx( className={clsx(
@@ -65,6 +112,7 @@ export function PhotoThumbnail({ photo, size, isSelected, onClick }: PhotoThumbn
> >
{/* Thumbnail Image */} {/* Thumbnail Image */}
{!imageError ? ( {!imageError ? (
<>
<img <img
src={thumbnailUrl} src={thumbnailUrl}
alt={photo.filename} alt={photo.filename}
@@ -76,11 +124,34 @@ export function PhotoThumbnail({ photo, size, isSelected, onClick }: PhotoThumbn
onError={handleImageError} onError={handleImageError}
loading="lazy" loading="lazy"
/> />
{/* Loading indicator */}
{!imageLoaded && (
<div className="absolute inset-0 flex items-center justify-center bg-surface">
<div className="text-text-muted">
{isRetrying ? (
<div className="text-center">
<RefreshCw className="h-5 w-5 animate-spin mx-auto mb-1" />
<div className="text-xs">Retrying...</div>
</div>
) : (
<div className="h-8 w-8 border-2 border-primary/30 border-t-primary rounded-full animate-spin" />
)}
</div>
</div>
)}
</>
) : ( ) : (
<div className="flex h-full w-full items-center justify-center bg-surface text-text-muted"> <div className="flex h-full w-full items-center justify-center bg-surface text-text-muted">
<div className="text-center text-xs"> <div className="text-center text-xs">
<button
onClick={handleManualRetry}
className="p-2 hover:bg-surface-light rounded transition-colors"
title="Retry loading thumbnail"
>
<RefreshCw className="h-5 w-5 mb-1" />
</button>
<div>Unable to load</div> <div>Unable to load</div>
<div className="mt-1 font-mono text-[10px]">{photo.filename}</div> <div className="mt-1 font-mono text-[10px] px-2 break-all">{photo.filename}</div>
</div> </div>
</div> </div>
)} )}

File diff suppressed because one or more lines are too long