fix: Prevent Library From Remaining in Loading State
This commit is contained in:
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user