fix: Prevent Library From Remaining in Loading State

This commit is contained in:
Rajarshee Chatterjee
2026-08-04 18:45:16 +05:30
parent c3b75ebeb2
commit 1eb8c587c7
3 changed files with 111 additions and 19 deletions
+30 -7
View File
@@ -23,6 +23,8 @@ import {
import {
SearchbarV2,
Button,
EmptyView,
ErrorScreenV2,
SafeAreaView,
TopTabBar,
} from '@components/index';
@@ -86,6 +88,7 @@ const LibraryScreen = ({ navigation }: LibraryScreenProps) => {
categories,
refetchLibrary,
isLoading,
error: libraryError,
settings: {
showNumberOfNovels,
downloadedOnlyMode,
@@ -258,9 +261,7 @@ const LibraryScreen = ({ navigation }: LibraryScreenProps) => {
)
: unfilteredNovels;
return isLoading ? (
<SourceScreenSkeletonLoading theme={theme} />
) : (
return (
<>
{searchText ? (
<Button
@@ -286,14 +287,12 @@ const LibraryScreen = ({ navigation }: LibraryScreenProps) => {
);
},
[
isLoading,
library,
navigation,
pickAndImport,
searchText,
searchLower,
styles.globalSearchBtn,
theme,
],
);
@@ -493,7 +492,20 @@ const LibraryScreen = ({ navigation }: LibraryScreenProps) => {
) : null}
<SelectionContext.Provider value={selectionContextValue}>
{categories.length ? (
{isLoading ? (
<SourceScreenSkeletonLoading theme={theme} />
) : libraryError ? (
<ErrorScreenV2
error={libraryError}
actions={[
{
iconName: 'refresh',
title: getString('common.retry'),
onPress: refetchLibrary,
},
]}
/>
) : categories.length ? (
<TabView
commonOptions={{
label: renderLabel,
@@ -506,7 +518,18 @@ const LibraryScreen = ({ navigation }: LibraryScreenProps) => {
initialLayout={{ width: layout.width }}
/>
) : (
<SourceScreenSkeletonLoading theme={theme} />
<EmptyView
theme={theme}
icon="Σ(ಠ_ಠ)"
description={getString('libraryScreen.emptyCategory')}
actions={[
{
iconName: 'compass-outline',
title: getString('browse'),
onPress: () => navigation.navigate('Browse'),
},
]}
/>
)}
</SelectionContext.Provider>
@@ -1,6 +1,11 @@
import { act, renderHook } from '@testing-library/react-native';
import { act, renderHook, waitFor } from '@testing-library/react-native';
import { useFocusEffect } from '@react-navigation/native';
import { getLibraryNovelsQuery } from '@database/queries/LibraryQueries';
import {
getLibraryNovelsFromDb,
getLibraryNovelsQuery,
} from '@database/queries/LibraryQueries';
import { getCategoriesFromDb } from '@database/queries/CategoryQueries';
import { NovelInfo } from '@database/types';
import { useLiveQuery } from '@database/manager/liveQuery';
import { useLibrary } from '../useLibrary';
@@ -49,8 +54,21 @@ const mockGetLibraryNovelsQuery = getLibraryNovelsQuery as jest.MockedFunction<
const mockUseLiveQuery = useLiveQuery as jest.MockedFunction<
typeof useLiveQuery
>;
const mockGetLibraryNovelsFromDb =
getLibraryNovelsFromDb as jest.MockedFunction<typeof getLibraryNovelsFromDb>;
const mockGetCategoriesFromDb = getCategoriesFromDb as jest.MockedFunction<
typeof getCategoriesFromDb
>;
const mockUseFocusEffect = useFocusEffect as jest.MockedFunction<
typeof useFocusEffect
>;
describe('useLibrary', () => {
beforeEach(() => {
mockGetLibraryNovelsFromDb.mockResolvedValue([]);
mockGetCategoriesFromDb.mockResolvedValue([]);
});
it('updates the library when the reactive Novel query changes', () => {
let onLibraryChange: ((novels: NovelInfo[]) => void) | undefined;
mockUseLiveQuery.mockImplementation((_query, fireOn, callback) => {
@@ -78,4 +96,26 @@ describe('useLibrary', () => {
);
expect(result.current.library).toEqual([updatedNovel]);
});
it('stops loading and exposes database errors', async () => {
const databaseError = new Error('Failed to load categories');
mockGetCategoriesFromDb.mockRejectedValueOnce(databaseError);
mockUseFocusEffect.mockImplementationOnce(callback => {
callback();
});
const { result } = renderHook(useLibrary);
await waitFor(() => expect(result.current.isLoading).toBe(false));
expect(result.current.error).toBe(databaseError);
});
it('keeps the focus callback stable across renders', () => {
const { rerender } = renderHook(useLibrary);
const firstFocusCallback = mockUseFocusEffect.mock.calls.at(-1)?.[0];
rerender(undefined);
expect(mockUseFocusEffect.mock.calls.at(-1)?.[0]).toBe(firstFocusCallback);
});
});
+39 -10
View File
@@ -27,6 +27,7 @@ export type UseLibraryReturnType = {
library: NovelInfo[];
categories: ExtendedCategory[];
isLoading: boolean;
error?: unknown;
setCategories: React.Dispatch<React.SetStateAction<ExtendedCategory[]>>;
refreshCategories: () => Promise<void>;
setLibrary: React.Dispatch<React.SetStateAction<NovelInfo[]>>;
@@ -46,7 +47,11 @@ export const useLibrary = (): UseLibraryReturnType => {
const [library, setLibrary] = useState<NovelInfo[]>([]);
const [categories, setCategories] = useState<ExtendedCategory[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<unknown>();
const [searchText, setSearchText] = useState('');
const hasLoadedRef = useRef(false);
const hasErrorRef = useRef(false);
const loadRequestIdRef = useRef(0);
const libraryQuery = useMemo(
() =>
@@ -79,17 +84,38 @@ export const useLibrary = (): UseLibraryReturnType => {
}, []);
const getLibrary = useCallback(async () => {
if (searchText) {
const requestId = ++loadRequestIdRef.current;
if (!hasLoadedRef.current || hasErrorRef.current || searchText) {
setIsLoading(true);
}
hasErrorRef.current = false;
setError(undefined);
const [, novels] = await Promise.all([
refreshCategories(),
getLibraryNovelsFromDb(sortOrder, filter, searchText, downloadedOnlyMode),
]);
try {
const [, novels] = await Promise.all([
refreshCategories(),
getLibraryNovelsFromDb(
sortOrder,
filter,
searchText,
downloadedOnlyMode,
),
]);
setLibrary(novels);
setIsLoading(false);
if (requestId === loadRequestIdRef.current) {
setLibrary(novels);
}
} catch (loadError) {
if (requestId === loadRequestIdRef.current) {
hasErrorRef.current = true;
setError(loadError);
}
} finally {
if (requestId === loadRequestIdRef.current) {
hasLoadedRef.current = true;
setIsLoading(false);
}
}
}, [downloadedOnlyMode, filter, refreshCategories, searchText, sortOrder]);
const libraryLookup = useMemo(() => {
@@ -125,9 +151,11 @@ export const useLibrary = (): UseLibraryReturnType => {
[downloadedOnlyMode, filter, refreshCategories, searchText, sortOrder],
);
useFocusEffect(() => {
getLibrary();
});
useFocusEffect(
useCallback(() => {
void getLibrary();
}, [getLibrary]),
);
const [taskQueue] = useMMKVObject<(BackgroundTask | QueuedBackgroundTask)[]>(
BACKGROUND_TASKS_STORE_KEY,
@@ -186,6 +214,7 @@ export const useLibrary = (): UseLibraryReturnType => {
library,
categories,
isLoading,
error,
setLibrary,
setCategories,
refreshCategories,