diff --git a/src/app/libraries/[libraryId]/ClientLibraryPage.tsx b/src/app/libraries/[libraryId]/ClientLibraryPage.tsx index 0d83666..61a04ba 100644 --- a/src/app/libraries/[libraryId]/ClientLibraryPage.tsx +++ b/src/app/libraries/[libraryId]/ClientLibraryPage.tsx @@ -44,6 +44,8 @@ export function ClientLibraryPage({ const effectivePageSize = pageSize || preferences.displayMode?.itemsPerPage || DEFAULT_PAGE_SIZE; useEffect(() => { + const abortController = new AbortController(); + const fetchData = async () => { setLoading(true); setError(null); @@ -59,7 +61,9 @@ export function ClientLibraryPage({ params.append("search", search); } - const response = await fetch(`/api/komga/libraries/${libraryId}/series?${params}`); + const response = await fetch(`/api/komga/libraries/${libraryId}/series?${params}`, { + signal: abortController.signal, + }); if (!response.ok) { const errorData = await response.json(); @@ -70,14 +74,24 @@ export function ClientLibraryPage({ setLibrary(data.library); setSeries(data.series); } catch (err) { + // Ignore abort errors (caused by StrictMode cleanup) + if (err instanceof Error && err.name === "AbortError") { + return; + } logger.error({ err }, "Error fetching library series"); setError(err instanceof Error ? err.message : "SERIES_FETCH_ERROR"); } finally { - setLoading(false); + if (!abortController.signal.aborted) { + setLoading(false); + } } }; fetchData(); + + return () => { + abortController.abort(); + }; }, [libraryId, currentPage, unreadOnly, search, effectivePageSize]); const handleRefresh = async (libraryId: string) => { diff --git a/src/app/series/[seriesId]/ClientSeriesPage.tsx b/src/app/series/[seriesId]/ClientSeriesPage.tsx index 91bf326..7afec2d 100644 --- a/src/app/series/[seriesId]/ClientSeriesPage.tsx +++ b/src/app/series/[seriesId]/ClientSeriesPage.tsx @@ -38,6 +38,8 @@ export function ClientSeriesPage({ const effectivePageSize = pageSize || preferences.displayMode?.itemsPerPage || DEFAULT_PAGE_SIZE; useEffect(() => { + const abortController = new AbortController(); + const fetchData = async () => { setLoading(true); setError(null); @@ -49,7 +51,9 @@ export function ClientSeriesPage({ unread: String(unreadOnly), }); - const response = await fetch(`/api/komga/series/${seriesId}/books?${params}`); + const response = await fetch(`/api/komga/series/${seriesId}/books?${params}`, { + signal: abortController.signal, + }); if (!response.ok) { const errorData = await response.json(); @@ -60,14 +64,24 @@ export function ClientSeriesPage({ setSeries(data.series); setBooks(data.books); } catch (err) { + // Ignore abort errors (caused by StrictMode cleanup) + if (err instanceof Error && err.name === "AbortError") { + return; + } logger.error({ err }, "Error fetching series books"); setError(err instanceof Error ? err.message : ERROR_CODES.BOOK.PAGES_FETCH_ERROR); } finally { - setLoading(false); + if (!abortController.signal.aborted) { + setLoading(false); + } } }; fetchData(); + + return () => { + abortController.abort(); + }; }, [seriesId, currentPage, unreadOnly, effectivePageSize]); const handleRefresh = async (seriesId: string) => { diff --git a/src/components/home/ClientHomePage.tsx b/src/components/home/ClientHomePage.tsx index 2e270f4..ac2e66e 100644 --- a/src/components/home/ClientHomePage.tsx +++ b/src/components/home/ClientHomePage.tsx @@ -17,40 +17,53 @@ export function ClientHomePage() { const [loading, setLoading] = useState(true); const [error, setError] = useState(null); - const fetchData = async () => { - setLoading(true); - setError(null); + useEffect(() => { + const abortController = new AbortController(); - try { - const response = await fetch("/api/komga/home"); + const fetchData = async () => { + setLoading(true); + setError(null); - if (!response.ok) { - const errorData = await response.json(); - const errorCode = errorData.error?.code || ERROR_CODES.KOMGA.SERVER_UNREACHABLE; + try { + const response = await fetch("/api/komga/home", { + signal: abortController.signal, + }); - // Si la config Komga est manquante, rediriger vers les settings - if (errorCode === ERROR_CODES.KOMGA.MISSING_CONFIG) { - router.push("/settings"); - return; + if (!response.ok) { + const errorData = await response.json(); + const errorCode = errorData.error?.code || ERROR_CODES.KOMGA.SERVER_UNREACHABLE; + + // Si la config Komga est manquante, rediriger vers les settings + if (errorCode === ERROR_CODES.KOMGA.MISSING_CONFIG) { + router.push("/settings"); + return; + } + + throw new Error(errorCode); } - throw new Error(errorCode); + const homeData = await response.json(); + setData(homeData); + } catch (err) { + // Ignore abort errors (caused by StrictMode cleanup) + if (err instanceof Error && err.name === "AbortError") { + return; + } + logger.error({ err }, "Error fetching home data"); + setError(err instanceof Error ? err.message : ERROR_CODES.KOMGA.SERVER_UNREACHABLE); + } finally { + if (!abortController.signal.aborted) { + setLoading(false); + } } + }; - const homeData = await response.json(); - setData(homeData); - } catch (err) { - logger.error({ err }, "Error fetching home data"); - setError(err instanceof Error ? err.message : ERROR_CODES.KOMGA.SERVER_UNREACHABLE); - } finally { - setLoading(false); - } - }; - - useEffect(() => { fetchData(); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); + + return () => { + abortController.abort(); + }; + }, [router]); const handleRefresh = async () => { try { @@ -79,14 +92,39 @@ export function ClientHomePage() { enabled: !loading && !error && !!data, }); + const handleRetry = async () => { + setLoading(true); + setError(null); + + try { + const response = await fetch("/api/komga/home"); + + if (!response.ok) { + const errorData = await response.json(); + const errorCode = errorData.error?.code || ERROR_CODES.KOMGA.SERVER_UNREACHABLE; + + if (errorCode === ERROR_CODES.KOMGA.MISSING_CONFIG) { + router.push("/settings"); + return; + } + + throw new Error(errorCode); + } + + const homeData = await response.json(); + setData(homeData); + } catch (err) { + logger.error({ err }, "Error fetching home data"); + setError(err instanceof Error ? err.message : ERROR_CODES.KOMGA.SERVER_UNREACHABLE); + } finally { + setLoading(false); + } + }; + if (loading) { return ; } - const handleRetry = () => { - fetchData(); - }; - if (error) { return (
diff --git a/src/components/layout/Sidebar.tsx b/src/components/layout/Sidebar.tsx index e1f6e7a..4338edb 100644 --- a/src/components/layout/Sidebar.tsx +++ b/src/components/layout/Sidebar.tsx @@ -26,6 +26,10 @@ import { NavButton } from "@/components/ui/nav-button"; import { IconButton } from "@/components/ui/icon-button"; import logger from "@/lib/logger"; +// Module-level flags to prevent duplicate fetches (survives StrictMode remounts) +let sidebarInitialFetchDone = false; +let sidebarFetchInProgress = false; + interface SidebarProps { isOpen: boolean; onClose: () => void; @@ -112,9 +116,18 @@ export function Sidebar({ }, [toast]); useEffect(() => { - if (Object.keys(preferences).length > 0) { - refreshLibraries(); - refreshFavorites(); + // Only load once when preferences become available (module-level flag survives StrictMode) + if ( + !sidebarInitialFetchDone && + !sidebarFetchInProgress && + Object.keys(preferences).length > 0 + ) { + sidebarFetchInProgress = true; + sidebarInitialFetchDone = true; + + Promise.all([refreshLibraries(), refreshFavorites()]).finally(() => { + sidebarFetchInProgress = false; + }); } }, [preferences, refreshLibraries, refreshFavorites]); @@ -138,6 +151,10 @@ export function Sidebar({ const handleLogout = async () => { try { + // Reset module-level flags to allow refetch on next login + sidebarInitialFetchDone = false; + sidebarFetchInProgress = false; + await signOut({ callbackUrl: "/login" }); setLibraries([]); setFavorites([]); diff --git a/src/contexts/PreferencesContext.tsx b/src/contexts/PreferencesContext.tsx index a73dfeb..f1aedb0 100644 --- a/src/contexts/PreferencesContext.tsx +++ b/src/contexts/PreferencesContext.tsx @@ -16,6 +16,10 @@ interface PreferencesContextType { const PreferencesContext = createContext(undefined); +// Module-level flag to prevent duplicate fetches (survives StrictMode remounts) +let preferencesFetchInProgress = false; +let preferencesFetched = false; + export function PreferencesProvider({ children, initialPreferences, @@ -29,7 +33,17 @@ export function PreferencesProvider({ ); const [isLoading, setIsLoading] = useState(false); + // Check if we have valid initial preferences from server + const hasValidInitialPreferences = + initialPreferences && Object.keys(initialPreferences).length > 0; + const fetchPreferences = useCallback(async () => { + // Prevent concurrent fetches + if (preferencesFetchInProgress || preferencesFetched) { + return; + } + preferencesFetchInProgress = true; + try { const response = await fetch("/api/preferences"); if (!response.ok) { @@ -45,25 +59,30 @@ export function PreferencesProvider({ viewMode: data.displayMode?.viewMode || defaultPreferences.displayMode.viewMode, }, }); + preferencesFetched = true; } catch (error) { logger.error({ err: error }, "Erreur lors de la récupération des préférences"); setPreferences(defaultPreferences); } finally { setIsLoading(false); + preferencesFetchInProgress = false; } }, []); useEffect(() => { - // Recharger les préférences quand la session change (connexion/déconnexion) if (status === "authenticated") { - // Toujours recharger depuis l'API pour avoir les dernières valeurs - // même si on a des initialPreferences (qui peuvent être en cache) + // Skip refetch if we already have valid initial preferences from server + if (hasValidInitialPreferences) { + preferencesFetched = true; // Mark as fetched since we have server data + return; + } fetchPreferences(); } else if (status === "unauthenticated") { - // Réinitialiser aux préférences par défaut quand l'utilisateur se déconnecte + // Reset to defaults when user logs out setPreferences(defaultPreferences); + preferencesFetched = false; // Allow refetch on next login } - }, [status, fetchPreferences]); + }, [status, fetchPreferences, hasValidInitialPreferences]); const updatePreferences = useCallback(async (newPreferences: Partial) => { try { diff --git a/src/lib/services/book.service.ts b/src/lib/services/book.service.ts index ebe887c..3836c66 100644 --- a/src/lib/services/book.service.ts +++ b/src/lib/services/book.service.ts @@ -3,7 +3,6 @@ import type { KomgaBook, KomgaBookWithPages } from "@/types/komga"; import type { ImageResponse } from "./image.service"; import { ImageService } from "./image.service"; import { PreferencesService } from "./preferences.service"; -import { SeriesService } from "./series.service"; import { ERROR_CODES } from "../../constants/errorCodes"; import { AppError } from "../../utils/errors"; @@ -192,32 +191,47 @@ export class BookService extends BaseApiService { }); } - const { LibraryService } = await import("./library.service"); - - // Faire une requête légère : prendre une page de séries d'une bibliothèque au hasard + // Use books/list directly with library filter to avoid extra series/list call const randomLibraryIndex = Math.floor(Math.random() * libraryIds.length); const randomLibraryId = libraryIds[randomLibraryIndex]; - // Récupérer juste une page de séries (pas toutes) - const seriesResponse = await LibraryService.getLibrarySeries(randomLibraryId, 0, 20); + // Random page offset for variety (assuming most libraries have at least 100 books) + const randomPage = Math.floor(Math.random() * 5); // Pages 0-4 - if (seriesResponse.content.length === 0) { - throw new AppError(ERROR_CODES.BOOK.NOT_FOUND, { - message: "Aucune série trouvée dans les bibliothèques sélectionnées", - }); - } + const searchBody = { + condition: { + libraryId: { + operator: "is", + value: randomLibraryId, + }, + }, + }; - // Choisir une série au hasard parmi celles récupérées - const randomSeriesIndex = Math.floor(Math.random() * seriesResponse.content.length); - const randomSeries = seriesResponse.content[randomSeriesIndex]; - - // Récupérer les books de cette série avec pagination - const booksResponse = await SeriesService.getSeriesBooks(randomSeries.id, 0, 100); + const booksResponse = await this.fetchFromApi<{ content: KomgaBook[]; totalElements: number }>( + { path: "books/list", params: { page: String(randomPage), size: "20", sort: "number,asc" } }, + { "Content-Type": "application/json" }, + { method: "POST", body: JSON.stringify(searchBody) } + ); if (booksResponse.content.length === 0) { - throw new AppError(ERROR_CODES.BOOK.NOT_FOUND, { - message: "Aucun livre trouvé dans la série", - }); + // Fallback to page 0 if random page was empty + const fallbackResponse = await this.fetchFromApi<{ + content: KomgaBook[]; + totalElements: number; + }>( + { path: "books/list", params: { page: "0", size: "20", sort: "number,asc" } }, + { "Content-Type": "application/json" }, + { method: "POST", body: JSON.stringify(searchBody) } + ); + + if (fallbackResponse.content.length === 0) { + throw new AppError(ERROR_CODES.BOOK.NOT_FOUND, { + message: "Aucun livre trouvé dans les bibliothèques sélectionnées", + }); + } + + const randomBookIndex = Math.floor(Math.random() * fallbackResponse.content.length); + return fallbackResponse.content[randomBookIndex].id; } const randomBookIndex = Math.floor(Math.random() * booksResponse.content.length); diff --git a/src/lib/services/library.service.ts b/src/lib/services/library.service.ts index 1427dbd..d00a222 100644 --- a/src/lib/services/library.service.ts +++ b/src/lib/services/library.service.ts @@ -16,12 +16,7 @@ export class LibraryService extends BaseApiService { static async getLibrary(libraryId: string): Promise { try { - const libraries = await this.getLibraries(); - const library = libraries.find((library) => library.id === libraryId); - if (!library) { - throw new AppError(ERROR_CODES.LIBRARY.NOT_FOUND, { libraryId }); - } - return library; + return this.fetchFromApi({ path: `libraries/${libraryId}` }); } catch (error) { if (error instanceof AppError) { throw error;