From 4c5b452299604dcc40a6348907518edb5564888f Mon Sep 17 00:00:00 2001 From: bizzkoot Date: Fri, 14 Aug 2026 22:12:59 +0800 Subject: [PATCH 01/54] fix(novel): select all chapters across lazy-loaded batches (upstream #1960) --- src/database/queries/ChapterQueries.ts | 120 +++++++-- .../ChapterQueries.selection.test.ts | 240 ++++++++++++++++++ src/hooks/persisted/useNovel.ts | 12 +- src/screens/novel/NovelScreen.tsx | 25 +- .../novel/components/NovelScreenList.tsx | 7 +- 5 files changed, 371 insertions(+), 33 deletions(-) create mode 100644 src/database/queries/__tests__/ChapterQueries.selection.test.ts diff --git a/src/database/queries/ChapterQueries.ts b/src/database/queries/ChapterQueries.ts index 346eeb8446..d96d2a687b 100644 --- a/src/database/queries/ChapterQueries.ts +++ b/src/database/queries/ChapterQueries.ts @@ -13,6 +13,17 @@ import { db } from '@database/db'; import NativeFile from '@specs/NativeFile'; import { MMKVStorage } from '@utils/mmkv/mmkv'; +const CHAPTER_ID_BATCH_SIZE = 500; +const chunkChapterIds = (chapterIds: number[]) => + Array.from( + { length: Math.ceil(chapterIds.length / CHAPTER_ID_BATCH_SIZE) }, + (_, index) => + chapterIds.slice( + index * CHAPTER_ID_BATCH_SIZE, + (index + 1) * CHAPTER_ID_BATCH_SIZE, + ), + ); + // #region Mutations export const insertChapters = async ( @@ -76,10 +87,16 @@ export const insertChapters = async ( export const markChapterRead = (chapterId: number) => db.runAsync('UPDATE Chapter SET `unread` = 0 WHERE id = ?', chapterId); -export const markChaptersRead = (chapterIds: number[]) => - db.execAsync( - `UPDATE Chapter SET \`unread\` = 0 WHERE id IN (${chapterIds.join(',')})`, - ); +export const markChaptersRead = async (chapterIds: number[]) => { + if (!chapterIds.length) { + return; + } + for (const ids of chunkChapterIds(chapterIds)) { + await db.execAsync( + `UPDATE Chapter SET \`unread\` = 0 WHERE id IN (${ids.join(',')})`, + ); + } +}; export const markChapterUnread = (chapterId: number) => { // Clear MMKV saved progress when marking unread @@ -87,14 +104,19 @@ export const markChapterUnread = (chapterId: number) => { return db.runAsync('UPDATE Chapter SET `unread` = 1 WHERE id = ?', chapterId); }; -export const markChaptersUnread = (chapterIds: number[]) => { +export const markChaptersUnread = async (chapterIds: number[]) => { + if (!chapterIds.length) { + return; + } // Clear MMKV saved progress for all chapters being marked unread chapterIds.forEach(id => { MMKVStorage.delete(`chapter_progress_${id}`); }); - return db.execAsync( - `UPDATE Chapter SET \`unread\` = 1 WHERE id IN (${chapterIds.join(',')})`, - ); + for (const ids of chunkChapterIds(chapterIds)) { + await db.execAsync( + `UPDATE Chapter SET \`unread\` = 1 WHERE id IN (${ids.join(',')})`, + ); + } }; export const markAllChaptersRead = (novelId: number) => @@ -147,21 +169,19 @@ export const deleteChapter = async ( export const deleteChapters = async ( pluginId: string, novelId: number, - chapters?: ChapterInfo[], + chapterIds?: number[], ) => { - if (!chapters?.length) { + if (!chapterIds?.length) { return; } - const chapterIdsString = chapters?.map(chapter => chapter.id).toString(); - - await Promise.all( - chapters?.map(chapter => - deleteDownloadedFiles(pluginId, novelId, chapter.id), - ), - ); - await db.execAsync( - `UPDATE Chapter SET isDownloaded = 0 WHERE id IN (${chapterIdsString})`, - ); + for (const ids of chunkChapterIds(chapterIds)) { + await Promise.all( + ids.map(chapterId => deleteDownloadedFiles(pluginId, novelId, chapterId)), + ); + await db.execAsync( + `UPDATE Chapter SET isDownloaded = 0 WHERE id IN (${ids.join(',')})`, + ); + } }; export const deleteDownloads = async (chapters: DownloadedChapter[]) => { @@ -209,14 +229,20 @@ export const updateChapterTTSState = (chapterId: number, ttsState: string) => chapterId, ); -export const updateChapterProgressByIds = ( +export const updateChapterProgressByIds = async ( chapterIds: number[], progress: number, -) => - db.runAsync( - `UPDATE Chapter SET progress = ? WHERE id in (${chapterIds.join(',')})`, - progress, - ); +) => { + if (!chapterIds.length) { + return; + } + for (const ids of chunkChapterIds(chapterIds)) { + await db.runAsync( + `UPDATE Chapter SET progress = ? WHERE id in (${ids.join(',')})`, + progress, + ); + } +}; export const bookmarkChapter = (chapterId: number) => db.runAsync( @@ -224,6 +250,17 @@ export const bookmarkChapter = (chapterId: number) => chapterId, ); +export const bookmarkChapters = async (chapterIds: number[]) => { + if (!chapterIds.length) { + return; + } + for (const ids of chunkChapterIds(chapterIds)) { + await db.execAsync( + `UPDATE Chapter SET bookmark = (CASE WHEN bookmark = 0 THEN 1 ELSE 0 END) WHERE id IN (${ids.join(',')})`, + ); + } +}; + export const markPreviuschaptersRead = (chapterId: number, novelId: number) => db.runAsync( 'UPDATE Chapter SET `unread` = 0 WHERE id <= ? AND novelId = ?', @@ -406,6 +443,37 @@ export const getPageChapters = ( ); }; +export const getPageChapterIds = ( + novelId: number, + filter?: string, + page?: string, +): number[] => { + const rows = db.getAllSync<{ id: number }>( + `SELECT id FROM Chapter WHERE novelId = ? AND page = ? ${filter || ''}`, + novelId, + page || '1', + ); + return (rows ?? []).map(row => row.id); +}; + +export const getChaptersByIds = (chapterIds: number[]): ChapterInfo[] => { + if (!chapterIds.length) { + return []; + } + const chapters = chunkChapterIds(chapterIds).map(ids => + db.getAllSync( + `SELECT * FROM Chapter WHERE id IN (${ids.join(',')})`, + ), + ); + const chaptersById = new Map( + chapters.flat().map(chapter => [chapter.id, chapter]), + ); + return chapterIds.flatMap(chapterId => { + const chapter = chaptersById.get(chapterId); + return chapter ? [chapter] : []; + }); +}; + export const getChapterCount = (novelId: number, page: string = '1') => db.getFirstSync<{ 'COUNT(*)': number }>( 'SELECT COUNT(*) FROM Chapter WHERE novelId = ? AND page = ?', diff --git a/src/database/queries/__tests__/ChapterQueries.selection.test.ts b/src/database/queries/__tests__/ChapterQueries.selection.test.ts new file mode 100644 index 0000000000..24bd45c5dd --- /dev/null +++ b/src/database/queries/__tests__/ChapterQueries.selection.test.ts @@ -0,0 +1,240 @@ +import { db } from '@database/db'; +import * as ChapterQueries from '../ChapterQueries'; +import NativeFile from '@specs/NativeFile'; +import { MMKVStorage } from '@utils/mmkv/mmkv'; + +jest.mock('@database/db', () => ({ + db: { + runAsync: jest.fn(() => + Promise.resolve({ lastInsertRowId: 1, changes: 1 }), + ), + execAsync: jest.fn(() => Promise.resolve()), + withExclusiveTransactionAsync: jest.fn(callback => + callback({ + runAsync: jest.fn(() => + Promise.resolve({ lastInsertRowId: 1, changes: 1 }), + ), + }), + ), + getAllAsync: jest.fn(() => Promise.resolve([])), + getFirstAsync: jest.fn(() => Promise.resolve(null)), + getFirstSync: jest.fn(() => null), + getAllSync: jest.fn(() => []), + }, +})); + +jest.mock('@utils/showToast', () => ({ + showToast: jest.fn(), +})); + +jest.mock('@strings/translations', () => ({ + getString: jest.fn(key => key), +})); + +jest.mock('@utils/Storages', () => ({ + NOVEL_STORAGE: 'file://novels', +})); + +jest.mock('@specs/NativeFile', () => ({ + unlink: jest.fn(), +})); + +jest.mock('@utils/mmkv/mmkv', () => ({ + MMKVStorage: { + set: jest.fn(), + getString: jest.fn(), + delete: jest.fn(), + }, +})); + +const buildChapterRows = (ids: number[]) => + ids.map(id => ({ + id, + novelId: 1, + name: `Chapter ${id}`, + position: id, + unread: 1, + bookmark: 0, + isDownloaded: 1, + page: '1', + })); + +describe('ChapterQueries select-all across lazy batches', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe('getPageChapterIds', () => { + it('should query ids for the current page + filter without a batch limit', () => { + (db.getAllSync as jest.Mock).mockReturnValue([ + { id: 1 }, + { id: 2 }, + { id: 3 }, + ]); + + const ids = ChapterQueries.getPageChapterIds(42, ' AND unread = 1', '2'); + + expect(db.getAllSync).toHaveBeenCalledWith( + 'SELECT id FROM Chapter WHERE novelId = ? AND page = ? AND unread = 1', + 42, + '2', + ); + expect(ids).toEqual([1, 2, 3]); + }); + + it('should default to page 1 and empty filter when omitted', () => { + (db.getAllSync as jest.Mock).mockReturnValue([]); + + ChapterQueries.getPageChapterIds(42); + + expect(db.getAllSync).toHaveBeenCalledWith( + 'SELECT id FROM Chapter WHERE novelId = ? AND page = ? ', + 42, + '1', + ); + }); + }); + + describe('getChaptersByIds', () => { + it('should return rows in the requested id order', () => { + (db.getAllSync as jest.Mock).mockReturnValueOnce( + buildChapterRows([1, 2, 3]), + ); + + const chapters = ChapterQueries.getChaptersByIds([3, 1, 2]); + + expect(chapters.map(ch => ch.id)).toEqual([3, 1, 2]); + expect(db.getAllSync).toHaveBeenCalledTimes(1); + expect(db.getAllSync).toHaveBeenCalledWith( + 'SELECT * FROM Chapter WHERE id IN (3,1,2)', + ); + }); + + it('should chunk lookups beyond CHAPTER_ID_BATCH_SIZE and merge in order', () => { + const ids = Array.from({ length: 1200 }, (_, i) => i + 1); + (db.getAllSync as jest.Mock) + .mockReturnValueOnce(buildChapterRows(ids.slice(0, 500))) + .mockReturnValueOnce(buildChapterRows(ids.slice(500, 1000))) + .mockReturnValueOnce(buildChapterRows(ids.slice(1000, 1200))); + + const chapters = ChapterQueries.getChaptersByIds(ids); + + expect(db.getAllSync).toHaveBeenCalledTimes(3); + expect(db.getAllSync).toHaveBeenNthCalledWith( + 1, + `SELECT * FROM Chapter WHERE id IN (${ids.slice(0, 500).join(',')})`, + ); + expect(db.getAllSync).toHaveBeenNthCalledWith( + 3, + `SELECT * FROM Chapter WHERE id IN (${ids.slice(1000, 1200).join(',')})`, + ); + expect(chapters).toHaveLength(1200); + expect(chapters[0].id).toBe(1); + expect(chapters[1199].id).toBe(1200); + }); + + it('should skip ids with no matching row and return [] for empty input', () => { + (db.getAllSync as jest.Mock).mockReturnValueOnce( + buildChapterRows([1, 3]), + ); + + const chapters = ChapterQueries.getChaptersByIds([1, 2, 3]); + + expect(chapters.map(ch => ch.id)).toEqual([1, 3]); + expect(ChapterQueries.getChaptersByIds([])).toEqual([]); + expect(db.getAllSync).toHaveBeenCalledTimes(1); + }); + }); + + describe('chunked bulk mutations', () => { + const manyIds = Array.from({ length: 1200 }, (_, i) => i + 1); + const chunkSql = (ids: number[]) => ids.join(','); + + it('markChaptersRead should chunk the UPDATE by 500', async () => { + await ChapterQueries.markChaptersRead(manyIds); + + expect(db.execAsync).toHaveBeenCalledTimes(3); + expect(db.execAsync).toHaveBeenNthCalledWith( + 1, + `UPDATE Chapter SET \`unread\` = 0 WHERE id IN (${chunkSql(manyIds.slice(0, 500))})`, + ); + expect(db.execAsync).toHaveBeenNthCalledWith( + 3, + `UPDATE Chapter SET \`unread\` = 0 WHERE id IN (${chunkSql(manyIds.slice(1000, 1200))})`, + ); + }); + + it('markChaptersUnread should chunk the UPDATE and clear MMKV progress for every id', async () => { + await ChapterQueries.markChaptersUnread(manyIds); + + expect(db.execAsync).toHaveBeenCalledTimes(3); + expect(db.execAsync).toHaveBeenNthCalledWith( + 1, + `UPDATE Chapter SET \`unread\` = 1 WHERE id IN (${chunkSql(manyIds.slice(0, 500))})`, + ); + expect(MMKVStorage.delete).toHaveBeenCalledTimes(1200); + expect(MMKVStorage.delete).toHaveBeenCalledWith('chapter_progress_1200'); + }); + + it('updateChapterProgressByIds should chunk and pass the progress value', async () => { + await ChapterQueries.updateChapterProgressByIds(manyIds, 100); + + expect(db.runAsync).toHaveBeenCalledTimes(3); + expect(db.runAsync).toHaveBeenNthCalledWith( + 1, + `UPDATE Chapter SET progress = ? WHERE id in (${chunkSql(manyIds.slice(0, 500))})`, + 100, + ); + }); + + it('bookmarkChapters should chunk the toggle UPDATE', async () => { + await ChapterQueries.bookmarkChapters(manyIds); + + expect(db.execAsync).toHaveBeenCalledTimes(3); + expect(db.execAsync).toHaveBeenNthCalledWith( + 1, + `UPDATE Chapter SET bookmark = (CASE WHEN bookmark = 0 THEN 1 ELSE 0 END) WHERE id IN (${chunkSql(manyIds.slice(0, 500))})`, + ); + }); + + it('bulk mutations should no-op on an empty id list', async () => { + await ChapterQueries.markChaptersRead([]); + await ChapterQueries.markChaptersUnread([]); + await ChapterQueries.updateChapterProgressByIds([], 100); + await ChapterQueries.bookmarkChapters([]); + + expect(db.execAsync).not.toHaveBeenCalled(); + expect(db.runAsync).not.toHaveBeenCalled(); + }); + }); + + describe('deleteChapters (id-based)', () => { + it('should delete downloaded files and clear the flag in 500-id chunks', async () => { + const ids = Array.from({ length: 1200 }, (_, i) => i + 1); + + await ChapterQueries.deleteChapters('plugin', 7, ids); + + // one unlink per id (chunked Promise.all groups of 500) + expect(NativeFile.unlink).toHaveBeenCalledTimes(1200); + expect(NativeFile.unlink).toHaveBeenCalledWith( + 'file://novels/plugin/7/1200', + ); + expect(db.execAsync).toHaveBeenCalledTimes(3); + expect(db.execAsync).toHaveBeenNthCalledWith( + 1, + `UPDATE Chapter SET isDownloaded = 0 WHERE id IN (${ids.slice(0, 500).join(',')})`, + ); + expect(db.execAsync).toHaveBeenNthCalledWith( + 3, + `UPDATE Chapter SET isDownloaded = 0 WHERE id IN (${ids.slice(1000, 1200).join(',')})`, + ); + }); + + it('should no-op when no ids are given', async () => { + await ChapterQueries.deleteChapters('plugin', 7, []); + + expect(NativeFile.unlink).not.toHaveBeenCalled(); + expect(db.execAsync).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/src/hooks/persisted/useNovel.ts b/src/hooks/persisted/useNovel.ts index d31965700c..65c32d566f 100644 --- a/src/hooks/persisted/useNovel.ts +++ b/src/hooks/persisted/useNovel.ts @@ -9,7 +9,7 @@ import { insertNovelAndChapters, } from '@database/queries/NovelQueries'; import { - bookmarkChapter as _bookmarkChapter, + bookmarkChapters as _bookmarkChapters, markChapterRead as _markChapterRead, markChaptersRead as _markChaptersRead, markPreviuschaptersRead as _markPreviuschaptersRead, @@ -396,9 +396,7 @@ export const useNovel = (novelOrPath: string | NovelInfo, pluginId: string) => { const bookmarkChapters = useCallback( (_chapters: ChapterInfo[]) => { - _chapters.map(_chapter => { - _bookmarkChapter(_chapter.id); - }); + _bookmarkChapters(_chapters.map(_chapter => _chapter.id)); mutateChapters(chs => chs.map(chapter => { if (_chapters.some(_c => _c.id === chapter.id)) { @@ -548,7 +546,11 @@ export const useNovel = (novelOrPath: string | NovelInfo, pluginId: string) => { const deleteChapters = useCallback( (_chaters: ChapterInfo[]) => { if (novel) { - _deleteChapters(novel.pluginId, novel.id, _chaters).then(() => { + _deleteChapters( + novel.pluginId, + novel.id, + _chaters.map(_chapter => _chapter.id), + ).then(() => { showToast( getString('updatesScreen.deletedChapters', { num: _chaters.length, diff --git a/src/screens/novel/NovelScreen.tsx b/src/screens/novel/NovelScreen.tsx index 8b7425e373..fbbd85aa45 100644 --- a/src/screens/novel/NovelScreen.tsx +++ b/src/screens/novel/NovelScreen.tsx @@ -25,6 +25,8 @@ import { resolveUrl } from '@services/plugin/fetch'; import { getAllUndownloadedAndUnreadChapters, getAllUndownloadedChapters, + getChaptersByIds, + getPageChapterIds, updateChapterProgressByIds, } from '@database/queries/ChapterQueries'; import { MaterialDesignIconName } from '@type/icon'; @@ -45,6 +47,9 @@ const Novel = ({ route, navigation }: NovelScreenProps) => { getNextChapterBatch, loadUpToBatch, setNovel, + novelSettings, + pageIndex, + pages, bookmarkChapters, markChaptersRead, markChaptersUnread, @@ -62,6 +67,24 @@ const Novel = ({ route, navigation }: NovelScreenProps) => { const chapterListRef = useRef(null); + const selectionVersionRef = useRef(0); + + const selectAllChapters = useCallback(async () => { + if (!novel) { + return; + } + const requestVersion = ++selectionVersionRef.current; + const chapterIds = getPageChapterIds( + novel.id, + novelSettings.filter, + pages[pageIndex], + ); + const allChapters = getChaptersByIds(chapterIds); + if (selectionVersionRef.current === requestVersion) { + setSelected(allChapters); + } + }, [novel, novelSettings.filter, pageIndex, pages]); + const deleteDownloadsSnackbar = useBoolean(); const headerOpacity = useSharedValue(0); @@ -277,7 +300,7 @@ const Novel = ({ route, navigation }: NovelScreenProps) => { icon="select-all" iconColor={theme.onBackground} onPress={() => { - setSelected(chapters); + void selectAllChapters(); }} /> diff --git a/src/screens/novel/components/NovelScreenList.tsx b/src/screens/novel/components/NovelScreenList.tsx index 6beba95999..1b1fa28885 100644 --- a/src/screens/novel/components/NovelScreenList.tsx +++ b/src/screens/novel/components/NovelScreenList.tsx @@ -181,8 +181,13 @@ const NovelScreenList = ({ /> ); + const selectedIdSet = React.useMemo( + () => new Set(selected.map(obj => obj.id)), + [selected], + ); + const isSelected = (id: number) => { - return selected.some(obj => obj.id === id); + return selectedIdSet.has(id); }; const onSelectPress = (chapter: ChapterInfo) => { From 4221a7f9e2838f2619ef242d54b2c10bcba750a9 Mon Sep 17 00:00:00 2001 From: bizzkoot Date: Fri, 14 Aug 2026 22:19:51 +0800 Subject: [PATCH 02/54] fix(db): prevent update-clear freeze on large libraries (upstream #1955) --- .../ConfirmationDialog/ConfirmationDialog.tsx | 32 +++- src/database/queries/ChapterQueries.ts | 14 +- .../ChapterQueries.clearUpdates.test.ts | 149 ++++++++++++++++++ .../settings/SettingsAdvancedScreen.tsx | 13 +- 4 files changed, 194 insertions(+), 14 deletions(-) create mode 100644 src/database/queries/__tests__/ChapterQueries.clearUpdates.test.ts diff --git a/src/components/ConfirmationDialog/ConfirmationDialog.tsx b/src/components/ConfirmationDialog/ConfirmationDialog.tsx index 0ee55bfad3..e6b91cc547 100644 --- a/src/components/ConfirmationDialog/ConfirmationDialog.tsx +++ b/src/components/ConfirmationDialog/ConfirmationDialog.tsx @@ -1,4 +1,4 @@ -import React, { useMemo } from 'react'; +import React, { useMemo, useState } from 'react'; import { StyleSheet, View } from 'react-native'; import AppText from '@components/AppText'; @@ -15,7 +15,7 @@ interface ConfirmationDialogProps { message?: string; visible: boolean; theme: ThemeColors; - onSubmit: () => void; + onSubmit: () => void | Promise; onDismiss: () => void; } @@ -48,16 +48,23 @@ const ConfirmationDialog: React.FC = ({ [uiScale], ); - const handleOnSubmit = () => { - onSubmit(); - onDismiss(); + const [isConfirming, setIsConfirming] = useState(false); + + const handleOnSubmit = async () => { + setIsConfirming(true); + try { + await onSubmit(); + onDismiss(); + } finally { + setIsConfirming(false); + } }; return ( {} : onDismiss} style={[styles.container, { backgroundColor: theme.overlay3 }]} > {title} @@ -69,8 +76,17 @@ const ConfirmationDialog: React.FC = ({ ) : null} - diff --git a/src/database/queries/ChapterQueries.ts b/src/database/queries/ChapterQueries.ts index d96d2a687b..15f9450230 100644 --- a/src/database/queries/ChapterQueries.ts +++ b/src/database/queries/ChapterQueries.ts @@ -12,6 +12,7 @@ import { NOVEL_STORAGE } from '@utils/Storages'; import { db } from '@database/db'; import NativeFile from '@specs/NativeFile'; import { MMKVStorage } from '@utils/mmkv/mmkv'; +import { createNovelTriggerQueryUpdate } from '@database/tables/NovelTable'; const CHAPTER_ID_BATCH_SIZE = 500; const chunkChapterIds = (chapterIds: number[]) => @@ -301,8 +302,17 @@ export const markChaptersBeforePositionRead = ( position, ); -export const clearUpdates = () => - db.execAsync('UPDATE Chapter SET updatedTime = NULL'); +export const clearUpdates = async (): Promise => { + await db.withExclusiveTransactionAsync(async tx => { + // The chapter update trigger recalculates novel aggregates once per row. + // Bypass it for this database-wide operation and update the one affected + // aggregate in bulk instead. + await tx.execAsync('DROP TRIGGER IF EXISTS update_novel_stats_on_update'); + await tx.execAsync('UPDATE Chapter SET updatedTime = NULL'); + await tx.execAsync('UPDATE Novel SET lastUpdatedAt = NULL'); + await tx.execAsync(createNovelTriggerQueryUpdate); + }); +}; export const resetFutureChaptersProgress = async ( novelId: number, diff --git a/src/database/queries/__tests__/ChapterQueries.clearUpdates.test.ts b/src/database/queries/__tests__/ChapterQueries.clearUpdates.test.ts new file mode 100644 index 0000000000..15dea2f554 --- /dev/null +++ b/src/database/queries/__tests__/ChapterQueries.clearUpdates.test.ts @@ -0,0 +1,149 @@ +import Database from 'better-sqlite3'; +import { db } from '@database/db'; +import * as ChapterQueries from '../ChapterQueries'; +import { + createNovelTableQuery, + createNovelTriggerQueryUpdate, +} from '../../tables/NovelTable'; +import { createChapterTableQuery } from '../../tables/ChapterTable'; + +jest.mock('@database/db', () => ({ + db: { + runAsync: jest.fn(() => + Promise.resolve({ lastInsertRowId: 1, changes: 1 }), + ), + execAsync: jest.fn(() => Promise.resolve()), + withExclusiveTransactionAsync: jest.fn(), + getAllAsync: jest.fn(() => Promise.resolve([])), + getFirstAsync: jest.fn(() => Promise.resolve(null)), + getFirstSync: jest.fn(() => null), + getAllSync: jest.fn(() => []), + }, +})); + +jest.mock('@utils/showToast', () => ({ + showToast: jest.fn(), +})); + +jest.mock('@strings/translations', () => ({ + getString: jest.fn(key => key), +})); + +jest.mock('@utils/Storages', () => ({ + NOVEL_STORAGE: 'file://novels', +})); + +jest.mock('@specs/NativeFile', () => ({ + unlink: jest.fn(), +})); + +describe('ChapterQueries clearUpdates', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('runs inside a single exclusive transaction', async () => { + const txExecAsync = jest.fn((_sql: string) => Promise.resolve()); + (db.withExclusiveTransactionAsync as jest.Mock).mockImplementation( + callback => + callback({ + runAsync: jest.fn(() => + Promise.resolve({ lastInsertRowId: 1, changes: 1 }), + ), + execAsync: txExecAsync, + }), + ); + + await ChapterQueries.clearUpdates(); + + expect(db.withExclusiveTransactionAsync).toHaveBeenCalledTimes(1); + expect(txExecAsync.mock.calls.map(call => call[0])).toEqual([ + 'DROP TRIGGER IF EXISTS update_novel_stats_on_update', + 'UPDATE Chapter SET updatedTime = NULL', + 'UPDATE Novel SET lastUpdatedAt = NULL', + createNovelTriggerQueryUpdate, + ]); + }); + + it('recreates the shared trigger constant (single source of truth)', async () => { + const txExecAsync = jest.fn((_sql: string) => Promise.resolve()); + (db.withExclusiveTransactionAsync as jest.Mock).mockImplementation( + callback => + callback({ + runAsync: jest.fn(() => + Promise.resolve({ lastInsertRowId: 1, changes: 1 }), + ), + execAsync: txExecAsync, + }), + ); + + await ChapterQueries.clearUpdates(); + + // The final statement must be the exact exported trigger DDL used by + // bootstrap (db.ts) and migration 004 — not a fork of it. + expect(txExecAsync).toHaveBeenLastCalledWith(createNovelTriggerQueryUpdate); + expect(createNovelTriggerQueryUpdate).toContain( + 'CREATE TRIGGER IF NOT EXISTS update_novel_stats_on_update', + ); + }); + + it('clears updatedTime/lastUpdatedAt and leaves the trigger functional (real SQLite)', async () => { + const sqlite = new Database(':memory:'); + sqlite.exec(createNovelTableQuery); + sqlite.exec(createChapterTableQuery); + sqlite.exec(createNovelTriggerQueryUpdate); + sqlite.exec( + "INSERT INTO Novel (path, pluginId, name, inLibrary) VALUES ('p', 'pl', 'n', 1)", + ); + sqlite.exec( + "INSERT INTO Chapter (path, name, novelId, position, updatedTime) VALUES ('c1','C1',1,0,'2024-01-01')", + ); + sqlite.exec( + "INSERT INTO Chapter (path, name, novelId, position, updatedTime) VALUES ('c2','C2',1,1,'2024-01-02')", + ); + + const txExecAsync = (sql: string) => { + sqlite.exec(sql); + return Promise.resolve(); + }; + (db.withExclusiveTransactionAsync as jest.Mock).mockImplementation( + callback => + callback({ + runAsync: jest.fn(() => + Promise.resolve({ lastInsertRowId: 1, changes: 1 }), + ), + execAsync: txExecAsync, + }), + ); + + await ChapterQueries.clearUpdates(); + + // All chapter update timestamps are cleared. + const chapterRows = sqlite + .prepare('SELECT updatedTime FROM Chapter ORDER BY id') + .all() as Array<{ updatedTime: string | null }>; + expect(chapterRows.every(row => row.updatedTime === null)).toBe(true); + + // The bulk lastUpdatedAt reset ran for the novel. + const novel = sqlite + .prepare('SELECT lastUpdatedAt FROM Novel WHERE id = 1') + .get() as { lastUpdatedAt: string | null }; + expect(novel.lastUpdatedAt).toBeNull(); + + // The trigger survives and still reacts to chapter updates. + const triggers = sqlite + .prepare( + "SELECT name FROM sqlite_master WHERE type = 'trigger' AND name = 'update_novel_stats_on_update'", + ) + .all(); + expect(triggers).toHaveLength(1); + + sqlite.exec("UPDATE Chapter SET updatedTime = '2024-02-01' WHERE id = 1"); + const after = sqlite + .prepare('SELECT lastUpdatedAt FROM Novel WHERE id = 1') + .get() as { lastUpdatedAt: string | null }; + expect(after.lastUpdatedAt).toBe('2024-02-01'); + + sqlite.close(); + }); +}); diff --git a/src/screens/settings/SettingsAdvancedScreen.tsx b/src/screens/settings/SettingsAdvancedScreen.tsx index 3dcdb39a26..8ebb0e6adf 100644 --- a/src/screens/settings/SettingsAdvancedScreen.tsx +++ b/src/screens/settings/SettingsAdvancedScreen.tsx @@ -279,10 +279,15 @@ const AdvancedSettings = ({ navigation }: AdvancedSettingsScreenProps) => { { - clearUpdates(); - showToast(getString('advancedSettingsScreen.clearUpdatesMessage')); - hideClearUpdatesDialog(); + onSubmit={async () => { + try { + await clearUpdates(); + showToast( + getString('advancedSettingsScreen.clearUpdatesMessage'), + ); + } catch (error) { + showToast(error instanceof Error ? error.message : String(error)); + } }} onDismiss={hideClearUpdatesDialog} theme={theme} From 487d58fafc898de55cb70afd5b463058e49e7adb Mon Sep 17 00:00:00 2001 From: bizzkoot Date: Fri, 14 Aug 2026 22:29:11 +0800 Subject: [PATCH 03/54] fix(library): prevent stuck loading state when fetch errors (upstream 1eb8c587c) --- src/screens/library/LibraryScreen.tsx | 59 +++++--- .../hooks/__tests__/useLibrary.test.ts | 131 ++++++++++++++++++ src/screens/library/hooks/useLibrary.ts | 53 +++++-- 3 files changed, 215 insertions(+), 28 deletions(-) create mode 100644 src/screens/library/hooks/__tests__/useLibrary.test.ts diff --git a/src/screens/library/LibraryScreen.tsx b/src/screens/library/LibraryScreen.tsx index a053a8a4e7..fa5b8b9ff9 100644 --- a/src/screens/library/LibraryScreen.tsx +++ b/src/screens/library/LibraryScreen.tsx @@ -24,6 +24,8 @@ import Color from 'color'; import { SearchbarV2, Button, + EmptyView, + ErrorScreenV2, SafeAreaView, TopTabBar, } from '@components/index'; @@ -90,6 +92,7 @@ const LibraryScreen = ({ navigation }: LibraryScreenProps) => { categories, refetchLibrary, isLoading, + error: libraryError, settings: { showNumberOfNovels, downloadedOnlyMode, incognitoMode }, } = useLibraryContext(); @@ -237,9 +240,7 @@ const LibraryScreen = ({ navigation }: LibraryScreenProps) => { (n.author?.toLowerCase().includes(searchText.toLowerCase()) ?? false), ); - return isLoading ? ( - - ) : ( + return ( <> {searchText ? (