refactor: implement abort controller for fetch requests in multiple components to prevent memory leaks and improve error handling

This commit is contained in:
Julien Froidefond
2026-01-03 21:51:07 +01:00
parent 512e9a480f
commit e903b55a46
7 changed files with 179 additions and 68 deletions

View File

@@ -44,6 +44,8 @@ export function ClientLibraryPage({
const effectivePageSize = pageSize || preferences.displayMode?.itemsPerPage || DEFAULT_PAGE_SIZE; const effectivePageSize = pageSize || preferences.displayMode?.itemsPerPage || DEFAULT_PAGE_SIZE;
useEffect(() => { useEffect(() => {
const abortController = new AbortController();
const fetchData = async () => { const fetchData = async () => {
setLoading(true); setLoading(true);
setError(null); setError(null);
@@ -59,7 +61,9 @@ export function ClientLibraryPage({
params.append("search", search); 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) { if (!response.ok) {
const errorData = await response.json(); const errorData = await response.json();
@@ -70,14 +74,24 @@ export function ClientLibraryPage({
setLibrary(data.library); setLibrary(data.library);
setSeries(data.series); setSeries(data.series);
} catch (err) { } catch (err) {
// Ignore abort errors (caused by StrictMode cleanup)
if (err instanceof Error && err.name === "AbortError") {
return;
}
logger.error({ err }, "Error fetching library series"); logger.error({ err }, "Error fetching library series");
setError(err instanceof Error ? err.message : "SERIES_FETCH_ERROR"); setError(err instanceof Error ? err.message : "SERIES_FETCH_ERROR");
} finally { } finally {
if (!abortController.signal.aborted) {
setLoading(false); setLoading(false);
} }
}
}; };
fetchData(); fetchData();
return () => {
abortController.abort();
};
}, [libraryId, currentPage, unreadOnly, search, effectivePageSize]); }, [libraryId, currentPage, unreadOnly, search, effectivePageSize]);
const handleRefresh = async (libraryId: string) => { const handleRefresh = async (libraryId: string) => {

View File

@@ -38,6 +38,8 @@ export function ClientSeriesPage({
const effectivePageSize = pageSize || preferences.displayMode?.itemsPerPage || DEFAULT_PAGE_SIZE; const effectivePageSize = pageSize || preferences.displayMode?.itemsPerPage || DEFAULT_PAGE_SIZE;
useEffect(() => { useEffect(() => {
const abortController = new AbortController();
const fetchData = async () => { const fetchData = async () => {
setLoading(true); setLoading(true);
setError(null); setError(null);
@@ -49,7 +51,9 @@ export function ClientSeriesPage({
unread: String(unreadOnly), 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) { if (!response.ok) {
const errorData = await response.json(); const errorData = await response.json();
@@ -60,14 +64,24 @@ export function ClientSeriesPage({
setSeries(data.series); setSeries(data.series);
setBooks(data.books); setBooks(data.books);
} catch (err) { } catch (err) {
// Ignore abort errors (caused by StrictMode cleanup)
if (err instanceof Error && err.name === "AbortError") {
return;
}
logger.error({ err }, "Error fetching series books"); logger.error({ err }, "Error fetching series books");
setError(err instanceof Error ? err.message : ERROR_CODES.BOOK.PAGES_FETCH_ERROR); setError(err instanceof Error ? err.message : ERROR_CODES.BOOK.PAGES_FETCH_ERROR);
} finally { } finally {
if (!abortController.signal.aborted) {
setLoading(false); setLoading(false);
} }
}
}; };
fetchData(); fetchData();
return () => {
abortController.abort();
};
}, [seriesId, currentPage, unreadOnly, effectivePageSize]); }, [seriesId, currentPage, unreadOnly, effectivePageSize]);
const handleRefresh = async (seriesId: string) => { const handleRefresh = async (seriesId: string) => {

View File

@@ -17,12 +17,17 @@ export function ClientHomePage() {
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
useEffect(() => {
const abortController = new AbortController();
const fetchData = async () => { const fetchData = async () => {
setLoading(true); setLoading(true);
setError(null); setError(null);
try { try {
const response = await fetch("/api/komga/home"); const response = await fetch("/api/komga/home", {
signal: abortController.signal,
});
if (!response.ok) { if (!response.ok) {
const errorData = await response.json(); const errorData = await response.json();
@@ -40,17 +45,25 @@ export function ClientHomePage() {
const homeData = await response.json(); const homeData = await response.json();
setData(homeData); setData(homeData);
} catch (err) { } catch (err) {
// Ignore abort errors (caused by StrictMode cleanup)
if (err instanceof Error && err.name === "AbortError") {
return;
}
logger.error({ err }, "Error fetching home data"); logger.error({ err }, "Error fetching home data");
setError(err instanceof Error ? err.message : ERROR_CODES.KOMGA.SERVER_UNREACHABLE); setError(err instanceof Error ? err.message : ERROR_CODES.KOMGA.SERVER_UNREACHABLE);
} finally { } finally {
if (!abortController.signal.aborted) {
setLoading(false); setLoading(false);
} }
}
}; };
useEffect(() => {
fetchData(); fetchData();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []); return () => {
abortController.abort();
};
}, [router]);
const handleRefresh = async () => { const handleRefresh = async () => {
try { try {
@@ -79,14 +92,39 @@ export function ClientHomePage() {
enabled: !loading && !error && !!data, 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) { if (loading) {
return <HomePageSkeleton />; return <HomePageSkeleton />;
} }
const handleRetry = () => {
fetchData();
};
if (error) { if (error) {
return ( return (
<main className="container mx-auto px-4 py-8"> <main className="container mx-auto px-4 py-8">

View File

@@ -26,6 +26,10 @@ import { NavButton } from "@/components/ui/nav-button";
import { IconButton } from "@/components/ui/icon-button"; import { IconButton } from "@/components/ui/icon-button";
import logger from "@/lib/logger"; import logger from "@/lib/logger";
// Module-level flags to prevent duplicate fetches (survives StrictMode remounts)
let sidebarInitialFetchDone = false;
let sidebarFetchInProgress = false;
interface SidebarProps { interface SidebarProps {
isOpen: boolean; isOpen: boolean;
onClose: () => void; onClose: () => void;
@@ -112,9 +116,18 @@ export function Sidebar({
}, [toast]); }, [toast]);
useEffect(() => { useEffect(() => {
if (Object.keys(preferences).length > 0) { // Only load once when preferences become available (module-level flag survives StrictMode)
refreshLibraries(); if (
refreshFavorites(); !sidebarInitialFetchDone &&
!sidebarFetchInProgress &&
Object.keys(preferences).length > 0
) {
sidebarFetchInProgress = true;
sidebarInitialFetchDone = true;
Promise.all([refreshLibraries(), refreshFavorites()]).finally(() => {
sidebarFetchInProgress = false;
});
} }
}, [preferences, refreshLibraries, refreshFavorites]); }, [preferences, refreshLibraries, refreshFavorites]);
@@ -138,6 +151,10 @@ export function Sidebar({
const handleLogout = async () => { const handleLogout = async () => {
try { try {
// Reset module-level flags to allow refetch on next login
sidebarInitialFetchDone = false;
sidebarFetchInProgress = false;
await signOut({ callbackUrl: "/login" }); await signOut({ callbackUrl: "/login" });
setLibraries([]); setLibraries([]);
setFavorites([]); setFavorites([]);

View File

@@ -16,6 +16,10 @@ interface PreferencesContextType {
const PreferencesContext = createContext<PreferencesContextType | undefined>(undefined); const PreferencesContext = createContext<PreferencesContextType | undefined>(undefined);
// Module-level flag to prevent duplicate fetches (survives StrictMode remounts)
let preferencesFetchInProgress = false;
let preferencesFetched = false;
export function PreferencesProvider({ export function PreferencesProvider({
children, children,
initialPreferences, initialPreferences,
@@ -29,7 +33,17 @@ export function PreferencesProvider({
); );
const [isLoading, setIsLoading] = useState(false); 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 () => { const fetchPreferences = useCallback(async () => {
// Prevent concurrent fetches
if (preferencesFetchInProgress || preferencesFetched) {
return;
}
preferencesFetchInProgress = true;
try { try {
const response = await fetch("/api/preferences"); const response = await fetch("/api/preferences");
if (!response.ok) { if (!response.ok) {
@@ -45,25 +59,30 @@ export function PreferencesProvider({
viewMode: data.displayMode?.viewMode || defaultPreferences.displayMode.viewMode, viewMode: data.displayMode?.viewMode || defaultPreferences.displayMode.viewMode,
}, },
}); });
preferencesFetched = true;
} catch (error) { } catch (error) {
logger.error({ err: error }, "Erreur lors de la récupération des préférences"); logger.error({ err: error }, "Erreur lors de la récupération des préférences");
setPreferences(defaultPreferences); setPreferences(defaultPreferences);
} finally { } finally {
setIsLoading(false); setIsLoading(false);
preferencesFetchInProgress = false;
} }
}, []); }, []);
useEffect(() => { useEffect(() => {
// Recharger les préférences quand la session change (connexion/déconnexion)
if (status === "authenticated") { if (status === "authenticated") {
// Toujours recharger depuis l'API pour avoir les dernières valeurs // Skip refetch if we already have valid initial preferences from server
// même si on a des initialPreferences (qui peuvent être en cache) if (hasValidInitialPreferences) {
preferencesFetched = true; // Mark as fetched since we have server data
return;
}
fetchPreferences(); fetchPreferences();
} else if (status === "unauthenticated") { } 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); setPreferences(defaultPreferences);
preferencesFetched = false; // Allow refetch on next login
} }
}, [status, fetchPreferences]); }, [status, fetchPreferences, hasValidInitialPreferences]);
const updatePreferences = useCallback(async (newPreferences: Partial<UserPreferences>) => { const updatePreferences = useCallback(async (newPreferences: Partial<UserPreferences>) => {
try { try {

View File

@@ -3,7 +3,6 @@ import type { KomgaBook, KomgaBookWithPages } from "@/types/komga";
import type { ImageResponse } from "./image.service"; import type { ImageResponse } from "./image.service";
import { ImageService } from "./image.service"; import { ImageService } from "./image.service";
import { PreferencesService } from "./preferences.service"; import { PreferencesService } from "./preferences.service";
import { SeriesService } from "./series.service";
import { ERROR_CODES } from "../../constants/errorCodes"; import { ERROR_CODES } from "../../constants/errorCodes";
import { AppError } from "../../utils/errors"; import { AppError } from "../../utils/errors";
@@ -192,32 +191,47 @@ export class BookService extends BaseApiService {
}); });
} }
const { LibraryService } = await import("./library.service"); // Use books/list directly with library filter to avoid extra series/list call
// Faire une requête légère : prendre une page de séries d'une bibliothèque au hasard
const randomLibraryIndex = Math.floor(Math.random() * libraryIds.length); const randomLibraryIndex = Math.floor(Math.random() * libraryIds.length);
const randomLibraryId = libraryIds[randomLibraryIndex]; const randomLibraryId = libraryIds[randomLibraryIndex];
// Récupérer juste une page de séries (pas toutes) // Random page offset for variety (assuming most libraries have at least 100 books)
const seriesResponse = await LibraryService.getLibrarySeries(randomLibraryId, 0, 20); const randomPage = Math.floor(Math.random() * 5); // Pages 0-4
if (seriesResponse.content.length === 0) { const searchBody = {
condition: {
libraryId: {
operator: "is",
value: randomLibraryId,
},
},
};
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) {
// 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, { throw new AppError(ERROR_CODES.BOOK.NOT_FOUND, {
message: "Aucune série trouvée dans les bibliothèques sélectionnées", message: "Aucun livre trouvé dans les bibliothèques sélectionnées",
}); });
} }
// Choisir une série au hasard parmi celles récupérées const randomBookIndex = Math.floor(Math.random() * fallbackResponse.content.length);
const randomSeriesIndex = Math.floor(Math.random() * seriesResponse.content.length); return fallbackResponse.content[randomBookIndex].id;
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);
if (booksResponse.content.length === 0) {
throw new AppError(ERROR_CODES.BOOK.NOT_FOUND, {
message: "Aucun livre trouvé dans la série",
});
} }
const randomBookIndex = Math.floor(Math.random() * booksResponse.content.length); const randomBookIndex = Math.floor(Math.random() * booksResponse.content.length);

View File

@@ -16,12 +16,7 @@ export class LibraryService extends BaseApiService {
static async getLibrary(libraryId: string): Promise<KomgaLibrary> { static async getLibrary(libraryId: string): Promise<KomgaLibrary> {
try { try {
const libraries = await this.getLibraries(); return this.fetchFromApi<KomgaLibrary>({ path: `libraries/${libraryId}` });
const library = libraries.find((library) => library.id === libraryId);
if (!library) {
throw new AppError(ERROR_CODES.LIBRARY.NOT_FOUND, { libraryId });
}
return library;
} catch (error) { } catch (error) {
if (error instanceof AppError) { if (error instanceof AppError) {
throw error; throw error;