From 8a12529ba80edf14433a738f4cb1855e2cc1fabf Mon Sep 17 00:00:00 2001 From: Rajarshee Chatterjee Date: Tue, 4 Aug 2026 19:52:11 +0530 Subject: [PATCH] fix: Prevent Update Clearing From Freezing App (Closes #1955) --- .../ConfirmationDialog/ConfirmationDialog.tsx | 31 ++++++++++---- src/components/__tests__/Dialog.test.tsx | 40 +++++++++++++++++-- src/database/queries/ChapterQueries.ts | 9 +++++ .../queries/__tests__/ChapterQueries.test.ts | 25 +++++++++++- .../settings/SettingsAdvancedScreen.tsx | 14 ++++--- 5 files changed, 102 insertions(+), 17 deletions(-) diff --git a/src/components/ConfirmationDialog/ConfirmationDialog.tsx b/src/components/ConfirmationDialog/ConfirmationDialog.tsx index c09515aa..2e7f86af 100644 --- a/src/components/ConfirmationDialog/ConfirmationDialog.tsx +++ b/src/components/ConfirmationDialog/ConfirmationDialog.tsx @@ -1,4 +1,4 @@ -import React from 'react'; +import React, { useState } from 'react'; import { getString } from '@i18n/translations'; @@ -25,20 +25,37 @@ const ConfirmationDialog: React.FC = ({ onDismiss, onConfirm, }) => { - const handleConfirm = () => { - void onConfirm(); - onDismiss(); + const [isConfirming, setIsConfirming] = useState(false); + + const handleConfirm = async () => { + setIsConfirming(true); + try { + await onConfirm(); + onDismiss(); + } finally { + setIsConfirming(false); + } }; return ( - + {} : onDismiss} + > {title} {message ? {message} : null} - {cancelLabel} - + + {cancelLabel} + + {confirmLabel} diff --git a/src/components/__tests__/Dialog.test.tsx b/src/components/__tests__/Dialog.test.tsx index 845b0427..d2427e5c 100644 --- a/src/components/__tests__/Dialog.test.tsx +++ b/src/components/__tests__/Dialog.test.tsx @@ -1,5 +1,10 @@ import './mocks'; -import { fireEvent, render, screen } from '@testing-library/react-native'; +import { + fireEvent, + render, + screen, + waitFor, +} from '@testing-library/react-native'; import { Dialog } from '../Dialog'; import ConfirmationDialog from '../ConfirmationDialog/ConfirmationDialog'; @@ -104,7 +109,7 @@ describe('Dialog', () => { expect(onDismiss).toHaveBeenCalledTimes(1); }); - it('runs and dismisses a confirmation action', () => { + it('runs and dismisses a confirmation action', async () => { const onConfirm = jest.fn(); const onDismiss = jest.fn(); @@ -121,6 +126,35 @@ describe('Dialog', () => { fireEvent.press(screen.getByText('Delete')); expect(onConfirm).toHaveBeenCalledTimes(1); - expect(onDismiss).toHaveBeenCalledTimes(1); + await waitFor(() => expect(onDismiss).toHaveBeenCalledTimes(1)); + }); + + it('waits for an async confirmation action before dismissing', async () => { + let resolveConfirmation: () => void = () => {}; + const onConfirm = jest.fn( + () => + new Promise(resolve => { + resolveConfirmation = resolve; + }), + ); + const onDismiss = jest.fn(); + + render( + , + ); + + fireEvent.press(screen.getByText('Delete')); + + expect(onConfirm).toHaveBeenCalledTimes(1); + expect(onDismiss).not.toHaveBeenCalled(); + + resolveConfirmation(); + await waitFor(() => expect(onDismiss).toHaveBeenCalledTimes(1)); }); }); diff --git a/src/database/queries/ChapterQueries.ts b/src/database/queries/ChapterQueries.ts index e83fcff9..e69927e5 100644 --- a/src/database/queries/ChapterQueries.ts +++ b/src/database/queries/ChapterQueries.ts @@ -28,6 +28,7 @@ import NativeFile from '@modules/native-file'; import { ChapterFilterKey, ChapterOrderKey } from '@database/constants'; import { chapterFilterToSQL, chapterOrderToSQL } from '@database/utils/parser'; import { castInt } from '@database/manager/manager'; +import { createNovelTriggerQueryUpdate } from '@database/queryStrings/triggers'; // #region Mutations @@ -366,7 +367,15 @@ export const markPreviousChaptersUnread = async ( export const clearUpdates = async (): Promise => { await dbManager.write(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.run( + sql.raw('DROP TRIGGER IF EXISTS update_novel_stats_on_update'), + ); await tx.update(chapterSchema).set({ updatedTime: null }).run(); + await tx.update(novelSchema).set({ lastUpdatedAt: null }).run(); + await tx.run(sql.raw(createNovelTriggerQueryUpdate)); }); }; diff --git a/src/database/queries/__tests__/ChapterQueries.test.ts b/src/database/queries/__tests__/ChapterQueries.test.ts index 2a04fd80..2e030d96 100644 --- a/src/database/queries/__tests__/ChapterQueries.test.ts +++ b/src/database/queries/__tests__/ChapterQueries.test.ts @@ -7,6 +7,8 @@ import './mockDb'; import { setupTestDatabase, getTestDb, teardownTestDatabase } from './setup'; import { insertTestNovel, insertTestChapter, clearAllTables } from './testData'; +import { chapterSchema } from '@database/schema'; +import { eq } from 'drizzle-orm'; import { markChapterRead, @@ -661,10 +663,10 @@ describe('ChapterQueries', () => { }); describe('clearUpdates', () => { - it('should clear all update timestamps', async () => { + it('should clear update timestamps and preserve the update trigger', async () => { const testDb = getTestDb(); const novelId = await insertTestNovel(testDb, { inLibrary: true }); - await insertTestChapter(testDb, novelId, { + const chapterId = await insertTestChapter(testDb, novelId, { updatedTime: '2024-01-01', }); await insertTestChapter(testDb, novelId, { @@ -675,6 +677,25 @@ describe('ChapterQueries', () => { const chapters = await getNovelChapters(novelId); expect(chapters.every(c => c.updatedTime === null)).toBe(true); + expect( + testDb.sqlite.executeSync( + 'SELECT lastUpdatedAt FROM Novel WHERE id = ?', + [novelId], + ).rows[0]?.lastUpdatedAt, + ).toBeNull(); + + await testDb.drizzleDb + .update(chapterSchema) + .set({ updatedTime: '2024-02-01' }) + .where(eq(chapterSchema.id, chapterId)) + .run(); + + expect( + testDb.sqlite.executeSync( + 'SELECT lastUpdatedAt FROM Novel WHERE id = ?', + [novelId], + ).rows[0]?.lastUpdatedAt, + ).toBe('2024-02-01'); }); }); diff --git a/src/screens/settings/SettingsAdvancedScreen.tsx b/src/screens/settings/SettingsAdvancedScreen.tsx index e013ff26..c62a527c 100644 --- a/src/screens/settings/SettingsAdvancedScreen.tsx +++ b/src/screens/settings/SettingsAdvancedScreen.tsx @@ -45,6 +45,14 @@ const AdvancedSettings = ({ navigation }: AdvancedSettingsScreenProps) => { const [clearUpdatesDialog, setClearUpdatesDialog] = useState(false); const showClearUpdatesDialog = () => setClearUpdatesDialog(true); const hideClearUpdatesDialog = () => setClearUpdatesDialog(false); + const handleClearUpdates = async () => { + try { + await clearUpdates(); + showToast(getString('advancedSettingsScreen.clearUpdatesMessage')); + } catch (error) { + showToast(error instanceof Error ? error.message : String(error)); + } + }; const { value: deleteReadChaptersDialog, @@ -150,11 +158,7 @@ const AdvancedSettings = ({ navigation }: AdvancedSettingsScreenProps) => { confirmLabel={getString('common.clear')} message={getString('advancedSettingsScreen.clearUpdatesWarning')} visible={clearUpdatesDialog} - onConfirm={() => { - clearUpdates(); - showToast(getString('advancedSettingsScreen.clearUpdatesMessage')); - hideClearUpdatesDialog(); - }} + onConfirm={handleClearUpdates} onDismiss={hideClearUpdatesDialog} />