Skip to content

Use novel refactor#27

Open
CD-Z wants to merge 26 commits intomasterfrom
useNovel-Refactor
Open

Use novel refactor#27
CD-Z wants to merge 26 commits intomasterfrom
useNovel-Refactor

Conversation

@CD-Z
Copy link
Copy Markdown
Owner

@CD-Z CD-Z commented Mar 25, 2026

Summary by CodeRabbit

  • New Features

    • Adds developer plugins for local testing and mock novels.
    • Introduces in-memory chapter text cache with read/write/remove/clear APIs.
  • Improvements

    • Refactors novel state to a persisted, centralized store for more reliable syncing.
    • UI components now use selector-based hooks for more consistent updates.
    • Enhanced pagination/loading flows and chapter operations (bookmark, read, progress, delete).
  • Chores

    • Added Zustand dependency.
    • Expanded test coverage across novel, chapter, and persistence logic.

@coderabbitai
Copy link
Copy Markdown

coderabbitai bot commented Mar 25, 2026

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

Replaces the prior useNovel hook with a Zustand-backed novel store and selector-based APIs; introduces bootstrap/persistence helpers (MMKV bridge, key contracts), dev plugins, many sync DB query variants, extensive store and chapter action implementations, and large test-suite additions across hooks, store, and UI components.

Changes

Cohort / File(s) Summary
Package
package.json
Added zustand @ ^5.0.12.
Novel Store Core
src/hooks/persisted/useNovel/store/..., src/hooks/persisted/useNovel/types.ts, src/hooks/persisted/useNovel.ts, src/hooks/persisted/index.ts
New Zustand-based store factory, types, chapter slice, actions, chapter-actions, bootstrap service, persistence contracts; useNovel stubbed to throw and re-exports moved to store-helper contracts; deleteCachedNovels exported from persisted index.
Persistence Bridge & Adapter
src/hooks/persisted/useNovel/store-helper/persistence.ts, .../contracts.ts, .../keyContract.ts, src/utils/mmkv/zustand-adapter.ts
MMKV-backed novelPersistence bridge, key contract functions, re-export contracts, and a Zustand MMKV adapter for persist storage.
Chapter Operations & Cache
src/hooks/persisted/useNovel/store/novelStore.chapterActions.ts, src/hooks/persisted/useNovel/store/chapterActions.ts, src/hooks/persisted/useNovel/useChapterOperations.ts
New chapter ops wiring: cache API (read/write/remove/clear), batch loading (getNextChapterBatch/loadUpToBatch) with inflight dedupe, and high-level action delegations.
Store creation & wiring
src/hooks/persisted/useNovel/store/createStore.ts, src/hooks/persisted/useNovel/store/novelStore.actions.ts, .../novelStore.ts, .../novelStore.types.ts
createStore factory tying bootstrap, persistence, chapter transforms, and actions; initial immediate bootstrap call; novel-level actions (setPageIndex, setNovelSettings, followNovel, etc.).
Mocks & Tests
src/hooks/__tests__/mocks.ts, src/hooks/__tests__/*, src/hooks/persisted/useNovel/__tests__/*, src/screens/**/__tests__/*, src/database/queries/__tests__/*
Expanded Jest mock helpers for novel store and context; many new/updated tests covering mock contracts, store behavior, bootstrap, chapter actions, persistence, components, and hooks.
UI Context & Consumers
src/screens/novel/NovelContext.tsx, src/screens/novel/*, src/screens/reader/*
Replaced prior context with NovelStoreContext + NovelLayoutContext; added selector hooks (useNovelStore, useNovelValue, useNovelActions, useNovelAction, useNovelLayout) and updated components to use selectors and actions.
Dev Plugins & Plugin Manager
src/plugins/dev/*, src/plugins/pluginManager.ts, src/hooks/persisted/usePlugins.ts
Added dev novel and paged-novel plugins, integrated them into plugin registry in DEV mode, and adjusted install/uninstall filtering and special-case uninstall.
Database & Query Changes
src/database/manager/manager.ts, src/database/queries/*, src/database/utils/*
Converted several DB helpers to synchronous variants (allSync/getSync) and added callbacks to live queries; expanded chapter query filters/sort/page params; parser and filter utilities adjusted.
Services & Misc
src/services/*, src/screens/settings/SettingsAdvancedScreen.tsx, src/screens/browse/components/InstalledTab.tsx, src/screens/novel/components/*
Migration now uses novelPersistence; fetchPage refined to prefer parsePage/parseNovel behaviors; minor UI logic updates (release time parsing, follow error handling, memo cleanup).

Sequence Diagram(s)

sequenceDiagram
    participant UI as UI Component
    participant Store as Zustand Store
    participant Bootstrap as Bootstrap Service
    participant DB as Database
    participant Network as Plugin/Network

    UI->>Store: useNovelValue / useNovelActions
    Store->>Store: check bootstrapped
    alt not bootstrapped
        Store->>Bootstrap: bootstrapNovelAsync(pluginId, novelPath)
        Bootstrap->>DB: getNovelByPath / getChapterCount
        alt chapters present
            DB-->>Bootstrap: novel + chapters
        else chapters missing
            Bootstrap->>Network: fetchNovel / fetchPage
            Network-->>Bootstrap: SourceNovel / SourcePage
            Bootstrap->>DB: insertNovelAndChapters / insertChapters
        end
        Bootstrap-->>Store: set novel/pages/chapters/batchInfo
    end
    Store-->>UI: deliver state snapshot
    UI->>Store: actions (setPageIndex, getNextChapterBatch)
    Store->>Bootstrap: getNextChapterBatch(...)
    Bootstrap->>DB: getPageChaptersBatched
    DB-->>Bootstrap: batch chapters
    Bootstrap-->>Store: append chapters, update batchInformation
Loading
sequenceDiagram
    participant Reader as Reader Component
    participant Cache as ChapterTextCache (store)
    participant Fetch as Fetch Service
    participant Network as Plugin/Network

    Reader->>Cache: read(chapterId)
    alt cache hit
        Cache-->>Reader: text
    else cache miss
        Reader->>Fetch: fetchChapter(pluginId, path, chapter)
        Fetch->>Network: request chapter HTML
        Network-->>Fetch: HTML
        Fetch-->>Reader: sanitized text
        Reader->>Cache: write(chapterId, text)
    end
    Reader->>Reader: render text
Loading

Estimated code review effort

🎯 5 (Critical) | ⏱️ ~120 minutes

Poem

🐰 I stitched a store from code and thread,

Chapters cached and bootstraps fed,
Dev plugins sing and tests take flight,
Zustand hums through day and night,
Hoppers cheer — the reader's set!

🚥 Pre-merge checks | ✅ 1 | ❌ 2

❌ Failed checks (1 warning, 1 inconclusive)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
Title check ❓ Inconclusive The title 'Use novel refactor' is vague and doesn't clearly convey the scope or nature of the substantial refactoring work involved in this pull request. Revise to a more descriptive title that captures the main refactoring objective, such as 'Refactor useNovel hook to use Zustand store' or 'Migrate novel state management to Zustand with store-based architecture'.
✅ Passed checks (1 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch useNovel-Refactor

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 19

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
src/screens/reader/components/ChapterDrawer/index.tsx (1)

190-191: ⚠️ Potential issue | 🔴 Critical

Bug: Comparing ref object instead of ref.current.

scrollToIndex is a useRef object, which is never undefined. The condition should check scrollToIndex.current === undefined instead.

🐛 Proposed fix
-      {scrollToIndex === undefined ? (
+      {scrollToIndex.current === undefined ? (
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/screens/reader/components/ChapterDrawer/index.tsx` around lines 190 -
191, The conditional is comparing the ref object instead of its value: change
the check to test scrollToIndex.current === undefined (not scrollToIndex) so the
LoadingScreenV2 branch only renders when the ref has no current value; update
any other places in this JSX/logic that read scrollToIndex to use
scrollToIndex.current (refer to the scrollToIndex ref and LoadingScreenV2 usage
in this component).
🧹 Nitpick comments (14)
src/hooks/persisted/useNovel/useChapterOperations.ts (2)

59-71: Remove unnecessary async keyword from synchronous functions.

transformAndSetChapters and extendChapters don't contain any await expressions. The async keyword causes them to return Promise<void> instead of void, which may confuse callers.

♻️ Remove async
   const transformAndSetChapters = useCallback(
-    async (chs: ChapterInfo[]) => {
+    (chs: ChapterInfo[]) => {
       _setChapters(transformChapters(chs));
     },
     [transformChapters, _setChapters],
   );

   const extendChapters = useCallback(
-    async (chs: ChapterInfo[]) => {
+    (chs: ChapterInfo[]) => {
       _setChapters(prev => prev.concat(transformChapters(chs)));
     },
     [transformChapters, _setChapters],
   );
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/hooks/persisted/useNovel/useChapterOperations.ts` around lines 59 - 71,
Both transformAndSetChapters and extendChapters are declared async but contain
no await and thus return Promise<void>; remove the unnecessary async keyword
from the function declarations for transformAndSetChapters and extendChapters so
they return void; keep their bodies unchanged and retain the same dependencies
(transformChapters and _setChapters) in the useCallback calls.

17-26: chapters parameter is declared but unused.

The chapters field in UseChapterOperationsParams is never referenced in the hook body. Either remove it from the interface or use it if needed.

♻️ Remove unused parameter
 export interface UseChapterOperationsParams {
   novel: NovelInfo | undefined;
-  chapters: ChapterInfo[];
   _setChapters: React.Dispatch<React.SetStateAction<ChapterInfo[]>>;
   transformChapters: (chs: ChapterInfo[]) => ChapterInfo[];
   settingsSort: ChapterOrderKey;
   settingsFilter: ChapterFilterKey[];
   currentPage: string;
   fetching: boolean;
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/hooks/persisted/useNovel/useChapterOperations.ts` around lines 17 - 26,
The interface UseChapterOperationsParams declares a chapters field that is never
used in the hook; remove the unused property from UseChapterOperationsParams
(delete the chapters: ChapterInfo[] entry) and update any call sites that
construct objects of this type (calls to useChapterOperations or factories
creating UseChapterOperationsParams) to stop providing chapters, or
alternatively, if the data is actually needed, wire the existing chapters usage
into the hook logic (e.g., inside useChapterOperations reference the passed
chapters instead of any other source). Ensure you only change the interface and
its callers or integrate the chapters parameter in the useChapterOperations
function to keep types consistent.
src/hooks/persisted/useNovel/types.ts (1)

7-18: Consider declaring the interface before its default value.

defaultNovelSettings references NovelSettings before the interface is declared. While TypeScript hoists interface declarations, defining the interface first improves readability.

♻️ Suggested reordering
 export const LAST_READ_PREFIX = 'LAST_READ_PREFIX';

+export interface NovelSettings {
+  sort?: ChapterOrderKey;
+  filter: ChapterFilterKey[];
+  showChapterTitles?: boolean;
+}
+
 export const defaultNovelSettings: NovelSettings = {
   showChapterTitles: true,
   filter: [],
 };

 export const defaultPageIndex = 0;
-
-export interface NovelSettings {
-  sort?: ChapterOrderKey;
-  filter: ChapterFilterKey[];
-  showChapterTitles?: boolean;
-}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/hooks/persisted/useNovel/types.ts` around lines 7 - 18, Move the
NovelSettings interface declaration above the default values so the type is
declared before it’s referenced: place the NovelSettings interface first,
followed by defaultNovelSettings and defaultPageIndex; update any imports/usages
if necessary to reflect the new order, ensuring the symbols NovelSettings and
defaultNovelSettings remain unchanged.
src/utils/mmkv/zustand-adapter.ts (1)

19-25: Replace console.error with a project logger or suppress the lint rule.

ESLint flags console.error statements. If logging storage errors is intentional for debugging, either use a project-sanctioned logger utility or add an inline eslint-disable comment with justification.

♻️ Option: Inline suppression if console is intentional
     } catch (error) {
+      // eslint-disable-next-line no-console -- Storage adapter error logging for debugging
       console.error(
         `[mmkvZustandAdapter] Error getting item for key "${key}":`,
         error,
       );

Apply the same pattern to lines 36 and 50.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/utils/mmkv/zustand-adapter.ts` around lines 19 - 25, Replace the
console.error calls inside the mmkv-zustand adapter error handlers with the
project logging utility or explicitly suppress the lint rule with a justified
inline comment: locate the catch blocks in the mmkv adapter functions (e.g.,
getItem, setItem, removeItem in zustand-adapter.ts) and either swap
console.error(...) for the shared logger (processLogger or logger) with the same
message and error object, or add an eslint-disable-next-line no-console comment
plus a brief justification above each console.error (the occurrences at the
catch blocks currently around the key-handling logic and the ones at lines
corresponding to getItem, setItem, and removeItem).
src/plugins/pluginManager.ts (1)

172-174: Consider extracting dev plugin IDs as constants.

The dev plugin IDs 'dev-novel' and 'dev-paged-novel' are duplicated here and in the registry initialization (lines 90-91). Extracting them as constants would reduce duplication and prevent typos.

♻️ Suggested refactor
+const DEV_NOVEL_ID = 'dev-novel';
+const DEV_PAGED_NOVEL_ID = 'dev-paged-novel';
+
 const plugins: Record<string, Plugin | undefined> = __DEV__
   ? {
-      'dev-novel': devNovelPlugin,
-      'dev-paged-novel': devPagedNovelPlugin,
+      [DEV_NOVEL_ID]: devNovelPlugin,
+      [DEV_PAGED_NOVEL_ID]: devPagedNovelPlugin,
     }
   : {};

Then update the check:

-  if (__DEV__ && (pluginId === 'dev-novel' || pluginId === 'dev-paged-novel')) {
+  if (__DEV__ && (pluginId === DEV_NOVEL_ID || pluginId === DEV_PAGED_NOVEL_ID)) {
     return plugins[pluginId];
   }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/plugins/pluginManager.ts` around lines 172 - 174, Extract the duplicate
dev plugin ID strings into shared constants (e.g., DEV_PLUGIN_NOVEL and
DEV_PLUGIN_PAGED_NOVEL) and replace the inline literals in both the registry
initialization and the conditional that checks pluginId in pluginManager.ts;
specifically update the check that currently uses (pluginId === 'dev-novel' ||
pluginId === 'dev-paged-novel') to reference the new constants and ensure the
registry initialization also uses the same constants to avoid duplication and
typos.
src/hooks/persisted/usePlugins.ts (1)

53-76: Consider extracting dev plugin PluginItem mapping to reduce duplication.

The same property mapping pattern is repeated for both dev plugins. A helper function would reduce duplication.

♻️ Suggested refactor
const toPluginItem = (plugin: typeof devNovelPlugin): PluginItem => ({
  id: plugin.id,
  name: plugin.name,
  site: plugin.site,
  lang: plugin.lang,
  version: plugin.version,
  url: plugin.url,
  iconUrl: plugin.iconUrl,
  hasSettings: !!plugin.pluginSettings,
});

const devPlugins: PluginItem[] = __DEV__
  ? [toPluginItem(devNovelPlugin), toPluginItem(devPagedNovelPlugin)]
  : [];
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/hooks/persisted/usePlugins.ts` around lines 53 - 76, Extract the repeated
mapping of devNovelPlugin and devPagedNovelPlugin into a small helper (e.g.,
toPluginItem) that converts a plugin (devNovelPlugin/devPagedNovelPlugin) into a
PluginItem by copying id, name, site, lang, version, url, iconUrl and computing
hasSettings as !!plugin.pluginSettings; then replace the inline objects in the
devPlugins array with calls to that helper (devPlugins, devNovelPlugin,
devPagedNovelPlugin, PluginItem, toPluginItem).
src/screens/reader/components/ChapterDrawer/index.tsx (1)

43-58: Consider extracting useNovelDomainValue to a shared utility.

This helper function is duplicated across multiple files (NovelScreenList.tsx, NovelScreen.tsx, useChapter.ts). Extract it to a shared location (e.g., @hooks/persisted/useNovel/useNovelDomainValue.ts) to reduce duplication and ensure consistency.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/screens/reader/components/ChapterDrawer/index.tsx` around lines 43 - 58,
Extract the duplicated hook useNovelDomainValue into a shared utility and update
callers to import it instead of redefining it; create a new file (e.g.,
`@hooks/persisted/useNovel/useNovelDomainValue.ts`) that exports
useNovelDomainValue with the same signature and implementation (it should
reference useNovelContext's novelStore, useCallback for subscribe/getSnapshot,
and call novelStore.subscribe and novelStore.getState with the selector), then
replace the local definitions in ChapterDrawer (index.tsx), NovelScreenList.tsx,
NovelScreen.tsx, and useChapter.ts with an import from the new module to remove
duplication and keep behavior identical.
src/screens/reader/components/ChapterDrawer/__tests__/ChapterDrawer.test.tsx (1)

135-162: Consider adding more test cases for edge conditions.

The current test covers the happy path but doesn't test:

  • When fetching is true (should not call getNextChapterBatch)
  • When batch >= total (should not call getNextChapterBatch)
  • When pages array is empty or chapter page is not found
💡 Additional test cases to consider
it('does not call getNextChapterBatch when fetching is true', async () => {
  const store = createStore({ fetching: true });
  // ... setup and render
  fireEvent.press(screen.getByTestId('legend-end-reached'));
  expect(store.state.getNextChapterBatch).not.toHaveBeenCalled();
});

it('does not call getNextChapterBatch when batch >= total', async () => {
  const store = createStore({ 
    batchInformation: { batch: 1, total: 1, totalChapters: 2 } 
  });
  // ... setup and render
  fireEvent.press(screen.getByTestId('legend-end-reached'));
  expect(store.state.getNextChapterBatch).not.toHaveBeenCalled();
});
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/screens/reader/components/ChapterDrawer/__tests__/ChapterDrawer.test.tsx`
around lines 135 - 162, Add unit tests in ChapterDrawer.test.tsx that cover the
edge conditions the reviewer suggested: create variants of createStore with
fetching: true, with batchInformation where batch >= total, and with pages empty
or missing the current chapter page; for each test use mockUseChapterContext and
mockUseNovelContext as in the existing test, render <ChapterDrawer />, trigger
the end-reached event via
fireEvent.press(screen.getByTestId('legend-end-reached')), and assert that
store.state.getNextChapterBatch is NOT called; also keep the existing
expectation that setPageIndex is invoked where appropriate to ensure
selector-backed behavior remains tested.
src/screens/novel/components/NovelScreenList.tsx (1)

108-130: Consider consolidating multiple selector subscriptions.

Each useNovelDomainValue call creates a separate subscription. With 16+ subscriptions in this component, consider using a single combined selector that returns an object with all needed values, then destructure it. This reduces subscription overhead and re-render frequency.

♻️ Example consolidated approach
const selectNovelScreenListState = (state: NovelStoreState) => ({
  chapters: state.chapters,
  deleteChapter: state.deleteChapter,
  fetching: state.fetching,
  // ... other needed values
});

// In component:
const {
  chapters,
  deleteChapter,
  fetching,
  // ...
} = useNovelDomainValue(novelStore, selectNovelScreenListState);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/screens/novel/components/NovelScreenList.tsx` around lines 108 - 130,
Multiple calls to useNovelDomainValue (e.g., useNovelDomainValue(novelStore,
selectChapters), selectDeleteChapter, selectFetching, selectFirstUnreadChapter,
selectLoading, selectNovelSettings, selectPages, selectSetNovel, selectNovel,
selectBatchInformation, selectPageIndex, selectOpenPage, selectUpdateChapter,
selectRefreshNovel, selectLastRead) create many subscriptions; replace them with
a single consolidated selector (e.g., selectNovelScreenListState) that returns
an object with all required fields, call useNovelDomainValue once with that
selector, and then destructure the returned object into chapters, deleteChapter,
fetching, firstUnreadChapter, loading, novelSettings, pages, setNovel,
fetchedNovel, batchInformation, pageIndex, openPage, updateChapter,
refreshNovel, lastRead to reduce subscription overhead and re-renders.
src/screens/novel/__tests__/NovelScreen.test.tsx (1)

43-48: Assert the store boundary here, not the DB helper.

This test is supposed to protect the novelStore cutover, but the progress-reset expectation still hangs off @database/queries/ChapterQueries.updateChapterProgressByIds. That couples the suite to an implementation detail instead of the screen’s public dependency.

Possible refactor
 const state = {
   novel: baseNovel,
   chapters: [],
   fetching: false,
   batchInformation: { batch: 0, total: 0, totalChapters: 0 },
   getNextChapterBatch: jest.fn(),
   loadUpToBatch: jest.fn(),
   setNovel: jest.fn(),
   bookmarkChapters: jest.fn(),
   markChaptersRead: jest.fn(),
   markChaptersUnread: jest.fn(),
+  updateChapterProgressByIds: jest.fn(),
   markPreviouschaptersRead: jest.fn(),
   markPreviousChaptersUnread: jest.fn(),
   refreshChapters: jest.fn(),
   deleteChapters: jest.fn(),
   ...overrides,
@@
     fireEvent.press(screen.getByTestId('select-read'));
     fireEvent.press(screen.getByTestId('action-check-outline'));
 
     expect(store.state.markChaptersUnread).toHaveBeenCalledTimes(1);
-    expect(mockUpdateChapterProgressByIds).toHaveBeenCalledWith([11], 0);
+    expect(store.state.updateChapterProgressByIds).toHaveBeenCalledWith(
+      [11],
+      0,
+    );
     expect(store.state.refreshChapters).toHaveBeenCalledTimes(1);
   });

Also applies to: 223-239, 290-305

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/screens/novel/__tests__/NovelScreen.test.tsx` around lines 43 - 48, The
test currently asserts behavior on the DB helper updateChapterProgressByIds
which ties the spec to an implementation detail; instead spy or mock the public
store API (novelStore) used by the screen and assert the store boundary was
invoked. Replace expectations that reference
`@database/queries/ChapterQueries.updateChapterProgressByIds` with assertions
against the novelStore method the screen calls (e.g., the store method that
resets/updates chapter progress), and update the mocks in the three affected
blocks (around lines 43-48, 223-239, 290-305) to stop verifying the DB helper
and verify novelStore was called with the expected ids/args; keep the existing
jest.mock of getAllUndownloadedAndUnreadChapters/getAllUndownloadedChapters but
move the progress-reset assertion to the store-level spy.
src/hooks/persisted/useNovel/novelStore.ts (2)

331-335: Address ESLint warning: prefer explicit async handling over void.

ESLint flags the void state.getChapters() expression. Consider using .catch() to explicitly handle potential errors, or refactor to make the intent clearer.

♻️ Suggested fix
       const state = get();
       if (state.novel && state.pages.length > 0) {
-        void state.getChapters();
+        state.getChapters().catch(() => {
+          // Fire-and-forget: errors handled internally
+        });
       }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/hooks/persisted/useNovel/novelStore.ts` around lines 331 - 335, The
expression using void to ignore the Promise from state.getChapters() should be
replaced with explicit async/error handling: either call
state.getChapters().catch(err => { /* log or handle error */ }) to handle
rejection, or make the enclosing callback async and await state.getChapters()
inside a try/catch; update the code around get() in novelStore.ts (the section
that reads const state = get(); if (state.novel && state.pages.length > 0) { ...
}) to use one of these patterns and ensure any errors are logged or handled
instead of being swallowed.

340-362: Address ESLint warning and add error handling for followNovel.

The void deps.switchNovelToLibrary(...) pattern triggers the ESLint warning. Additionally, if switchNovelToLibrary fails, the UI state will remain unchanged, but users won't receive feedback. Consider adding error handling.

♻️ Suggested improvement
      followNovel: () => {
        const state = get();
        const currentNovel = state.novel;
        if (!currentNovel || !deps.switchNovelToLibrary) {
          return;
        }

-       void deps
-         .switchNovelToLibrary(state.novelPath, state.pluginId)
+       deps
+         .switchNovelToLibrary(state.novelPath, state.pluginId)
          .then(() => {
            set(inner => {
              if (!inner.novel) {
                return {};
              }
              return {
                novel: {
                  ...inner.novel,
                  inLibrary: !inner.novel.inLibrary,
                },
              };
            });
+         })
+         .catch(() => {
+           // Optionally show error toast via deps
          });
      },
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/hooks/persisted/useNovel/novelStore.ts` around lines 340 - 362, The
followNovel function currently uses "void deps.switchNovelToLibrary(...)" which
triggers ESLint and lacks error handling; change followNovel to an async
function, await deps.switchNovelToLibrary(state.novelPath, state.pluginId)
inside a try/catch, and move the state toggle (set(...)) into the try block so
it only runs on success; in the catch block, surface the failure (e.g., call a
provided notifier like deps.notifyError or at minimum console.error with the
caught error) so users get feedback when switchNovelToLibrary fails and ESLint
warnings are resolved.
src/screens/novel/NovelContext.tsx (1)

45-94: Consider stabilizing route.params to prevent store recreation.

Including route.params directly in the dependency array may cause unnecessary store recreation if the params object reference changes between renders while the actual path and pluginId values remain the same. Since path and pluginId are already extracted and used individually, consider removing route.params from the deps or extracting the id check outside the memo.

♻️ Suggested improvement
+ const routeNovel = 'id' in route.params ? (route.params as NovelInfo) : undefined;
+
  const novelStore = useMemo(
    () =>
      createNovelStore({
        pluginId,
        novelPath: path,
-       novel: 'id' in route.params ? (route.params as NovelInfo) : undefined,
+       novel: routeNovel,
        defaultChapterSort,
        // ... rest unchanged
      }),
-   [defaultChapterSort, path, pluginId, route.params, switchNovelToLibrary],
+   [defaultChapterSort, path, pluginId, routeNovel, switchNovelToLibrary],
  );
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/screens/novel/NovelContext.tsx` around lines 45 - 94, The memo currently
includes route.params in the dependency array which can cause unnecessary
re-creation of novelStore; remove route.params from the deps and instead derive
the NovelInfo check outside the useMemo (e.g. compute a local variable like
initialNovel = 'id' in route.params ? (route.params as NovelInfo) : undefined)
and pass that stable value (or just rely on path and pluginId) into
createNovelStore; update the useMemo dependency array to [defaultChapterSort,
path, pluginId, switchNovelToLibrary] so novelStore (created by
createNovelStore) only rebuilds when actual identifying values change.
src/plugins/dev/DevPagedNovelPlugin.ts (1)

252-363: Consider extracting shared page calculation logic.

The page calculation logic (lines 262-294 and 322-354) is duplicated between parseNovel and parsePage. Consider extracting this into a helper function for maintainability.

♻️ Suggested extraction
const calculateTotalPages = async (
  novelPath: string,
  newContent: boolean,
  newPages: boolean,
): Promise<number> => {
  const res = await dbManager
    .select({ count: novelSchema.totalChapters })
    .from(novelSchema)
    .where(
      and(
        eq(novelSchema.pluginId, 'dev-paged-novel'),
        eq(novelSchema.path, novelPath),
      ),
    )
    .get();

  const currentChapters = res?.count || 0;
  let totalPages = Math.ceil(currentChapters / CHAPTERS_PER_PAGE);

  if (totalPages < DEFAULT_PAGES) {
    totalPages = DEFAULT_PAGES;
  }

  if (newPages) {
    totalPages += 2;
  } else if (newContent) {
    const extraChapters = Math.min(
      5,
      CHAPTERS_PER_PAGE - (totalPages * CHAPTERS_PER_PAGE - currentChapters),
    );
    if (extraChapters > 0) {
      totalPages = Math.ceil(
        (currentChapters + extraChapters) / CHAPTERS_PER_PAGE,
      );
    }
  }

  return totalPages;
};
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/plugins/dev/DevPagedNovelPlugin.ts` around lines 252 - 363, Both
parseNovel and parsePage duplicate the page-calculation logic; extract that
logic into a new async helper (e.g., calculateTotalPages) that accepts
novelPath, newContent, newPages and returns totalPages by performing the
dbManager select on novelSchema, applying CHAPTERS_PER_PAGE and DEFAULT_PAGES
rules and the newPages/newContent adjustments; then replace the duplicated
blocks in parseNovel and parsePage to call calculateTotalPages(novelPath,
newContent, newPages) and use its result for generating chapters and page bounds
checks (references: parseNovel, parsePage, dbManager, novelSchema,
CHAPTERS_PER_PAGE, DEFAULT_PAGES, Storage keys 'provideNewContent' and
'provideNewPages').
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@__mocks__/react-native-mmkv.js`:
- Around line 79-101: createTupleHook currently creates a new jest.fn setter on
every call which breaks referential stability and mock call accumulation; change
createTupleHook to look up a per-key setter in a cache (e.g., setterCache Map)
and reuse the same jest.fn for a given key (creating and storing it only if
missing), keeping the existing setter behavior (deleting maps when value ===
undefined and type-based sets otherwise); ensure tests can clear setterCache in
beforeEach when needed.

In `@package.json`:
- Around line 117-118: The package.json currently lists "zustand": "^5.0.12"
which declares a peer dependency excluding React 19; update package
configuration to avoid broken installs by either (A) pinning the project React
peer/dependency to a 18.x range (so keep "zustand": "^5.0.12") or (B) if you
must target React 19, remove or delay the dependency upgrade and add a TODO to
upgrade "zustand" to a version that advertises React 19 support once released
(or use an explicit override/resolution only as a temporary measure); adjust the
package.json "react"/"react-dom" entries or add a comment/issue referencing
"zustand": "^5.0.12" to document the chosen approach.

In `@src/hooks/__tests__/mocks.ts`:
- Around line 56-59: The shared mock object `batchInformation` is missing the
new `totalChapters` property; update the mock in src/hooks/__tests__/mocks.ts so
`batchInformation` includes `totalChapters` (e.g., set to 0) alongside `batch`
and `total` to match the production/bootstrap/store shape used by the code paths
that read `batchInformation.totalChapters`.
- Around line 98-123: The mock store must implement the external-store contract:
update setState to notify subscribers and make the default context use a fresh
store instead of a singleton. In createMockNovelStore, add an internal listeners
array, implement subscribe(listener) to push the listener and return an
unsubscribe function that removes it, keep getState() returning the current
state, and modify setState(nextState) to compute the new partial state
(supporting function or object), update state, then call each listener();
finally, change defaultMockNovelContext/mockUseNovelContext so they create a new
store instance by calling createMockNovelStore() when invoked rather than
reusing a single store across tests.

In `@src/hooks/__tests__/useNovel.test.ts`:
- Around line 37-93: The test seeds only keyContract.settings/lastRead but
asserts that novelPersistence.keys.settings/lastRead are removed; update the
setup in the test for each novel in cachedNovels to also write the legacy keys
using MMKVStorage.set with novelPersistence.keys.settings({...}) and
novelPersistence.keys.lastRead({...}) (and the tracked key via
TRACKED_NOVEL_PREFIX remains), so deleteCachedNovels is exercised against both
current and legacy key builders; keep the rest of the assertions unchanged.

In `@src/hooks/persisted/useNovel/__tests__/keyContract.test.ts`:
- Around line 50-60: The test expectations use an inconsistent prefix naming:
the spec checks for 'NOVEL_SETTINGS_webnovel_...' while other tests expect
suffixes like 'NOVEL_PAGE_INDEX_PREFIX_' and 'LAST_READ_PREFIX_'; inspect key
generation in keyContract.settings and the prefix constants (e.g.,
NOVEL_SETTINGS_PREFIX, NOVEL_PAGE_INDEX_PREFIX, LAST_READ_PREFIX) and make them
consistent by either changing the expected value in this test to the actual
prefix produced by keyContract.settings or adjusting keyContract to emit the
same prefix format used by the other keys so all tests use the same PREFIX
naming convention.

In `@src/hooks/persisted/useNovel/__tests__/persistence.test.ts`:
- Around line 167-173: Rename the test description to accurately state it only
checks runtime export availability (not compile-time type safety); update the
it(...) string that currently reads 'keeps migrate contract usage compile-safe
through stable exports' to something like 'validates that migrate contract
exports remain available at runtime' and keep the existing runtime checks
against novelPersistence.copySettings, novelPersistence.readLastRead, and the
require('@services/migrate/migrateNovel') destructure of migrateNovel unchanged.

In `@src/hooks/persisted/useNovel/bootstrapService.ts`:
- Around line 65-68: The inflight cache (inflightBootstraps) is too coarse and
global: it is keyed only by getBootstrapKey(pluginId, novelPath) and lives at
module scope, causing different reader settings (pageIndex, settingsSort,
settingsFilter) to collide and leaking dedupe across createBootstrapService()
instances. Fix by moving the Map into the createBootstrapService() closure (so
each service instance has its own cache) and extend the getBootstrapKey to
include pageIndex, settingsSort and settingsFilter (serialize them
deterministically) so the key uniquely represents the full bootstrap request
before storing/reading Promises from inflightBootstraps.
- Around line 133-137: The try/catch around the batched chapter fetch (calling
deps.getPageChaptersBatched in bootstrapService) is swallowing errors and
returning an empty array; replace the console.error swallow with proper error
propagation and app logging: remove the catch that returns [] (or rethrow after
logging via the app logger) so failures bubble to the caller, and apply the same
change to the other batched loader blocks referenced (the similar try/catch
blocks around the other batched loaders at the later occurrences), ensuring you
use the application logger instead of console.error and do not convert
exceptions into successful empty results.
- Around line 80-83: The code is incorrectly filtering custom page keys by
coercing to Number (which drops non-numeric keys like "special"); update the
tmpPages assignment and the analogous branch around lines 138-149 to accept any
non-null page id returned by deps.getCustomPages() instead of checking
Number(page). Specifically, in the tmpPages = (await
deps.getCustomPages(tmpNovel.id)).map(c => c.page).filter((page): page is string
=> page !== null) flow (and the similar block referenced), remove any
Number(page) truthiness checks so calculatePages() receives all string keys
(numeric parsing can be done later only where numeric ids are required).
- Around line 151-155: The batch total is computed as a one-based count causing
an off-by-one when batches are zero-indexed; replace uses of
Math.floor(chapterCount / 300) with a zero-based last-batch calculation: use
Math.max(0, Math.ceil(chapterCount / 300) - 1) so the last batch index is
correct (and non-negative) and update the three occurrences (the BatchInfo
construction assigned to batchInformation and the other two spots referenced in
the comment) to use this expression instead of Math.floor(...).

In `@src/hooks/persisted/useNovel/chapterActions.ts`:
- Around line 188-208: The code persists a clamped value but writes the raw
progress into local state; in updateChapterProgressAction capture a single
clamped value (e.g., const clamped = Math.min(progress, 100)) and use that
clamped value both when calling deps.updateChapterProgress(chapterId, clamped)
and when updating state inside mutateChapters (set c.progress to clamped for the
matching chapterId) so DB and UI remain consistent.
- Around line 210-234: deleteChapterAction (and similarly deleteChaptersAction)
currently calls deps.deleteChapter/deps.deleteChapters and uses .then() with no
rejection handling; add error handling so failures don't leave the UI
inconsistent. Update deleteChapterAction and deleteChaptersAction to either
await the promise in an async function or append a .catch() to the existing
promise chain to revert or avoid mutating state when the delete fails and call
deps.showToast with an error message (use deps.getString to build a localized
error message and include the caught error). Ensure you reference
deps.deleteChapter/deps.deleteChapters, mutateChapters, deps.showToast and
deps.getString when implementing the fix so successful deletes still update
isDownloaded and failures surface an error and do not change state.

In `@src/hooks/persisted/useNovel/keyContract.ts`:
- Around line 12-24: The current key builders in keyContract (pageIndex,
settings, lastRead) concatenate NOVEL_*_PREFIX with pluginId and novelPath using
"_" which can cause collisions when inputs contain underscores; update these
functions to use a safer delimiter (e.g., "::") or serialize the identifiers
(e.g., JSON.stringify or encodeURIComponent) before concatenation so the
produced keys are unambiguous, ensuring you update all three methods (pageIndex,
settings, lastRead) to the same scheme and keep the prefix constants unchanged.

In `@src/hooks/persisted/useNovel/persistence.ts`:
- Around line 72-97: readPageIndex and writePageIndex currently accept negative
or fractional numbers; update both to enforce a non-negative integer: in
readPageIndex (using keys.pageIndex, storage.getNumber and storage.getString)
treat any numeric value as valid only if Number.isFinite and >= 0, then
normalize by taking Math.floor (and persist the floored value with storage.set)
before returning; for parsed string values do the same (Number(...) -> check
finite and >= 0 -> Math.floor -> persist -> return); otherwise fallback to
storage.set(key, defaultPageIndex) and return defaultPageIndex; in
writePageIndex apply the same validation/normalization (Number.isFinite(value)
&& value >= 0 ? Math.floor(value) : defaultPageIndex) and persist that with
storage.set.

In `@src/plugins/dev/DevNovelPlugin.ts`:
- Around line 234-250: The code currently applies the "+5 new content" bump
regardless of whether the novel exists because chapterCount is normalized to at
least 20 before applying newChapters; change the logic so the +5 is only applied
when a real row/count exists for that novel. Concretely, keep retrieving res via
dbManager.select(...) and compute a baseCount from res (e.g., const baseCount =
res?.count ?? 0), derive chapterCount = baseCount < 20 && baseCount > 0 ? 20 :
Math.max(baseCount, 0) (or keep existing normalization but only when
baseCount>0), and set nextChapterCount = newChapters && baseCount > 0 ?
chapterCount + 5 : chapterCount so that provideNewContent only adds chapters
when the novel already exists (use the symbols s, newChapters, res, novelSchema,
chapterCount, nextChapterCount to locate the code).

In `@src/screens/novel/NovelScreen.tsx`:
- Around line 59-60: The selector and store/action names have inconsistent
casing for "Chapters": rename the selector selectMarkPreviouschaptersRead to
selectMarkPreviousChaptersRead, rename the store property
markPreviouschaptersRead to markPreviousChaptersRead, and rename the action
markPreviouschaptersReadAction to markPreviousChaptersReadAction; update all
references/usages (selectors, dispatches, imports/exports, tests) to the new
names so identifiers match, and run type checks to fix any remaining imports or
compilation errors.

In `@src/services/plugin/fetch.ts`:
- Around line 48-53: The code currently treats any non-first-page request as a
generic "could not fetch chapters" error; update the logic in the fetch path
that uses plugin.parseNovel so that when page !== '1' (or numeric page > 1) and
plugin.parsePage is not implemented, you throw a distinct error indicating
"paged chapter fetching is unsupported" (or an UnsupportedPaginationError)
instead of the generic failure; keep the existing behavior of calling
plugin.parseNovel(novelPath) and returning res.chapters when page === '1' and
parseNovel exists, but add an explicit branch that checks plugin.parsePage (or
its absence) and page to produce the clearer error referencing plugin.parsePage,
page and novelPath.

In `@src/services/updates/LibraryUpdateQueries.ts`:
- Line 197: Remove the debug console logging statement
console.log(novel.chapters) from the LibraryUpdateQueries update flow; either
delete it or replace it with a gated logger call (e.g., processLogger.debug or
similar) behind a dev-only condition (NODE_ENV or a feature flag) so production
no-console rules and runtime noise are avoided; locate the statement where the
novel variable is handled in LibraryUpdateQueries and update that block
accordingly.

---

Outside diff comments:
In `@src/screens/reader/components/ChapterDrawer/index.tsx`:
- Around line 190-191: The conditional is comparing the ref object instead of
its value: change the check to test scrollToIndex.current === undefined (not
scrollToIndex) so the LoadingScreenV2 branch only renders when the ref has no
current value; update any other places in this JSX/logic that read scrollToIndex
to use scrollToIndex.current (refer to the scrollToIndex ref and LoadingScreenV2
usage in this component).

---

Nitpick comments:
In `@src/hooks/persisted/useNovel/novelStore.ts`:
- Around line 331-335: The expression using void to ignore the Promise from
state.getChapters() should be replaced with explicit async/error handling:
either call state.getChapters().catch(err => { /* log or handle error */ }) to
handle rejection, or make the enclosing callback async and await
state.getChapters() inside a try/catch; update the code around get() in
novelStore.ts (the section that reads const state = get(); if (state.novel &&
state.pages.length > 0) { ... }) to use one of these patterns and ensure any
errors are logged or handled instead of being swallowed.
- Around line 340-362: The followNovel function currently uses "void
deps.switchNovelToLibrary(...)" which triggers ESLint and lacks error handling;
change followNovel to an async function, await
deps.switchNovelToLibrary(state.novelPath, state.pluginId) inside a try/catch,
and move the state toggle (set(...)) into the try block so it only runs on
success; in the catch block, surface the failure (e.g., call a provided notifier
like deps.notifyError or at minimum console.error with the caught error) so
users get feedback when switchNovelToLibrary fails and ESLint warnings are
resolved.

In `@src/hooks/persisted/useNovel/types.ts`:
- Around line 7-18: Move the NovelSettings interface declaration above the
default values so the type is declared before it’s referenced: place the
NovelSettings interface first, followed by defaultNovelSettings and
defaultPageIndex; update any imports/usages if necessary to reflect the new
order, ensuring the symbols NovelSettings and defaultNovelSettings remain
unchanged.

In `@src/hooks/persisted/useNovel/useChapterOperations.ts`:
- Around line 59-71: Both transformAndSetChapters and extendChapters are
declared async but contain no await and thus return Promise<void>; remove the
unnecessary async keyword from the function declarations for
transformAndSetChapters and extendChapters so they return void; keep their
bodies unchanged and retain the same dependencies (transformChapters and
_setChapters) in the useCallback calls.
- Around line 17-26: The interface UseChapterOperationsParams declares a
chapters field that is never used in the hook; remove the unused property from
UseChapterOperationsParams (delete the chapters: ChapterInfo[] entry) and update
any call sites that construct objects of this type (calls to
useChapterOperations or factories creating UseChapterOperationsParams) to stop
providing chapters, or alternatively, if the data is actually needed, wire the
existing chapters usage into the hook logic (e.g., inside useChapterOperations
reference the passed chapters instead of any other source). Ensure you only
change the interface and its callers or integrate the chapters parameter in the
useChapterOperations function to keep types consistent.

In `@src/hooks/persisted/usePlugins.ts`:
- Around line 53-76: Extract the repeated mapping of devNovelPlugin and
devPagedNovelPlugin into a small helper (e.g., toPluginItem) that converts a
plugin (devNovelPlugin/devPagedNovelPlugin) into a PluginItem by copying id,
name, site, lang, version, url, iconUrl and computing hasSettings as
!!plugin.pluginSettings; then replace the inline objects in the devPlugins array
with calls to that helper (devPlugins, devNovelPlugin, devPagedNovelPlugin,
PluginItem, toPluginItem).

In `@src/plugins/dev/DevPagedNovelPlugin.ts`:
- Around line 252-363: Both parseNovel and parsePage duplicate the
page-calculation logic; extract that logic into a new async helper (e.g.,
calculateTotalPages) that accepts novelPath, newContent, newPages and returns
totalPages by performing the dbManager select on novelSchema, applying
CHAPTERS_PER_PAGE and DEFAULT_PAGES rules and the newPages/newContent
adjustments; then replace the duplicated blocks in parseNovel and parsePage to
call calculateTotalPages(novelPath, newContent, newPages) and use its result for
generating chapters and page bounds checks (references: parseNovel, parsePage,
dbManager, novelSchema, CHAPTERS_PER_PAGE, DEFAULT_PAGES, Storage keys
'provideNewContent' and 'provideNewPages').

In `@src/plugins/pluginManager.ts`:
- Around line 172-174: Extract the duplicate dev plugin ID strings into shared
constants (e.g., DEV_PLUGIN_NOVEL and DEV_PLUGIN_PAGED_NOVEL) and replace the
inline literals in both the registry initialization and the conditional that
checks pluginId in pluginManager.ts; specifically update the check that
currently uses (pluginId === 'dev-novel' || pluginId === 'dev-paged-novel') to
reference the new constants and ensure the registry initialization also uses the
same constants to avoid duplication and typos.

In `@src/screens/novel/__tests__/NovelScreen.test.tsx`:
- Around line 43-48: The test currently asserts behavior on the DB helper
updateChapterProgressByIds which ties the spec to an implementation detail;
instead spy or mock the public store API (novelStore) used by the screen and
assert the store boundary was invoked. Replace expectations that reference
`@database/queries/ChapterQueries.updateChapterProgressByIds` with assertions
against the novelStore method the screen calls (e.g., the store method that
resets/updates chapter progress), and update the mocks in the three affected
blocks (around lines 43-48, 223-239, 290-305) to stop verifying the DB helper
and verify novelStore was called with the expected ids/args; keep the existing
jest.mock of getAllUndownloadedAndUnreadChapters/getAllUndownloadedChapters but
move the progress-reset assertion to the store-level spy.

In `@src/screens/novel/components/NovelScreenList.tsx`:
- Around line 108-130: Multiple calls to useNovelDomainValue (e.g.,
useNovelDomainValue(novelStore, selectChapters), selectDeleteChapter,
selectFetching, selectFirstUnreadChapter, selectLoading, selectNovelSettings,
selectPages, selectSetNovel, selectNovel, selectBatchInformation,
selectPageIndex, selectOpenPage, selectUpdateChapter, selectRefreshNovel,
selectLastRead) create many subscriptions; replace them with a single
consolidated selector (e.g., selectNovelScreenListState) that returns an object
with all required fields, call useNovelDomainValue once with that selector, and
then destructure the returned object into chapters, deleteChapter, fetching,
firstUnreadChapter, loading, novelSettings, pages, setNovel, fetchedNovel,
batchInformation, pageIndex, openPage, updateChapter, refreshNovel, lastRead to
reduce subscription overhead and re-renders.

In `@src/screens/novel/NovelContext.tsx`:
- Around line 45-94: The memo currently includes route.params in the dependency
array which can cause unnecessary re-creation of novelStore; remove route.params
from the deps and instead derive the NovelInfo check outside the useMemo (e.g.
compute a local variable like initialNovel = 'id' in route.params ?
(route.params as NovelInfo) : undefined) and pass that stable value (or just
rely on path and pluginId) into createNovelStore; update the useMemo dependency
array to [defaultChapterSort, path, pluginId, switchNovelToLibrary] so
novelStore (created by createNovelStore) only rebuilds when actual identifying
values change.

In
`@src/screens/reader/components/ChapterDrawer/__tests__/ChapterDrawer.test.tsx`:
- Around line 135-162: Add unit tests in ChapterDrawer.test.tsx that cover the
edge conditions the reviewer suggested: create variants of createStore with
fetching: true, with batchInformation where batch >= total, and with pages empty
or missing the current chapter page; for each test use mockUseChapterContext and
mockUseNovelContext as in the existing test, render <ChapterDrawer />, trigger
the end-reached event via
fireEvent.press(screen.getByTestId('legend-end-reached')), and assert that
store.state.getNextChapterBatch is NOT called; also keep the existing
expectation that setPageIndex is invoked where appropriate to ensure
selector-backed behavior remains tested.

In `@src/screens/reader/components/ChapterDrawer/index.tsx`:
- Around line 43-58: Extract the duplicated hook useNovelDomainValue into a
shared utility and update callers to import it instead of redefining it; create
a new file (e.g., `@hooks/persisted/useNovel/useNovelDomainValue.ts`) that exports
useNovelDomainValue with the same signature and implementation (it should
reference useNovelContext's novelStore, useCallback for subscribe/getSnapshot,
and call novelStore.subscribe and novelStore.getState with the selector), then
replace the local definitions in ChapterDrawer (index.tsx), NovelScreenList.tsx,
NovelScreen.tsx, and useChapter.ts with an import from the new module to remove
duplication and keep behavior identical.

In `@src/utils/mmkv/zustand-adapter.ts`:
- Around line 19-25: Replace the console.error calls inside the mmkv-zustand
adapter error handlers with the project logging utility or explicitly suppress
the lint rule with a justified inline comment: locate the catch blocks in the
mmkv adapter functions (e.g., getItem, setItem, removeItem in
zustand-adapter.ts) and either swap console.error(...) for the shared logger
(processLogger or logger) with the same message and error object, or add an
eslint-disable-next-line no-console comment plus a brief justification above
each console.error (the occurrences at the catch blocks currently around the
key-handling logic and the ones at lines corresponding to getItem, setItem, and
removeItem).

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 9b8c4d30-3941-47a4-9d60-e1a364145a33

📥 Commits

Reviewing files that changed from the base of the PR and between 44e035a and 3a4f569.

⛔ Files ignored due to path filters (1)
  • pnpm-lock.yaml is excluded by !**/pnpm-lock.yaml
📒 Files selected for processing (47)
  • .github/workflows/build.yml
  • .gitignore
  • __mocks__/react-native-mmkv.js
  • package.json
  • src/hooks/__tests__/mocks.ts
  • src/hooks/__tests__/mocksContract.test.ts
  • src/hooks/__tests__/useNovel.test.ts
  • src/hooks/__tests__/useNovelStore.test.ts
  • src/hooks/persisted/__mocks__/useNovel.ts
  • src/hooks/persisted/__mocks__/useNovelSettings.ts
  • src/hooks/persisted/index.ts
  • src/hooks/persisted/useNovel.ts
  • src/hooks/persisted/useNovel/__tests__/bootstrapService.test.ts
  • src/hooks/persisted/useNovel/__tests__/chapterActions.test.ts
  • src/hooks/persisted/useNovel/__tests__/keyContract.test.ts
  • src/hooks/persisted/useNovel/__tests__/persistence.test.ts
  • src/hooks/persisted/useNovel/__tests__/useNovelSettings.test.ts
  • src/hooks/persisted/useNovel/bootstrapService.ts
  • src/hooks/persisted/useNovel/chapterActions.ts
  • src/hooks/persisted/useNovel/contracts.ts
  • src/hooks/persisted/useNovel/keyContract.ts
  • src/hooks/persisted/useNovel/novelStore.ts
  • src/hooks/persisted/useNovel/persistence.ts
  • src/hooks/persisted/useNovel/types.ts
  • src/hooks/persisted/useNovel/useChapterOperations.ts
  • src/hooks/persisted/useNovelSettings.ts
  • src/hooks/persisted/usePlugins.ts
  • src/plugins/dev/DevNovelPlugin.ts
  • src/plugins/dev/DevPagedNovelPlugin.ts
  • src/plugins/dev/index.ts
  • src/plugins/pluginManager.ts
  • src/screens/browse/components/InstalledTab.tsx
  • src/screens/novel/NovelContext.tsx
  • src/screens/novel/NovelScreen.tsx
  • src/screens/novel/__tests__/NovelScreen.test.tsx
  • src/screens/novel/components/Info/NovelInfoHeader.tsx
  • src/screens/novel/components/NovelScreenList.tsx
  • src/screens/novel/components/__tests__/NovelScreenList.test.tsx
  • src/screens/reader/components/ChapterDrawer/__tests__/ChapterDrawer.test.tsx
  • src/screens/reader/components/ChapterDrawer/index.tsx
  • src/screens/reader/hooks/useChapter.ts
  • src/screens/settings/SettingsAdvancedScreen.tsx
  • src/services/migrate/migrateNovel.ts
  • src/services/plugin/fetch.ts
  • src/services/updates/LibraryUpdateQueries.ts
  • src/utils/mmkv/zustand-adapter.ts
  • tsconfig.tsbuildinfo
💤 Files with no reviewable changes (1)
  • src/screens/browse/components/InstalledTab.tsx

Comment on lines +79 to +101
const createTupleHook = getter => (key, fallback) =>
[
getter(key) ?? fallback,
jest.fn(value => {
if (value === undefined) {
strings.delete(key);
numbers.delete(key);
booleans.delete(key);
buffers.delete(key);
return;
}

if (typeof value === 'string') {
strings.set(key, value);
} else if (typeof value === 'number') {
numbers.set(key, value);
} else if (typeof value === 'boolean') {
booleans.set(key, value);
} else {
buffers.set(key, value);
}
}),
];
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Hook setters are recreated on each call, losing mock call history.

createTupleHook returns a new jest.fn() on every invocation. This means:

  1. Each component render gets a different setter reference.
  2. Mock assertions on the setter won't accumulate across renders.

If tests need to assert on setter calls or rely on referential stability, consider caching the setter per key.

🛡️ Optional fix: Cache setters per key
+const setterCache = new Map();
+
 const createTupleHook = getter => (key, fallback) => {
+  if (!setterCache.has(key)) {
+    setterCache.set(key, jest.fn(value => {
+      if (value === undefined) {
+        strings.delete(key);
+        numbers.delete(key);
+        booleans.delete(key);
+        buffers.delete(key);
+        return;
+      }
+      if (typeof value === 'string') {
+        strings.set(key, value);
+      } else if (typeof value === 'number') {
+        numbers.set(key, value);
+      } else if (typeof value === 'boolean') {
+        booleans.set(key, value);
+      } else {
+        buffers.set(key, value);
+      }
+    }));
+  }
   return [
     getter(key) ?? fallback,
-    jest.fn(value => {
-      // ... setter logic
-    }),
+    setterCache.get(key),
   ];
 };

Remember to clear setterCache in a beforeEach if needed.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const createTupleHook = getter => (key, fallback) =>
[
getter(key) ?? fallback,
jest.fn(value => {
if (value === undefined) {
strings.delete(key);
numbers.delete(key);
booleans.delete(key);
buffers.delete(key);
return;
}
if (typeof value === 'string') {
strings.set(key, value);
} else if (typeof value === 'number') {
numbers.set(key, value);
} else if (typeof value === 'boolean') {
booleans.set(key, value);
} else {
buffers.set(key, value);
}
}),
];
const setterCache = new Map();
const createTupleHook = getter => (key, fallback) => {
if (!setterCache.has(key)) {
setterCache.set(key, jest.fn(value => {
if (value === undefined) {
strings.delete(key);
numbers.delete(key);
booleans.delete(key);
buffers.delete(key);
return;
}
if (typeof value === 'string') {
strings.set(key, value);
} else if (typeof value === 'number') {
numbers.set(key, value);
} else if (typeof value === 'boolean') {
booleans.set(key, value);
} else {
buffers.set(key, value);
}
}));
}
return [
getter(key) ?? fallback,
setterCache.get(key),
];
};
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@__mocks__/react-native-mmkv.js` around lines 79 - 101, createTupleHook
currently creates a new jest.fn setter on every call which breaks referential
stability and mock call accumulation; change createTupleHook to look up a
per-key setter in a cache (e.g., setterCache Map) and reuse the same jest.fn for
a given key (creating and storing it only if missing), keeping the existing
setter behavior (deleting maps when value === undefined and type-based sets
otherwise); ensure tests can clear setterCache in beforeEach when needed.

Comment on lines +117 to +118
"urlencode": "^2.0.0",
"zustand": "^5.0.12"
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🌐 Web query:

What is the latest version of zustand npm package?

💡 Result:

The latest version of the zustand npm package is 5.0.12.

Citations:


🌐 Web query:

Zustand 5.0.12 React 19 compatibility

💡 Result:

Zustand 5.0.12 is not officially compatible with React 19. The peer dependency for React is specified as ">=18.0.0", which does not include React 19. Official documentation and changelogs for v5.0.12 do not mention React 19 support or updates to peer dependencies. Early discussions (e.g., Nov 2024) show peer dependency conflicts with React 19 RCs when using npm strict mode, requiring --force or --legacy-peer-deps. While some tools like DepFixer claim compatibility for earlier 5.x versions and Zustand works in practice due to minimal React API usage, strict compatibility requires peer deps alignment, which is absent. For React 19, follow best practices like using selectors with 'use' prefix and useShallow for destructuring to work with React Compiler.

Citations:


Zustand 5.0.12 is not officially compatible with React 19.

Zustand 5.0.12 is the latest stable version, but its peer dependency specifies >=18.0.0, explicitly excluding React 19. Using it with React 19 will require --force or --legacy-peer-deps flags during installation. If this project targets React 19, either upgrade Zustand once React 19 support is released or retain React 18 compatibility.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@package.json` around lines 117 - 118, The package.json currently lists
"zustand": "^5.0.12" which declares a peer dependency excluding React 19; update
package configuration to avoid broken installs by either (A) pinning the project
React peer/dependency to a 18.x range (so keep "zustand": "^5.0.12") or (B) if
you must target React 19, remove or delay the dependency upgrade and add a TODO
to upgrade "zustand" to a version that advertises React 19 support once released
(or use an explicit override/resolution only as a temporary measure); adjust the
package.json "react"/"react-dom" entries or add a comment/issue referencing
"zustand": "^5.0.12" to document the chosen approach.

Comment on lines +56 to +59
batchInformation: {
batch: 0,
total: 0,
},
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Include totalChapters in the shared mock batch state.

The new bootstrap/store flow populates batchInformation.totalChapters. Leaving it out here means tests built on this helper see undefined instead of the production shape.

Possible fix
   batchInformation: {
     batch: 0,
     total: 0,
+    totalChapters: 0,
   },
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
batchInformation: {
batch: 0,
total: 0,
},
batchInformation: {
batch: 0,
total: 0,
totalChapters: 0,
},
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/hooks/__tests__/mocks.ts` around lines 56 - 59, The shared mock object
`batchInformation` is missing the new `totalChapters` property; update the mock
in src/hooks/__tests__/mocks.ts so `batchInformation` includes `totalChapters`
(e.g., set to 0) alongside `batch` and `total` to match the
production/bootstrap/store shape used by the code paths that read
`batchInformation.totalChapters`.

Comment on lines +98 to +123
export const createMockNovelStore = (
stateOverrides: Record<string, unknown> = {},
) => {
let state = createMockNovelStoreState(stateOverrides);

return {
getState: jest.fn(() => state),
setState: jest.fn(nextState => {
const partial =
typeof nextState === 'function' ? nextState(state) : nextState;
state = {
...state,
...partial,
};
}),
subscribe: jest.fn(() => () => {}),
};
};

const defaultMockNovelContext = {
novelStore: createMockNovelStore(),
navigationBarHeight: 0,
statusBarHeight: 0,
};

export const mockUseNovelContext = jest.fn(() => defaultMockNovelContext);
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Make the mock store obey the external-store contract.

setState() never notifies subscribers, and the default context reuses a singleton store. With useSyncExternalStore, that can hide rerender bugs and leak mutated state between tests.

Possible fix
 export const createMockNovelStore = (
   stateOverrides: Record<string, unknown> = {},
 ) => {
   let state = createMockNovelStoreState(stateOverrides);
+  const listeners = new Set<() => void>();

   return {
     getState: jest.fn(() => state),
     setState: jest.fn(nextState => {
       const partial =
         typeof nextState === 'function' ? nextState(state) : nextState;
       state = {
         ...state,
         ...partial,
       };
+      listeners.forEach(listener => listener());
     }),
-    subscribe: jest.fn(() => () => {}),
+    subscribe: jest.fn(listener => {
+      listeners.add(listener);
+      return () => listeners.delete(listener);
+    }),
   };
 };
 
-const defaultMockNovelContext = {
-  novelStore: createMockNovelStore(),
-  navigationBarHeight: 0,
-  statusBarHeight: 0,
-};
-
-export const mockUseNovelContext = jest.fn(() => defaultMockNovelContext);
+const createDefaultMockNovelContext = () => ({
+  novelStore: createMockNovelStore(),
+  navigationBarHeight: 0,
+  statusBarHeight: 0,
+});
+
+export const mockUseNovelContext = jest.fn(() =>
+  createDefaultMockNovelContext(),
+);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/hooks/__tests__/mocks.ts` around lines 98 - 123, The mock store must
implement the external-store contract: update setState to notify subscribers and
make the default context use a fresh store instead of a singleton. In
createMockNovelStore, add an internal listeners array, implement
subscribe(listener) to push the listener and return an unsubscribe function that
removes it, keep getState() returning the current state, and modify
setState(nextState) to compute the new partial state (supporting function or
object), update state, then call each listener(); finally, change
defaultMockNovelContext/mockUseNovelContext so they create a new store instance
by calling createMockNovelStore() when invoked rather than reusing a single
store across tests.

Comment on lines +72 to +97
const readPageIndex = (input: NovelPersistenceInput): number => {
const key = keys.pageIndex(input);
const numberValue = storage.getNumber(key);

if (typeof numberValue === 'number' && Number.isFinite(numberValue)) {
return numberValue;
}

const stringValue = storage.getString(key);
if (stringValue !== undefined) {
const parsed = Number(stringValue);
if (Number.isFinite(parsed)) {
storage.set(key, parsed);
return parsed;
}
}

storage.set(key, defaultPageIndex);
return defaultPageIndex;
};

const writePageIndex = (input: NovelPersistenceInput, value: number) => {
const key = keys.pageIndex(input);
const safeValue = Number.isFinite(value) ? value : defaultPageIndex;
storage.set(key, safeValue);
};
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Reject negative or fractional page indices.

Number.isFinite still accepts values like -1 and 1.5, so the bridge can persist/read indices that later make pages[pageIndex] resolve to undefined and silently fall back to page '1'. Normalize both legacy-string parsing and new writes to a non-negative integer.

🛠️ Proposed fix
   const readPageIndex = (input: NovelPersistenceInput): number => {
     const key = keys.pageIndex(input);
     const numberValue = storage.getNumber(key);

-    if (typeof numberValue === 'number' && Number.isFinite(numberValue)) {
+    if (
+      typeof numberValue === 'number' &&
+      Number.isInteger(numberValue) &&
+      numberValue >= 0
+    ) {
       return numberValue;
     }

     const stringValue = storage.getString(key);
     if (stringValue !== undefined) {
       const parsed = Number(stringValue);
-      if (Number.isFinite(parsed)) {
+      if (Number.isInteger(parsed) && parsed >= 0) {
         storage.set(key, parsed);
         return parsed;
       }
     }

     storage.set(key, defaultPageIndex);
     return defaultPageIndex;
   };

   const writePageIndex = (input: NovelPersistenceInput, value: number) => {
     const key = keys.pageIndex(input);
-    const safeValue = Number.isFinite(value) ? value : defaultPageIndex;
+    const safeValue =
+      Number.isInteger(value) && value >= 0 ? value : defaultPageIndex;
     storage.set(key, safeValue);
   };
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const readPageIndex = (input: NovelPersistenceInput): number => {
const key = keys.pageIndex(input);
const numberValue = storage.getNumber(key);
if (typeof numberValue === 'number' && Number.isFinite(numberValue)) {
return numberValue;
}
const stringValue = storage.getString(key);
if (stringValue !== undefined) {
const parsed = Number(stringValue);
if (Number.isFinite(parsed)) {
storage.set(key, parsed);
return parsed;
}
}
storage.set(key, defaultPageIndex);
return defaultPageIndex;
};
const writePageIndex = (input: NovelPersistenceInput, value: number) => {
const key = keys.pageIndex(input);
const safeValue = Number.isFinite(value) ? value : defaultPageIndex;
storage.set(key, safeValue);
};
const readPageIndex = (input: NovelPersistenceInput): number => {
const key = keys.pageIndex(input);
const numberValue = storage.getNumber(key);
if (
typeof numberValue === 'number' &&
Number.isInteger(numberValue) &&
numberValue >= 0
) {
return numberValue;
}
const stringValue = storage.getString(key);
if (stringValue !== undefined) {
const parsed = Number(stringValue);
if (Number.isInteger(parsed) && parsed >= 0) {
storage.set(key, parsed);
return parsed;
}
}
storage.set(key, defaultPageIndex);
return defaultPageIndex;
};
const writePageIndex = (input: NovelPersistenceInput, value: number) => {
const key = keys.pageIndex(input);
const safeValue =
Number.isInteger(value) && value >= 0 ? value : defaultPageIndex;
storage.set(key, safeValue);
};
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/hooks/persisted/useNovel/persistence.ts` around lines 72 - 97,
readPageIndex and writePageIndex currently accept negative or fractional
numbers; update both to enforce a non-negative integer: in readPageIndex (using
keys.pageIndex, storage.getNumber and storage.getString) treat any numeric value
as valid only if Number.isFinite and >= 0, then normalize by taking Math.floor
(and persist the floored value with storage.set) before returning; for parsed
string values do the same (Number(...) -> check finite and >= 0 -> Math.floor ->
persist -> return); otherwise fallback to storage.set(key, defaultPageIndex) and
return defaultPageIndex; in writePageIndex apply the same
validation/normalization (Number.isFinite(value) && value >= 0 ?
Math.floor(value) : defaultPageIndex) and persist that with storage.set.

Comment on lines +234 to +250
const s = new Storage('dev-novel');
const newChapters = s.get('provideNewContent');

const res = await dbManager
.select({
count: novelSchema.totalChapters,
})
.from(novelSchema)
.where(
and(
eq(novelSchema.pluginId, 'dev-novel'),
eq(novelSchema.path, novelPath),
),
)
.get();
const chapterCount = !res?.count || res.count < 20 ? 20 : res?.count;
const nextChapterCount = newChapters ? chapterCount + 5 : chapterCount;
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Only add “new content” when the novel already exists.

With the current math, a brand-new novel starts at 25 chapters when provideNewContent is enabled, because the base count is normalized to 20 before the extra + 5. That does not match the setting label “on update”.

Possible fix
     const res = await dbManager
       .select({
         count: novelSchema.totalChapters,
       })
       .from(novelSchema)
@@
       )
       .get();
-    const chapterCount = !res?.count || res.count < 20 ? 20 : res?.count;
-    const nextChapterCount = newChapters ? chapterCount + 5 : chapterCount;
+    const existingChapterCount = res?.count ?? 0;
+    const chapterCount =
+      existingChapterCount < 20 ? 20 : existingChapterCount;
+    const nextChapterCount =
+      newChapters && existingChapterCount > 0
+        ? chapterCount + 5
+        : chapterCount;
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/plugins/dev/DevNovelPlugin.ts` around lines 234 - 250, The code currently
applies the "+5 new content" bump regardless of whether the novel exists because
chapterCount is normalized to at least 20 before applying newChapters; change
the logic so the +5 is only applied when a real row/count exists for that novel.
Concretely, keep retrieving res via dbManager.select(...) and compute a
baseCount from res (e.g., const baseCount = res?.count ?? 0), derive
chapterCount = baseCount < 20 && baseCount > 0 ? 20 : Math.max(baseCount, 0) (or
keep existing normalization but only when baseCount>0), and set nextChapterCount
= newChapters && baseCount > 0 ? chapterCount + 5 : chapterCount so that
provideNewContent only adds chapters when the novel already exists (use the
symbols s, newChapters, res, novelSchema, chapterCount, nextChapterCount to
locate the code).

Comment on lines +59 to +60
const selectMarkPreviouschaptersRead = (state: NovelStoreState) =>
state.markPreviouschaptersRead;
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Check if the store property uses consistent casing
ast-grep --pattern 'markPreviouschaptersRead'
ast-grep --pattern 'markPreviousChaptersRead'

Repository: CD-Z/lnreader

Length of output: 414


🏁 Script executed:

# Check if selectMarkPreviousChaptersUnread exists
rg -i "selectmarkpreviouschapters" --type ts --type tsx -A 2 -B 2

Repository: CD-Z/lnreader

Length of output: 84


🏁 Script executed:

# Also search for any "Unread" related selectors and store properties
rg "PreviousChapters" --type ts --type tsx -A 1 -B 1

Repository: CD-Z/lnreader

Length of output: 84


🏁 Script executed:

# Look at the store definition to understand property naming
fd "store" --type ts --type tsx | xargs rg "markPrevious" -A 1 -B 1

Repository: CD-Z/lnreader

Length of output: 11762


Fix inconsistent casing in selector and store property names.

selectMarkPreviouschaptersRead and the underlying markPreviouschaptersRead property use lowercase 'c' in "chapters", while selectMarkPreviousChaptersUnread uses uppercase 'C'. Standardize to uppercase 'C' for consistency.

Changes needed:

  • Rename selectMarkPreviouschaptersRead to selectMarkPreviousChaptersRead (line 59)
  • Rename markPreviouschaptersRead property in store (novelStore.ts)
  • Rename markPreviouschaptersReadAction function (chapterActions.ts)
  • Update all usages across the codebase
🔤 Proposed fix for selector
-const selectMarkPreviouschaptersRead = (state: NovelStoreState) =>
-  state.markPreviouschaptersRead;
+const selectMarkPreviousChaptersRead = (state: NovelStoreState) =>
+  state.markPreviousChaptersRead;
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/screens/novel/NovelScreen.tsx` around lines 59 - 60, The selector and
store/action names have inconsistent casing for "Chapters": rename the selector
selectMarkPreviouschaptersRead to selectMarkPreviousChaptersRead, rename the
store property markPreviouschaptersRead to markPreviousChaptersRead, and rename
the action markPreviouschaptersReadAction to markPreviousChaptersReadAction;
update all references/usages (selectors, dispatches, imports/exports, tests) to
the new names so identifiers match, and run type checks to fix any remaining
imports or compilation errors.

Comment on lines +48 to +53
if (plugin.parseNovel && page === '1') {
const res = await plugin.parseNovel(novelPath);
if (res.chapters) return { chapters: res.chapters };
throw new Error(`Could not fetch chapters for novel ${novelPath}`);
}
throw new Error(`Could not fetch chapters for novel ${novelPath}`);
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Differentiate the unsupported non-first-page case.

When parsePage is missing and the caller asks for page > 1, this throws as if the novel fetch failed. The real failure is that paged chapter fetching is unsupported, which is much more actionable for callers and logs.

Possible fix
   if (plugin.parseNovel && page === '1') {
     const res = await plugin.parseNovel(novelPath);
     if (res.chapters) return { chapters: res.chapters };
     throw new Error(`Could not fetch chapters for novel ${novelPath}`);
   }
-  throw new Error(`Could not fetch chapters for novel ${novelPath}`);
+  throw new Error(
+    `Plugin ${pluginId} cannot fetch chapters for page ${page} of ${novelPath} without parsePage()`,
+  );
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
if (plugin.parseNovel && page === '1') {
const res = await plugin.parseNovel(novelPath);
if (res.chapters) return { chapters: res.chapters };
throw new Error(`Could not fetch chapters for novel ${novelPath}`);
}
throw new Error(`Could not fetch chapters for novel ${novelPath}`);
if (plugin.parseNovel && page === '1') {
const res = await plugin.parseNovel(novelPath);
if (res.chapters) return { chapters: res.chapters };
throw new Error(`Could not fetch chapters for novel ${novelPath}`);
}
throw new Error(
`Plugin ${pluginId} cannot fetch chapters for page ${page} of ${novelPath} without parsePage()`,
);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/services/plugin/fetch.ts` around lines 48 - 53, The code currently treats
any non-first-page request as a generic "could not fetch chapters" error; update
the logic in the fetch path that uses plugin.parseNovel so that when page !==
'1' (or numeric page > 1) and plugin.parsePage is not implemented, you throw a
distinct error indicating "paged chapter fetching is unsupported" (or an
UnsupportedPaginationError) instead of the generic failure; keep the existing
behavior of calling plugin.parseNovel(novelPath) and returning res.chapters when
page === '1' and parseNovel exists, but add an explicit branch that checks
plugin.parsePage (or its absence) and page to produce the clearer error
referencing plugin.parsePage, page and novelPath.

Copy link
Copy Markdown

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 15

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
src/screens/novel/NovelScreen.tsx (1)

40-56: ⚠️ Potential issue | 🔴 Critical

Update all Jest mocks to export useNovelValue and useNovelActions instead of the non-existent useNovelContext.

The mocks in test files reference useNovelContext, which is not actually exported from NovelContext.tsx. The actual exports are useNovelValue and useNovelActions. This mismatch causes test failures with TypeError: useNovelValue is not a function.

Update the following mock definitions to match the real export surface:

  • src/screens/novel/__tests__/NovelScreen.test.tsx:31
  • src/screens/novel/components/__tests__/NovelScreenList.test.tsx:9
  • src/hooks/__tests__/mocks.ts:125
  • src/screens/reader/hooks/__tests__/useChapter.test.ts:23
  • src/screens/reader/components/ChapterDrawer/__tests__/ChapterDrawer.test.tsx:13
  • src/hooks/persisted/useNovel/__tests__/useNovelSettings.test.ts:6

Each mock should export useNovelValue and useNovelActions matching the signatures in NovelContext.tsx (e.g., useNovelValue: (key) => mockValue(key) and useNovelActions: () => mockActions()).

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/screens/novel/NovelScreen.tsx` around lines 40 - 56, Update each Jest
mock module that currently exports useNovelContext to instead export two
functions: useNovelValue and useNovelActions; implement useNovelValue as a
function that accepts a key and returns the corresponding mock value (e.g.,
return mockValue(key)) and implement useNovelActions as a function that returns
the mock actions object (e.g., return mockActions()); ensure the exported
signatures match the real NovelContext hooks used in production
(useNovelValue(key) and useNovelActions()) and replace any references to
useNovelContext in tests with these new exports so tests no longer attempt to
call a non-existent function.
🧹 Nitpick comments (2)
src/hooks/persisted/useNovelSettings.ts (1)

37-48: setChapterSort dependency array may cause stale closures.

The callback references novelSettings?.showChapterTitles and _filter, but novelSettings object itself changes on every settings update. Consider whether including the entire novelSettings object would be more robust.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/hooks/persisted/useNovelSettings.ts` around lines 37 - 48, The
setChapterSort callback can capture stale values because it depends on
novelSettings?.showChapterTitles and _filter while novelSettings is a new object
on each update; update the dependency array to include novelSettings (the whole
object) and _filter so the hook re-creates when settings change, and keep using
novelSettings?.showChapterTitles inside the body when calling
writeNovelSettings; ensure the dependency list reads [novel, writeNovelSettings,
novelSettings, _filter] targeting the setChapterSort function.
src/screens/novel/NovelContext.tsx (1)

157-163: Consider whether layoutValue memoization is correct.

The useMemo has an empty dependency array, but the value depends on navigationBarHeightRef.current and statusBarHeightRef.current which may change. Since refs don't trigger re-renders, this memo will only compute once. If this is intentional (to capture initial layout values), this is fine. Otherwise, consider if the layout needs to update when insets change.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/screens/novel/NovelContext.tsx` around lines 157 - 163, layoutValue is
memoized with an empty dependency array but reads navigationBarHeightRef.current
and statusBarHeightRef.current, so it won't update when those refs change;
either include the values that drive layoutValue in the dependency list (e.g.
navigationBarHeightRef.current and statusBarHeightRef.current) or convert the
refs into state and update that state when insets change, then memoize using
that state; update the code around layoutValue, navigationBarHeightRef,
statusBarHeightRef and the useMemo call accordingly so layoutValue reflects
changes.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@src/database/manager/manager.ts`:
- Around line 146-149: The current code calls the provided callback directly
from the useState initializer and from effects with callback excluded from deps,
causing render-time invocation and stale/unsafe closures; fix this by storing
the latest callback in a ref (e.g. callbackRef.current) and updating it inside
an effect, remove direct calls to callback from the useState initializer (do not
call callback from the db.executeSync initialiser), and invoke
callbackRef.current only from committed effects/subscriptions (and from the live
query subscription handlers) so calls are cancelled on unmount; apply the same
pattern to useLiveQueryAsync by moving the initial async callback into an effect
with cancellation and using callbackRef.current for all subsequent invocations
to avoid stale closures.

In `@src/database/queries/NovelQueries.ts`:
- Around line 85-89: The change made getNovelById into a synchronous/blocking
call by using dbManager.getSync; restore the original async contract or add an
explicit sync helper: revert getNovelById to return a Promise (use the
async/dbManager.get or the async API around
dbManager.select().from(novelSchema).where(eq(novelSchema.id, novelId))) so
existing await callers (e.g., downloadChapter.ts and tests) remain async, and if
a sync variant is needed add a new function named getNovelByIdSync that uses
dbManager.getSync; make sure to update or leave callers unchanged depending on
which route you pick.

In `@src/hooks/persisted/useNovel/__tests__/novelStore.chapterState.test.ts`:
- Around line 82-94: The tests assume remove() returns a boolean but the current
API (ChapterTextCacheApi.remove) and its implementation
(novelStore.chapterActions.ts remove) return void; change the tests that call
createChapterTextCache().remove(...) (including the other occurrences around
lines 118-123) to assert post-removal state instead of a boolean — e.g., call
cache.remove(id) then assert cache.read(id) is undefined or cache.has(id) is
false (or that the cache size changed) so the tests align with the existing
remove() contract rather than changing the API/implementation.
- Line 1: The test imports createChapterTextCache from the wrong module
(novelStore.chapterState) which only exports createInitialChapterSlice; update
the import to pull createChapterTextCache from the module that actually exports
it (the module that defines the cache factory) so createChapterTextCache is not
undefined and tests can run; keep the existing import for
createInitialChapterSlice as-is if needed and replace only the source module for
createChapterTextCache.

In `@src/hooks/persisted/useNovel/bootstrapService.ts`:
- Line 274: Remove the debug timing calls: locate the console.time and
console.timeEnd usages (they use the key `bootstrap_${pluginId}_${novelPath}`
and reference the variables pluginId and novelPath) and delete them or replace
them with the project's logger (e.g., processLogger.debug/info) emitting a
single log entry with timing info if timing is required; ensure you update both
occurrences (the one at the start and the corresponding console.timeEnd) so no
raw console.* calls remain.
- Around line 326-332: The returned service object is currently filled with
no-op stubs; replace those stubs by returning the real implementations defined
above—specifically return getChaptersForPage, getNextChapterBatch,
loadUpToBatch, and bootstrapNovel (the actual functions defined earlier in this
module) instead of empty arrow functions so the bootstrap service becomes
functional.

In `@src/hooks/persisted/useNovel/novelStore.actions.ts`:
- Around line 160-180: The followNovel action is double-toggling the inLibrary
flag: deps.switchNovelToLibrary already updates DB and refetches, so remove the
local manual toggle inside set(...) in followNovel; instead call
deps.switchNovelToLibrary(state.novelPath, state.pluginId) and do not modify
inner.novel.inLibrary—let the refetch/update flow reconcile the store, or
alternatively change switchNovelToLibrary to return the new novel state and
assign that returned value to novel if you prefer immediate local update (use
symbols: followNovel, deps.switchNovelToLibrary, get, set, inner.novel,
inLibrary, state.novelPath, state.pluginId).

In `@src/hooks/persisted/useNovel/novelStore.chapterActions.ts`:
- Line 2: The import line in novelStore.chapterActions.ts includes unused type
imports (remove NovelInfo and BatchInfo) — keep only ChapterInfo (and any other
actually used symbols) in the import from '@database/types'; update the import
statement that currently references ChapterInfo, NovelInfo (and BatchInfo) so
ESLint no longer flags unused imports and rely on the function signatures'
inferred types instead.

In `@src/hooks/persisted/useNovelSettings.ts`:
- Around line 19-28: The test's Jest mock for NovelContext only exports
useNovelContext but the hook under test (useNovelSettings) imports useNovelValue
and useNovelAction; update the mock in
src/hooks/persisted/useNovel/__tests__/useNovelSettings.test.ts to also export
useNovelValue and useNovelAction so calls in useNovelSettings resolve during
tests (implement them to read from the mocked novel store/state and actions
similarly to useNovelContext's mock).

In `@src/screens/novel/components/JumpToChapterModal.tsx`:
- Around line 43-47: The code sets maxNumber = batchInformation.totalChapters ??
-1 which produces an invalid upper bound; change maxNumber to be optional (e.g.,
undefined) when batchInformation.totalChapters is not present and update all
logic that uses maxNumber (search validation, numeric parse branches,
placeholder generation) in JumpToChapterModal to treat the upper bound as
unknown rather than -1 so numeric searches still work against loadedChapters and
the placeholder doesn't show "≤ -1"; locate references to maxNumber and the
functions that rely on it (e.g., the numeric matching code around lines ~131-150
and the loadUpToBatch invocation) and branch on maxNumber being undefined to
skip upper-bound checks and render a suitable placeholder text.

In `@src/screens/novel/components/NovelScreenList.tsx`:
- Line 481: Remove the temporary render-time debug log that violates no-console:
delete the console.log('rendering chapter', fetching, chapters.length) statement
inside the NovelScreenList component (the render function where variables
fetching and chapters are referenced) or replace it with a conditional debug
logger tied to environment/config; ensure no direct console.log calls remain in
NovelScreenList.

In `@src/screens/novel/NovelContext.tsx`:
- Line 46: Remove the debug timing statements in NovelContext.tsx — delete
console.time('novel contex') and all other
console.time*/console.timeLog*/console.timeEnd* occurrences (notably the groups
around the earlier suggested lines) and, if timing is needed, replace them with
calls to the app's logging utility or a debug logger that respects NODE_ENV so
these traces are disabled in production; locate the statements by searching for
console.time, console.timeLog and console.timeEnd within the NovelContext
component and update uses accordingly.
- Line 4: Remove the unused imports from NovelContext.tsx: delete useEffect,
useLiveQuery, and useLiveQueryAsync from the import list so only actually used
hooks remain; specifically update the import statement that currently includes
useEffect and the live query hooks (look for identifiers useEffect,
useLiveQuery, useLiveQueryAsync) and remove them to eliminate unused-import
warnings.

In `@src/screens/novel/NovelScreen.tsx`:
- Around line 1-8: Remove the temporary debug code in NovelScreen.tsx: delete
the unused import useSyncExternalStore from the React import list and remove the
render-time console.log calls around lines 44-46 (the inline debug logs in the
NovelScreen component) so the component no longer violates ESLint rules; adjust
the import statement to only include the actually used hooks (Suspense,
useCallback, useMemo, useRef, useState) to keep imports clean.

In `@src/screens/reader/hooks/__tests__/useChapter.test.ts`:
- Around line 27-32: The test currently mocks the wrong hook (useNovelContext)
and injects { novelStore: store }; instead mock useNovelActions from
`@screens/novel/NovelContext` and have those mocks return the actual action
methods the hook expects (the same named functions you destructure from
useNovelActions in the hook under test) instead of a novelStore object; locate
the mock definitions currently returning { novelStore: store } and replace them
so useNovelActions: () => ({ /* implement the action methods used in the hook
(same names as destructured) as jest.fn() or pointing to your test stubs */ })
so the hook receives the expected API.

---

Outside diff comments:
In `@src/screens/novel/NovelScreen.tsx`:
- Around line 40-56: Update each Jest mock module that currently exports
useNovelContext to instead export two functions: useNovelValue and
useNovelActions; implement useNovelValue as a function that accepts a key and
returns the corresponding mock value (e.g., return mockValue(key)) and implement
useNovelActions as a function that returns the mock actions object (e.g., return
mockActions()); ensure the exported signatures match the real NovelContext hooks
used in production (useNovelValue(key) and useNovelActions()) and replace any
references to useNovelContext in tests with these new exports so tests no longer
attempt to call a non-existent function.

---

Nitpick comments:
In `@src/hooks/persisted/useNovelSettings.ts`:
- Around line 37-48: The setChapterSort callback can capture stale values
because it depends on novelSettings?.showChapterTitles and _filter while
novelSettings is a new object on each update; update the dependency array to
include novelSettings (the whole object) and _filter so the hook re-creates when
settings change, and keep using novelSettings?.showChapterTitles inside the body
when calling writeNovelSettings; ensure the dependency list reads [novel,
writeNovelSettings, novelSettings, _filter] targeting the setChapterSort
function.

In `@src/screens/novel/NovelContext.tsx`:
- Around line 157-163: layoutValue is memoized with an empty dependency array
but reads navigationBarHeightRef.current and statusBarHeightRef.current, so it
won't update when those refs change; either include the values that drive
layoutValue in the dependency list (e.g. navigationBarHeightRef.current and
statusBarHeightRef.current) or convert the refs into state and update that state
when insets change, then memoize using that state; update the code around
layoutValue, navigationBarHeightRef, statusBarHeightRef and the useMemo call
accordingly so layoutValue reflects changes.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 89fa0555-ac25-49a7-a7ad-d21e07621f74

📥 Commits

Reviewing files that changed from the base of the PR and between 3a4f569 and 29de6d4.

📒 Files selected for processing (22)
  • src/database/manager/manager.ts
  • src/database/queries/NovelQueries.ts
  • src/hooks/__tests__/useNovelStore.test.ts
  • src/hooks/persisted/useNovel/__tests__/novelStore.chapterActions.test.ts
  • src/hooks/persisted/useNovel/__tests__/novelStore.chapterState.test.ts
  • src/hooks/persisted/useNovel/bootstrapService.ts
  • src/hooks/persisted/useNovel/novelStore.actions.ts
  • src/hooks/persisted/useNovel/novelStore.chapterActions.ts
  • src/hooks/persisted/useNovel/novelStore.chapterState.ts
  • src/hooks/persisted/useNovel/novelStore.ts
  • src/hooks/persisted/useNovel/novelStore.types.ts
  • src/hooks/persisted/useNovelSettings.ts
  • src/screens/novel/NovelContext.tsx
  • src/screens/novel/NovelScreen.tsx
  • src/screens/novel/components/Info/NovelInfoHeader.tsx
  • src/screens/novel/components/JumpToChapterModal.tsx
  • src/screens/novel/components/NovelScreenList.tsx
  • src/screens/reader/components/ChapterDrawer/index.tsx
  • src/screens/reader/components/ReaderAppbar.tsx
  • src/screens/reader/components/ReaderFooter.tsx
  • src/screens/reader/hooks/__tests__/useChapter.test.ts
  • src/screens/reader/hooks/useChapter.ts
✅ Files skipped from review due to trivial changes (2)
  • src/hooks/tests/useNovelStore.test.ts
  • src/hooks/persisted/useNovel/novelStore.types.ts
🚧 Files skipped from review as they are similar to previous changes (3)
  • src/screens/reader/components/ChapterDrawer/index.tsx
  • src/screens/reader/hooks/useChapter.ts
  • src/screens/novel/components/Info/NovelInfoHeader.tsx

Comment on lines +146 to +149
const [data, setData] = useState<ReturnValue>(() => {
const r = db.executeSync(sqlString, params as any[]).rows as ReturnValue;
if (callback) callback(r);
return r;
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

# First, find and read the file
wc -l src/database/manager/manager.ts

Repository: CD-Z/lnreader

Length of output: 92


🏁 Script executed:

# Read the file around the mentioned line ranges
cat -n src/database/manager/manager.ts | sed -n '140,200p'

Repository: CD-Z/lnreader

Length of output: 2251


🏁 Script executed:

cat -n src/database/manager/manager.ts | sed -n '135,170p'

Repository: CD-Z/lnreader

Length of output: 1346


🏁 Script executed:

cat -n src/database/manager/manager.ts | sed -n '168,202p'

Repository: CD-Z/lnreader

Length of output: 1293


Make callback delivery lifecycle-safe.

The callback paths have two React-specific correctness issues: the initial calls are triggered from render (useState initializers on lines 146-149 and 179-185), and later reactive updates keep stale callback closures because callback is excluded from both dependency arrays (lines 152-164 and 187-199) with exhaustive-deps suppressed. This means callbacks can run before the component commits, still fire after an abandoned/unmounted async render, or invoke stale logic on later updates. Keep the latest callback in a useRef and invoke it only from committed effects/subscriptions.

Minimal pattern
-import { useEffect, useState } from 'react';
+import { useEffect, useRef, useState } from 'react';

 export function useLiveQuery<T extends ExecutableSelect>(
   query: T,
   fireOn: FireOn,
   callback?: (data: Awaited<ReturnType<T['all']>>) => void,
 ) {
   type ReturnValue = Awaited<ReturnType<T['all']>>;
+  const callbackRef = useRef(callback);
+
+  useEffect(() => {
+    callbackRef.current = callback;
+  }, [callback]);

-  const [data, setData] = useState<ReturnValue>(() => {
-    const r = db.executeSync(sqlString, params as any[]).rows as ReturnValue;
-    if (callback) callback(r);
-    return r;
-  });
+  const [data, setData] = useState<ReturnValue>(
+    () => db.executeSync(sqlString, params as any[]).rows as ReturnValue,
+  );
+
+  useEffect(() => {
+    callbackRef.current?.(data);
+  }, []);

   useEffect(() => {
     const unsub = db.reactiveExecute({
       query: sqlString,
       arguments: params as any[],
       fireOn,
       callback: (result: { rows: ReturnValue }) => {
         setData(result.rows);
-        if (callback) callback(result.rows);
+        callbackRef.current?.(result.rows);
       },
     });

Apply the same callbackRef.current pattern to useLiveQueryAsync, and move the initial async callback into an effect with cancellation so it can't fire after unmount.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/database/manager/manager.ts` around lines 146 - 149, The current code
calls the provided callback directly from the useState initializer and from
effects with callback excluded from deps, causing render-time invocation and
stale/unsafe closures; fix this by storing the latest callback in a ref (e.g.
callbackRef.current) and updating it inside an effect, remove direct calls to
callback from the useState initializer (do not call callback from the
db.executeSync initialiser), and invoke callbackRef.current only from committed
effects/subscriptions (and from the live query subscription handlers) so calls
are cancelled on unmount; apply the same pattern to useLiveQueryAsync by moving
the initial async callback into an effect with cancellation and using
callbackRef.current for all subsequent invocations to avoid stale closures.

Comment on lines 85 to 89
export const getNovelById = (novelId: number): NovelInfo | undefined => {
return dbManager.getSync(
dbManager.select().from(novelSchema).where(eq(novelSchema.id, novelId)),
);
};
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Don't silently flip this shared query to sync.

await getNovelById() will still "work", but this change turns a previously async lookup into a blocking DB read. src/services/download/downloadChapter.ts:80 and the query tests still consume it as async, while src/screens/novel/NovelContext.tsx:81 now runs it on the render path. Please keep the async contract or split this into an explicit getNovelByIdSync helper.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/database/queries/NovelQueries.ts` around lines 85 - 89, The change made
getNovelById into a synchronous/blocking call by using dbManager.getSync;
restore the original async contract or add an explicit sync helper: revert
getNovelById to return a Promise (use the async/dbManager.get or the async API
around dbManager.select().from(novelSchema).where(eq(novelSchema.id, novelId)))
so existing await callers (e.g., downloadChapter.ts and tests) remain async, and
if a sync variant is needed add a new function named getNovelByIdSync that uses
dbManager.getSync; make sure to update or leave callers unchanged depending on
which route you pick.

@@ -0,0 +1,219 @@
import { createChapterTextCache } from '../novelStore.chapterState';
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Import the cache factory from the module that actually exports it.

../novelStore.chapterState only exports createInitialChapterSlice in the current diff, so createChapterTextCache() is undefined and every case aborts before reaching its assertions.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/hooks/persisted/useNovel/__tests__/novelStore.chapterState.test.ts` at
line 1, The test imports createChapterTextCache from the wrong module
(novelStore.chapterState) which only exports createInitialChapterSlice; update
the import to pull createChapterTextCache from the module that actually exports
it (the module that defines the cache factory) so createChapterTextCache is not
undefined and tests can run; keep the existing import for
createInitialChapterSlice as-is if needed and replace only the source module for
createChapterTextCache.

@CD-Z CD-Z force-pushed the master branch 2 times, most recently from 2547835 to 06f0f66 Compare April 6, 2026 12:01
@CD-Z CD-Z force-pushed the useNovel-Refactor branch from 29de6d4 to db25722 Compare April 10, 2026 20:47
Copy link
Copy Markdown

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 14

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (3)
__mocks__/react-native-nitro-modules.js (1)

2-30: ⚠️ Potential issue | 🔴 Critical

Fix the broken mock module declaration.

The mock is syntactically invalid at Line 9, so Jest cannot parse this file and the RN test suites never start. Keep a single export shape here and close the object correctly.

Suggested fix
-jest.mock('react-native-nitro-modules', () => ({
-  __esModule: true,
-  default: {
-    createHybridObject: jest.fn(() => {
-      // Return a mock object that won't be used since MMKV has its own mock
-      return {};
-    }),
-  ];
-
-module.exports = {
+module.exports = {
+  __esModule: true,
+  default: {
+    createHybridObject: jest.fn(() => {
+      // Return a mock object that won't be used since MMKV has its own mock
+      return {};
+    }),
+  },
   MMKV,
   useMMKVString: createTupleHook(key => strings.get(key)),
   useMMKVNumber: createTupleHook(key => numbers.get(key)),
   useMMKVBoolean: createTupleHook(key => booleans.get(key)),
   useMMKVObject: createTupleHook(key => {
@@
   NitroModules: {
     createHybridObject: jest.fn(() => ({})),
   },
-}));
+};
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@__mocks__/react-native-nitro-modules.js` around lines 2 - 30, The mock file
has a broken object/array syntax around the jest.mock export
(createHybridObject) causing parse errors; fix by making a single consistent
export object: ensure the jest.mock call returns an object with __esModule and
default properties closed properly (remove the stray bracket/semicolon after
createHybridObject), and then export the MMKV,
useMMKVString/useMMKVNumber/useMMKVBoolean/useMMKVObject helpers and the
NitroModules.createHybridObject mock as part of one module.exports value rather
than duplicating exports; locate the createHybridObject mock and
MMKV/NitroModules declarations and correct the braces/brackets so the object is
syntactically valid.
src/hooks/persisted/__mocks__/useNovel.ts (1)

48-61: ⚠️ Potential issue | 🟡 Minor

Keep this mock aligned with the current novel-store contract.

batchInformation.totalChapters is still missing, and the promise-returning actions here now resolve to undefined immediately because they are plain jest.fn() values. That keeps tests from exercising the same shape and async behavior as production.

Possible fix
   batchInformation: {
     batch: 1,
     total: 1,
+    totalChapters: 0,
   },
   chapterTextCache: createChapterTextCache(),
   bootstrapNovel: jest.fn().mockResolvedValue(true),
   getChapters: jest.fn().mockResolvedValue(undefined),
-  getNextChapterBatch: jest.fn(),
-  loadUpToBatch: jest.fn(),
+  getNextChapterBatch: jest.fn().mockResolvedValue(undefined),
+  loadUpToBatch: jest.fn().mockResolvedValue(undefined),
   refreshNovel: jest.fn().mockResolvedValue(undefined),
   getNovel: jest.fn().mockResolvedValue(mockNovel),
   setPages: jest.fn(),
   setPageIndex: jest.fn(),
-  openPage: jest.fn(),
+  openPage: jest.fn().mockResolvedValue(undefined),
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/hooks/persisted/__mocks__/useNovel.ts` around lines 48 - 61, The mock is
out-of-date: add batchInformation.totalChapters to match the novel-store
contract and convert any functions that should return promises into
jest.fn().mockResolvedValue(...) so tests observe async behavior; specifically
update batchInformation to include totalChapters, and replace plain jest.fn()
for
bootstrapNovel/getChapters/getNextChapterBatch/loadUpToBatch/refreshNovel/getNovel
(and any other promise-returning helpers) with jest.fn().mockResolvedValue(...)
or jest.fn().mockResolvedValue(mockValue) as appropriate so the mock shape and
async responses mirror production.
src/hooks/persisted/useNovelSettings.ts (1)

60-89: ⚠️ Potential issue | 🟠 Major

Use useMemo to recreate the filter manager when the filter or its setter changes.

The useRef initialization freezes filterManager.current to a single instance. When writeNovelSettings() updates _filter from the context, the four callback helpers (lines 64-89) continue operating on a stale manager instance instead of reflecting the current filter state. This causes mutations via cycle() and set() to operate on outdated data.

The provided fix is correct: replace useRef with useMemo([_filter, setChapterFilter]), update all filterManager.current?.method() calls to filterManager.method(), and include [filterManager] in the dependency arrays of the callbacks. This ensures the manager is recreated whenever external state changes and the eslint-disable-next-line comments become unnecessary.

Suggested fix
-import { useCallback, useMemo, useRef } from 'react';
+import { useCallback, useMemo } from 'react';
...
-  const filterManager = useRef<ChapterFilterObject>(
-    new ChapterFilterObject(_filter, setChapterFilter),
-  );
+  const filterManager = useMemo(
+    () => new ChapterFilterObject(_filter, setChapterFilter),
+    [_filter, setChapterFilter],
+  );

   const cycleChapterFilter = useCallback(
     (key: ChapterFilterPositiveKey) => {
-      filterManager.current?.cycle(key);
-    }, // eslint-disable-next-line react-hooks/exhaustive-deps
-    [_filter],
+      filterManager.cycle(key);
+    },
+    [filterManager],
   );

   const setChapterFilterValue = useCallback(
     (key: ChapterFilterPositiveKey, value: keyof FilterStates) => {
-      filterManager.current?.set(key, value);
-    }, // eslint-disable-next-line react-hooks/exhaustive-deps
-    [_filter],
+      filterManager.set(key, value);
+    },
+    [filterManager],
   );

   const getChapterFilterState = useCallback(
     (key: ChapterFilterPositiveKey) => {
-      return filterManager.current?.state(key) ?? false;
-    }, // eslint-disable-next-line react-hooks/exhaustive-deps
-    [_filter],
+      return filterManager.state(key) ?? false;
+    },
+    [filterManager],
   );

   const getChapterFilter = useCallback(
-    (key: ChapterFilterPositiveKey) => filterManager.current?.get(key),
-    // eslint-disable-next-line react-hooks/exhaustive-deps
-    [_filter],
+    (key: ChapterFilterPositiveKey) => filterManager.get(key),
+    [filterManager],
   );
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/hooks/persisted/useNovelSettings.ts` around lines 60 - 89, The filter
manager is stuck as a single stale instance because useRef was used; replace the
useRef<ChapterFilterObject>(new ChapterFilterObject(_filter, setChapterFilter))
with useMemo(() => new ChapterFilterObject(_filter, setChapterFilter), [_filter,
setChapterFilter]) so the ChapterFilterObject is recreated when the external
_filter or setChapterFilter changes, then update all call sites to use
filterManager.method(...) instead of filterManager.current?.method(...) and
change the callback dependency arrays for cycleChapterFilter,
setChapterFilterValue, getChapterFilterState, and getChapterFilter to include
filterManager (remove the eslint-disable-next-line comments afterwards).
♻️ Duplicate comments (17)
src/services/updates/LibraryUpdateQueries.ts (1)

197-197: ⚠️ Potential issue | 🟠 Major

Remove debug console output from the update path.

console.log on Line 197 is still violating no-console and will keep ESLint failing. Please remove it (or swap to a dev-gated logger).

Proposed fix
-  console.log(novel.chapters);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/services/updates/LibraryUpdateQueries.ts` at line 197, Remove the debug
console output: delete the console.log(novel.chapters) call in
LibraryUpdateQueries (the update path handling where novel.chapters is being
printed) or replace it with the project logger behind a dev-only gate (e.g.,
process.env.NODE_ENV check) so ESLint no-console no longer fails; ensure any
retained logging uses the existing logging utility used elsewhere in this module
instead of console.log.
src/screens/novel/NovelScreen.tsx (1)

37-39: ⚠️ Potential issue | 🟡 Minor

Remove the render-time debug log.

This still trips no-console, and because it runs during render it will log on every store-driven re-render of the screen.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/screens/novel/NovelScreen.tsx` around lines 37 - 39, Remove the
render-time console.log by deleting the console.log('Novel render', novel?.name,
chapters.length) call in the NovelScreen component; if you need persistent
debugging, move the log into an effect such as useEffect that watches
useNovelValue('novel') and useNovelValue('chapters') or replace with a proper
logger obeying no-console rules, referencing the useNovelValue hook and the
novel/chapters variables.
src/hooks/__tests__/useNovel.test.ts (1)

37-93: ⚠️ Potential issue | 🟡 Minor

Seed the legacy keys you assert on.

This test now verifies deletion of novelPersistence.keys.settings() and novelPersistence.keys.lastRead(), but the setup still only writes the keyContract.* variants. As written, those assertions stay green even if the legacy-key cleanup path stops working.

Suggested fix
       MMKVStorage.set(
         keyContract.settings({
           pluginId: novel.pluginId,
           novelPath: novel.path,
         }),
         JSON.stringify({ filter: [], showChapterTitles: true }),
       );
+      MMKVStorage.set(
+        novelPersistence.keys.settings({
+          pluginId: novel.pluginId,
+          novelPath: novel.path,
+        }),
+        JSON.stringify({ filter: [], showChapterTitles: true }),
+      );
       MMKVStorage.set(
         keyContract.lastRead({
           pluginId: novel.pluginId,
           novelPath: novel.path,
         }),
         JSON.stringify({ id: 1 }),
       );
+      MMKVStorage.set(
+        novelPersistence.keys.lastRead({
+          pluginId: novel.pluginId,
+          novelPath: novel.path,
+        }),
+        JSON.stringify({ id: 1 }),
+      );
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/hooks/__tests__/useNovel.test.ts` around lines 37 - 93, The test seeds
only the keyContract.* variants but asserts that legacy keys
(novelPersistence.keys.settings and novelPersistence.keys.lastRead) are removed;
modify the setup loop in the test so that for each novel you also write entries
using novelPersistence.keys.settings({pluginId: novel.pluginId, novelPath:
novel.path}) and novelPersistence.keys.lastRead({pluginId: novel.pluginId,
novelPath: novel.path}) (e.g. via MMKVStorage.set with the same payloads you
currently use for keyContract.pageIndex/settings/lastRead) so the assertions
actually validate legacy-key cleanup.
src/services/plugin/fetch.ts (1)

48-53: ⚠️ Potential issue | 🟡 Minor

Differentiate unsupported pagination from a novel fetch failure.

For page !== '1' without parsePage(), this still throws the generic novel error. That makes logs and caller handling much less actionable than an explicit "paged fetch unsupported" failure.

Suggested fix
-  if (plugin.parseNovel && page === '1') {
-    const res = await plugin.parseNovel(novelPath);
-    if (res.chapters) return { chapters: res.chapters };
-    throw new Error(`Could not fetch chapters for novel ${novelPath}`);
-  }
-  throw new Error(`Could not fetch chapters for novel ${novelPath}`);
+  if (page !== '1') {
+    throw new Error(
+      `Plugin ${pluginId} cannot fetch chapters for page ${page} of ${novelPath} without parsePage()`,
+    );
+  }
+
+  const res = await plugin.parseNovel(novelPath);
+  if (res.chapters) return { chapters: res.chapters };
+  throw new Error(`Could not fetch chapters for novel ${novelPath}`);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/services/plugin/fetch.ts` around lines 48 - 53, The current block always
throws the generic novel fetch error even when the caller requested a page
beyond '1' but the plugin doesn't implement pagination; update the logic in the
fetch routine handling plugin.parseNovel so that if page !== '1' and
plugin.parsePage is not provided you throw a distinct "paged fetch unsupported"
(or similar) error, otherwise preserve the existing behavior: call
plugin.parseNovel(novelPath) when page === '1', return { chapters: res.chapters
} if present, and if parseNovel returns no chapters throw the existing "Could
not fetch chapters for novel ..." error; reference plugin.parseNovel,
plugin.parsePage, novelPath and page to locate and implement this conditional
change.
src/screens/novel/components/NovelScreenList.tsx (1)

481-481: ⚠️ Potential issue | 🟡 Minor

Remove the render-time debug log.

This runs on every list render and still fails no-console.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/screens/novel/components/NovelScreenList.tsx` at line 481, Remove the
runtime debug console output in NovelScreenList.tsx: delete the
console.log('rendering chapter', fetching, chapters.length) call (or replace it
with a conditional/debug logger that respects the app's logging mechanism)
inside the component that renders the chapter list so the render path no longer
violates the no-console rule; locate the statement in the component where
chapters and fetching are available (the render function or JSX body) and remove
or guard it.
src/hooks/__tests__/mocks.ts (3)

125-127: ⚠️ Potential issue | 🟠 Major

Export the selector hooks from the shared NovelContext mock too.

src/hooks/persisted/useNovelSettings.ts now imports useNovelValue and useNovelAction, and src/screens/novel/components/NovelScreenList.tsx imports useNovelValue and useNovelActions. With only useNovelContext mocked here, tests that bootstrap this shared helper will still crash on those newer imports.

Possible fix
 jest.mock('@screens/novel/NovelContext', () => ({
   useNovelContext: () => mockUseNovelContext(),
+  useNovelValue: (key: string) =>
+    mockUseNovelContext().novelStore.getState()[key],
+  useNovelAction: (key: string) =>
+    mockUseNovelContext().novelStore.getState()[key],
+  useNovelActions: () => {
+    const state = mockUseNovelContext().novelStore.getState();
+    return {
+      deleteChapter: state.deleteChapter,
+      setNovel: state.setNovel,
+      getNextChapterBatch: state.getNextChapterBatch,
+      openPage: state.openPage,
+      updateChapter: state.updateChapter,
+      refreshNovel: state.refreshNovel,
+    };
+  },
 }));
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/hooks/__tests__/mocks.ts` around lines 125 - 127, The shared NovelContext
mock only exports useNovelContext; extend that jest.mock call to also export the
selector hooks used across the codebase—specifically add exports for
useNovelValue, useNovelAction and useNovelActions (matching the actual import
names used by useNovelSettings.ts and NovelScreenList.tsx) and have them
delegate to the corresponding test helpers (e.g., mockUseNovelValue,
mockUseNovelAction, mockUseNovelActions) so tests importing those hooks don't
crash.

98-123: ⚠️ Potential issue | 🟠 Major

Make the mock store notify subscribers and stop reusing one singleton instance.

Selector hooks rely on the external-store contract. Right now setState() never notifies listeners, subscribe() is inert, and defaultMockNovelContext shares one mutable store across tests.

Possible fix
 export const createMockNovelStore = (
   stateOverrides: Record<string, unknown> = {},
 ) => {
   let state = createMockNovelStoreState(stateOverrides);
+  const listeners = new Set<() => void>();

   return {
     getState: jest.fn(() => state),
     setState: jest.fn(nextState => {
       const partial =
         typeof nextState === 'function' ? nextState(state) : nextState;
       state = {
         ...state,
         ...partial,
       };
+      listeners.forEach(listener => listener());
     }),
-    subscribe: jest.fn(() => () => {}),
+    subscribe: jest.fn(listener => {
+      listeners.add(listener);
+      return () => listeners.delete(listener);
+    }),
   };
 };
 
-const defaultMockNovelContext = {
-  novelStore: createMockNovelStore(),
-  navigationBarHeight: 0,
-  statusBarHeight: 0,
-};
-
-export const mockUseNovelContext = jest.fn(() => defaultMockNovelContext);
+const createDefaultMockNovelContext = () => ({
+  novelStore: createMockNovelStore(),
+  navigationBarHeight: 0,
+  statusBarHeight: 0,
+});
+
+export const mockUseNovelContext = jest.fn(() =>
+  createDefaultMockNovelContext(),
+);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/hooks/__tests__/mocks.ts` around lines 98 - 123, The mock store currently
never notifies listeners and defaultMockNovelContext reuses a single mutable
store across tests; update createMockNovelStore to maintain a subscribers
Set/array, have subscribe(fn) add the listener and return an unsubscribe
function that removes it, and call all subscribers with the new state inside
setState (handle functional and object updates as currently done); also stop
sharing one singleton by making mockUseNovelContext return a fresh context
(create a new novelStore via createMockNovelStore() each call) instead of using
defaultMockNovelContext so each test gets an isolated store.

56-59: ⚠️ Potential issue | 🟡 Minor

Include totalChapters in the shared batch mock.

batchInformation.totalChapters is now read by the novel screen, so leaving it out here keeps test state behind the production shape.

Possible fix
   batchInformation: {
     batch: 0,
     total: 0,
+    totalChapters: 0,
   },
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/hooks/__tests__/mocks.ts` around lines 56 - 59, The shared test mock's
batchInformation object is missing the totalChapters property used by the novel
screen; update the mock in src/hooks/__tests__/mocks.ts by adding totalChapters
(e.g., totalChapters: 0) to the batchInformation object so the test shape
matches production and prevents missing-property errors when code reads
batchInformation.totalChapters.
src/hooks/persisted/useNovel/__tests__/persistence.test.ts (1)

167-173: ⚠️ Potential issue | 🟡 Minor

Rename this test so it describes a runtime export check, not compile-time safety.

Destructuring from require() here only proves that the exports exist at runtime. It does not give the same compile-time guarantees as a typed import, so the current title overstates the protection this test provides.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/hooks/persisted/useNovel/__tests__/persistence.test.ts` around lines 167
- 173, Rename the test title to reflect that it checks runtime exports rather
than compile-time safety: update the it(...) description currently asserting
typeof novelPersistence.copySettings, novelPersistence.readLastRead, and
migrateNovel to something like "verifies runtime exports exist for migrateNovel
and novelPersistence functions"; keep the same body that requires
'@services/migrate/migrateNovel' and asserts typeof for
novelPersistence.copySettings, novelPersistence.readLastRead, and migrateNovel
so the test intent matches the actual runtime-only check.
src/screens/novel/components/__tests__/NovelScreenList.test.tsx (1)

9-11: ⚠️ Potential issue | 🟠 Major

Update the NovelContext mock to export the selector hooks this component now uses.

NovelScreenList no longer reads everything from useNovelContext(). With only that one export mocked here, the test will still fail on useNovelValue / useNovelActions before it reaches any assertions.

Possible fix
 jest.mock('../../NovelContext', () => ({
   useNovelContext: () => mockUseNovelContext(),
+  useNovelValue: (key: string) =>
+    mockUseNovelContext().novelStore.getState()[key],
+  useNovelActions: () => {
+    const state = mockUseNovelContext().novelStore.getState();
+    return {
+      deleteChapter: state.deleteChapter,
+      setNovel: state.setNovel,
+      getNextChapterBatch: state.getNextChapterBatch,
+      openPage: state.openPage,
+      updateChapter: state.updateChapter,
+      refreshNovel: state.refreshNovel,
+    };
+  },
 }));
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/screens/novel/components/__tests__/NovelScreenList.test.tsx` around lines
9 - 11, The test mock for NovelContext currently only exports useNovelContext
which causes missing exports for the selector hooks; update the jest.mock to
also export useNovelValue and useNovelActions (e.g., map them to
mockUseNovelValue and mockUseNovelActions or return appropriate stub
implementations) so NovelScreenList can import those hooks during the test;
ensure the exported names exactly match useNovelValue and useNovelActions and
keep useNovelContext mapped to mockUseNovelContext for compatibility.
src/hooks/persisted/useNovel/store-helper/bootstrapService.ts (4)

172-184: ⚠️ Potential issue | 🟠 Major

Don't turn loader failures into empty successful states.

The catch blocks only console.error and continue with [], so a DB/query failure is surfaced as a successful bootstrap or batch load with missing chapters. Let the error bubble or return an explicit failure result.

🛠️ Proposed fix
     let newChapters: ChapterInfo[] = [];
-    try {
-      newChapters =
-        (await getPageChaptersBatched(
-          novel.id,
-          settingsSort,
-          settingsFilter,
-          page,
-          nextBatch,
-        )) || [];
-    } catch (error) {
-      console.error('teaser', error);
-    }
+    newChapters =
+      (await getPageChaptersBatched(
+        novel.id,
+        settingsSort,
+        settingsFilter,
+        page,
+        nextBatch,
+      )) || [];

Also applies to lines 223-235.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/hooks/persisted/useNovel/store-helper/bootstrapService.ts` around lines
172 - 184, The catch around getPageChaptersBatched swallows errors and returns
an empty array (newChapters), turning loader failures into successful empty
states; instead either rethrow the caught error or return an explicit failure
result from the enclosing function (do not replace with []), so update the
try/catch in bootstrapService (the block that calls getPageChaptersBatched and
the similar block at lines 223-235) to propagate the error (throw error) or
return a Result/Error object that upstream code can handle rather than assigning
[] to newChapters.

54-57: ⚠️ Potential issue | 🟠 Major

Scope the inflight cache to the full bootstrap request.

The promise cache is keyed only by pluginId + novelPath, but the returned chapter state also depends on pageIndex, settingsSort, and settingsFilter. Two concurrent bootstraps for the same novel with different reader settings can reuse the wrong promise. Additionally, the module-level Map leaks dedupe across separate createBootstrapService() instances.

🛠️ Proposed fix
-const inflightBootstraps = new Map<string, Promise<BootstrapResult>>();
-
-const getBootstrapKey = (pluginId: string, novelPath: string) =>
-  `${pluginId}_${novelPath}`;
-
 export const createBootstrapService = () => {
+  const inflightBootstraps = new Map<string, Promise<BootstrapResult>>();
+
+  const getBootstrapKey = ({
+    pluginId,
+    novelPath,
+    pageIndex,
+    settingsSort,
+    settingsFilter,
+  }: {
+    pluginId: string;
+    novelPath: string;
+    pageIndex: number;
+    settingsSort: ChapterOrderKey;
+    settingsFilter: ChapterFilterKey[];
+  }) =>
+    JSON.stringify([pluginId, novelPath, pageIndex, settingsSort, settingsFilter]);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/hooks/persisted/useNovel/store-helper/bootstrapService.ts` around lines
54 - 57, The inflight cache Map (inflightBootstraps) is too coarse and
module-scoped causing wrong promise reuse and cross-instance leakage; update
getBootstrapKey to include pageIndex, settingsSort, and settingsFilter (e.g.,
`${pluginId}_${novelPath}_${pageIndex}_${settingsSort}_${settingsFilter}`) and
move the inflightBootstraps Map into the createBootstrapService() factory so
each service instance has its own cache; adjust any callers of getBootstrapKey/
inflightBootstraps within bootstrapService.ts to use the expanded key and the
instance-scoped Map to ensure dedupe is per-full-request and per-service
instance.

137-141: ⚠️ Potential issue | 🟡 Minor

Fix the zero-based batch total calculation.

Batch 0 is already loaded, so Math.floor(chapterCount / 1000) overstates total for exact multiples of 1000 and allows one phantom follow-up fetch. Compute the last zero-based batch index instead.

🛠️ Proposed fix
     const batchInformation: BatchInfo = {
       batch: 0,
-      total: Math.floor(chapterCount / 1000),
+      total: Math.max(0, Math.ceil(chapterCount / 1000) - 1),
       totalChapters: chapterCount,
     };

Also applies to line 353.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/hooks/persisted/useNovel/store-helper/bootstrapService.ts` around lines
137 - 141, The batch total is computed as a 1-based count but stored as a
zero-based last index; update the BatchInfo initialization (batchInformation) to
set total to the last zero-based batch index by using Math.max(0,
Math.ceil(chapterCount / 1000) - 1) (instead of Math.floor(chapterCount /
1000)), and make the same change for the second occurrence in this file (the
other total assignment around the later batch computation).

112-123: ⚠️ Potential issue | 🟠 Major

Replace console statements with proper logging to fix pipeline failures.

Multiple console.time, console.error, and console.timeEnd statements are causing ESLint no-console failures. Either remove debug statements or use an application logger.

🛠️ Proposed fix - remove debug timing and error swallowing
   const getChaptersForPage = async ({
     ...
   }): Promise<ChapterLoadResult> => {
-    console.time('getChaptersForPage');
     const page = pages[pageIndex] ?? '1';
     let newChapters: ChapterInfo[] = [];
     const config = [novel.id, settingsSort, settingsFilter, page] as const;

     let chapterCount = await getChapterCount(novel.id, page);
     if (chapterCount) {
-      try {
-        newChapters = (await getPageChaptersBatched(...config)) || [];
-      } catch (error) {
-        console.error('Error fetching chapters:', error);
-      }
+      newChapters = (await getPageChaptersBatched(...config)) || [];
     } else if (Number(page)) {

Apply similar changes to lines 143, 183, 234, 256, and 304.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/hooks/persisted/useNovel/store-helper/bootstrapService.ts` around lines
112 - 123, The code uses console.time/console.timeEnd and console.error inside
the chapter-fetching block (around getChapterCount and getPageChaptersBatched)
which triggers ESLint no-console; replace these with the application logger
(e.g., logger or processLogger used in the project) and include the error object
in the log call instead of swallowing it; remove timing calls or convert them to
logger.debug/trace if timing is needed; update the console.* occurrences in the
getChaptersForPage block and apply the same replacement pattern to the other
occurrences referenced (lines near the other blocks) so all console.time,
console.timeEnd, and console.error calls are replaced with logger.debug/trace
and logger.error respectively and preserve the error details when logging.
src/hooks/persisted/useNovel/store/chapterActions.ts (2)

188-208: ⚠️ Potential issue | 🟠 Major

State/persistence inconsistency: clamped value persisted but raw value stored in memory.

Line 194 persists Math.min(progress, 100), but line 204 stores the raw progress in local state. If progress > 100, the DB will have 100 while the UI shows the unclamped value.

🐛 Proposed fix: Clamp both values consistently
 export const updateChapterProgressAction = (
   chapterId: number,
   progress: number,
   mutateChapters: MutateChapters,
   deps: ChapterActionsDependencies = defaultChapterActionsDependencies,
 ) => {
-  deps.updateChapterProgress(chapterId, Math.min(progress, 100));
+  const clampedProgress = Math.max(0, Math.min(progress, 100));
+  deps.updateChapterProgress(chapterId, clampedProgress);

   mutateChapters(chs =>
     chs.map(c => {
       if (c.id !== chapterId) {
         return c;
       }

       return {
         ...c,
-        progress,
+        progress: clampedProgress,
       };
     }),
   );
 };
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/hooks/persisted/useNovel/store/chapterActions.ts` around lines 188 - 208,
The updateChapterProgressAction currently persists a clamped value but writes
the raw progress into memory; change the implementation so both persistence and
in-memory state use the same clamped value by computing a local const clamped =
Math.min(progress, 100) and passing clamped to
deps.updateChapterProgress(chapterId, clamped) and using clamped when updating
the chapter in mutateChapters (inside the map that returns {...c, progress}).
Ensure you only modify updateChapterProgressAction and keep MutateChapters/deps
usage the same.

210-263: ⚠️ Potential issue | 🟡 Minor

Missing error handling on delete operations.

deleteChapter and deleteChapters use .then() without .catch(). If the delete fails, the promise rejection goes unhandled and the UI state may become inconsistent (state not updated, but no error feedback).

🛡️ Proposed fix: Add error handling
 export const deleteChapterAction = (
   _chapter: ChapterInfo,
   novel: NovelInfo | undefined,
   mutateChapters: MutateChapters,
   deps: ChapterActionsDependencies = defaultChapterActionsDependencies,
 ) => {
   if (novel) {
-    deps.deleteChapter(novel.pluginId, novel.id, _chapter.id).then(() => {
+    deps.deleteChapter(novel.pluginId, novel.id, _chapter.id)
+      .then(() => {
         mutateChapters(chs =>
           chs.map(chapter => {
             if (chapter.id !== _chapter.id) {
               return chapter;
             }
             return { ...chapter, isDownloaded: false };
           }),
         );
         deps.showToast(deps.getString('common.deleted', { name: _chapter.name }));
-      });
+      })
+      .catch(() => {
+        deps.showToast(deps.getString('common.error'));
+      });
   }
 };

Apply the same pattern to deleteChaptersAction.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/hooks/persisted/useNovel/store/chapterActions.ts` around lines 210 - 263,
Both deleteChapterAction and deleteChaptersAction currently call
deps.deleteChapter / deps.deleteChapters with .then() but no .catch(), leaving
promise rejections unhandled; add a .catch(error => {
console.error('deleteChapter(s) failed', error);
deps.showToast(deps.getString('common.deleteFailed', { message: error?.message
|| String(error) })); }) to each promise chain (or convert to async/await with
try/catch) so errors are logged and the user gets feedback; keep the existing
mutateChapters updates inside the success path so state only changes on
successful deletion and include the unique symbols deleteChapterAction,
deleteChaptersAction, deps.deleteChapter, deps.deleteChapters, mutateChapters
and deps.showToast to locate where to add the handlers.
src/hooks/persisted/useNovel/store-helper/persistence.ts (1)

69-94: ⚠️ Potential issue | 🟡 Minor

Reject negative or fractional page indices.

Number.isFinite accepts values like -1 and 1.5, so the bridge can persist/read indices that cause pages[pageIndex] to resolve to undefined, silently falling back to page '1'. Normalize both legacy-string parsing and new writes to a non-negative integer.

🛠️ Proposed fix
   const readPageIndex = (input: NovelPersistenceInput): number => {
     const key = keys.pageIndex(input);
     const numberValue = storage.getNumber(key);

-    if (typeof numberValue === 'number' && Number.isFinite(numberValue)) {
+    if (
+      typeof numberValue === 'number' &&
+      Number.isInteger(numberValue) &&
+      numberValue >= 0
+    ) {
       return numberValue;
     }

     const stringValue = storage.getString(key);
     if (stringValue !== undefined) {
       const parsed = Number(stringValue);
-      if (Number.isFinite(parsed)) {
+      if (Number.isInteger(parsed) && parsed >= 0) {
         storage.set(key, parsed);
         return parsed;
       }
     }

     storage.set(key, defaultPageIndex);
     return defaultPageIndex;
   };

   const writePageIndex = (input: NovelPersistenceInput, value: number) => {
     const key = keys.pageIndex(input);
-    const safeValue = Number.isFinite(value) ? value : defaultPageIndex;
+    const safeValue =
+      Number.isInteger(value) && value >= 0 ? value : defaultPageIndex;
     storage.set(key, safeValue);
   };
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/hooks/persisted/useNovel/store-helper/persistence.ts` around lines 69 -
94, The readPageIndex and writePageIndex logic currently accepts negative and
fractional values because it only checks Number.isFinite; update both functions
(keys.pageIndex, readPageIndex, writePageIndex) to only accept non-negative
integers: replace the Number.isFinite checks with Number.isInteger(value) &&
value >= 0 for the numeric path (storage.getNumber), and when parsing
stringValue ensure parsed is an integer >= 0 before calling storage.set;
similarly compute safeValue in writePageIndex as value if
Number.isInteger(value) && value >= 0 else defaultPageIndex so only valid
non-negative integers are persisted.
🧹 Nitpick comments (8)
src/database/queries/ChapterQueries.ts (1)

453-454: Batch size increased from 300 to 1000.

This 3x increase in batch size will reduce the number of queries needed but increases memory usage per batch. Ensure this is acceptable for your target devices' memory constraints.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/database/queries/ChapterQueries.ts` around lines 453 - 454, The batch
size was changed from 300 to 1000 in ChapterQueries.ts (see the constants limit
and offset calculation), which may spike memory usage; either revert limit back
to 300 or make the batch size configurable (e.g., a BATCH_SIZE constant/env var)
and add validation or comments documenting expected memory impact and
recommended limits for target devices; update any callers or tests that assume
the old size and ensure offset calculation (offset = limit * batch) uses the
same variable name to remain consistent.
src/utils/mmkv/zustand-adapter.ts (2)

19-25: Replace console.error with a logging utility.

The console.error statements violate the no-console ESLint rule. Consider using the project's logging utility or conditionally logging in __DEV__ mode.

💡 Suggested approach
+import { __DEV__ } from '@utils/constants'; // or similar

 getItem: (key: string): string | null => {
   try {
     const value = MMKVStorage.getString(key);
     return value ?? null;
   } catch (error) {
-    console.error(
-      `[mmkvZustandAdapter] Error getting item for key "${key}":`,
-      error,
-    );
+    if (__DEV__) {
+      // eslint-disable-next-line no-console
+      console.error(`[mmkvZustandAdapter] Error getting item for key "${key}":`, error);
+    }
     return null;
   }
 },

Apply similar changes to setItem and removeItem.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/utils/mmkv/zustand-adapter.ts` around lines 19 - 25, Replace
console.error calls in mmkvZustandAdapter functions with the project's logging
utility (or guard logs behind __DEV__) to satisfy no-console linting: in
getItem, setItem, and removeItem replace the console.error usage with a call to
the logger (e.g., use the existing logger instance or import createLogger) or
wrap the console call in if (__DEV__) { ... } and include the key and error
details in the log message; ensure you import or reference the same logging
utility used elsewhere so all three functions (getItem, setItem, removeItem)
consistently log errors without using console directly.

32-41: Silent failures in storage operations may mask data loss.

While catching errors prevents crashes, silently swallowing setItem failures means the app may continue believing state is persisted when it isn't. Consider whether critical write failures should surface to the caller or trigger a user notification.

Also applies to: 46-55

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/utils/mmkv/zustand-adapter.ts` around lines 32 - 41, The current MMKV
adapter methods (e.g., setItem) swallow errors after logging, which can hide
write failures; update setItem and the corresponding methods referenced
(removeItem, and whichever method occupies lines ~46-55) to surface failures to
callers by either rethrowing the caught error after logging or returning a clear
failure result (e.g., boolean or error value) so callers can react; ensure the
method signatures (setItem, removeItem, getItem or similar) are updated
consistently and unit tests/usage sites handle the propagated error or returned
status.
src/screens/novel/components/ChapterItem.tsx (1)

101-106: Consider moving parseTime outside the component or memoizing it.

The function is recreated on every render. While cheap, moving it outside the component (since it has no dependencies on props/state) would be cleaner.

♻️ Suggested refactor
 import dayjs from 'dayjs';

+function parseTime(time?: string | Date | null) {
+  if (!time) return undefined;
+  const parsedTime = dayjs(time);
+  return parsedTime.isValid() ? parsedTime.format('LL') : (time as string);
+}
+
 const ChapterItem: React.FC<ChapterItemProps> = ({
   ...
 }) => {
   ...
-  function parseTime(time?: string | Date | null) {
-    if (!time) return undefined;
-    const parsedTime = dayjs(time);
-    return parsedTime.isValid() ? parsedTime.format('LL') : (time as string);
-  }
   const parsedTime = parseTime(releaseTime);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/screens/novel/components/ChapterItem.tsx` around lines 101 - 106, The
parseTime helper inside ChapterItem is recreated on every render; move the
parseTime(time?: string | Date | null) function declaration out of the component
scope (so it becomes a module-level helper) or, if you prefer to keep it inside,
memoize it with useCallback/useMemo tied to releaseTime; ensure parseTime still
uses dayjs and that parsedTime = parseTime(releaseTime) continues to work
unchanged in the component.
src/screens/novel/__tests__/NovelScreen.test.tsx (1)

54-58: Consider avoiding shadowed React variable in mock factories.

ESLint flags that React is redeclared inside the mock factory. While this works, you can avoid the warning by using a different variable name or restructuring.

♻️ Suggested approach
 jest.mock('../components/NovelAppbar', () => {
-  const React = require('react');
+  const { createElement } = require('react');
   const { Text } = require('react-native');
-  return () => React.createElement(Text, { testID: 'novel-appbar' }, 'appbar');
+  return () => createElement(Text, { testID: 'novel-appbar' }, 'appbar');
 });

Apply similarly to other mock factories at lines 61, 123, 150, 158, 183, 190, 196, and 207.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/screens/novel/__tests__/NovelScreen.test.tsx` around lines 54 - 58, The
mock factory for '../components/NovelAppbar' declares a shadowed React variable
which triggers ESLint; update the mock to avoid redeclaring React by either
importing React at top-level and using that name inside the factory or renaming
the local require to a different identifier (e.g., ReactLib or RN) so you don't
shadow the outer React symbol; apply the same change to the other mock factories
mentioned (the mocks for the other components in this test file) to remove all
shadowed React declarations.
src/hooks/persisted/useNovel.ts (1)

31-42: Redundant key removal: novelPersistence.keys.* delegates to keyContract.*.

Looking at persistence.ts (context snippet 2), novelPersistence.keys.pageIndex calls keyContract.pageIndex internally. Both generate the same key, so lines 35-37 and 39-41 remove the same three keys twice.

♻️ Proposed simplification
 const clearNovelPersistence = ({
   pluginId,
   novelPath,
 }: NovelPersistenceInput) => {
-  MMKVStorage.remove(keyContract.pageIndex({ pluginId, novelPath }));
-  MMKVStorage.remove(keyContract.settings({ pluginId, novelPath }));
-  MMKVStorage.remove(keyContract.lastRead({ pluginId, novelPath }));
-
-  MMKVStorage.remove(novelPersistence.keys.pageIndex({ pluginId, novelPath }));
-  MMKVStorage.remove(novelPersistence.keys.settings({ pluginId, novelPath }));
-  MMKVStorage.remove(novelPersistence.keys.lastRead({ pluginId, novelPath }));
+  const input = { pluginId, novelPath };
+  MMKVStorage.remove(novelPersistence.keys.pageIndex(input));
+  MMKVStorage.remove(novelPersistence.keys.settings(input));
+  MMKVStorage.remove(novelPersistence.keys.lastRead(input));
 };
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/hooks/persisted/useNovel.ts` around lines 31 - 42, The
clearNovelPersistence function is removing the same keys twice because
novelPersistence.keys.pageIndex/settings/lastRead delegate to
keyContract.pageIndex/settings/lastRead; remove the redundant calls by keeping
only one set of MMKVStorage.remove calls (either the keyContract.* calls or the
novelPersistence.keys.* calls) so each of the three keys (pageIndex, settings,
lastRead) is removed exactly once; update clearNovelPersistence to call
MMKVStorage.remove for either keyContract.pageIndex/settings/lastRead or for
novelPersistence.keys.pageIndex/settings/lastRead and delete the other trio of
remove calls.
src/plugins/dev/DevPagedNovelPlugin.ts (1)

252-363: Extract duplicated totalPages calculation logic.

Both parseNovel (lines 258-294) and parsePage (lines 318-354) contain identical logic for:

  1. Fetching currentChapters from the database
  2. Calculating totalPages based on settings

Since this is dev-only code the impact is lower, but extracting a shared helper would improve maintainability.

♻️ Suggested refactor
const getTotalPages = async (
  novelPath: string,
  storage: Storage,
): Promise<number> => {
  const newContent = storage.get('provideNewContent');
  const newPages = storage.get('provideNewPages');

  const res = await dbManager
    .select({ count: novelSchema.totalChapters })
    .from(novelSchema)
    .where(
      and(
        eq(novelSchema.pluginId, 'dev-paged-novel'),
        eq(novelSchema.path, novelPath),
      ),
    )
    .get();

  const currentChapters = res?.count || 0;
  let totalPages = Math.ceil(currentChapters / CHAPTERS_PER_PAGE);

  if (totalPages < DEFAULT_PAGES) {
    totalPages = DEFAULT_PAGES;
  }

  if (newPages) {
    totalPages += 2;
  } else if (newContent) {
    const extraChapters = Math.min(
      5,
      CHAPTERS_PER_PAGE - (totalPages * CHAPTERS_PER_PAGE - currentChapters),
    );
    if (extraChapters > 0) {
      totalPages = Math.ceil(
        (currentChapters + extraChapters) / CHAPTERS_PER_PAGE,
      );
    }
  }

  return totalPages;
};

Then use in both parseNovel and parsePage.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/plugins/dev/DevPagedNovelPlugin.ts` around lines 252 - 363, Duplicate
logic for computing totalPages appears in parseNovel and parsePage; extract it
into a shared async helper (e.g., getTotalPages) that accepts novelPath and a
Storage instance, moves the dbManager query, currentChapters calculation,
DEFAULT_PAGES/CHAPTERS_PER_PAGE adjustments and newPages/newContent handling
into that helper, and then call getTotalPages(...) from both parseNovel and
parsePage (replace the repeated blocks with await getTotalPages(novelPath, s)).
Ensure helper returns a number and use the exact symbols novelSchema, dbManager,
CHAPTERS_PER_PAGE, DEFAULT_PAGES, and Storage from the module.
src/hooks/persisted/useNovel/store-helper/persistence.ts (1)

109-112: Redundant delete before set.

Line 110 deletes the key, then line 111 immediately sets a new value. The set operation will overwrite regardless, making the delete unnecessary.

♻️ Proposed fix
     if (isValidNovelSettings(parsed)) {
       return parsed;
     }

-    storage.delete(key);
     storage.set(key, JSON.stringify(defaultNovelSettings));
     return defaultNovelSettings;
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/hooks/persisted/useNovel/store-helper/persistence.ts` around lines 109 -
112, The storage.delete call before immediately calling storage.set is
redundant; remove the storage.delete(key) line and simply call storage.set(key,
JSON.stringify(defaultNovelSettings)) and return defaultNovelSettings (keep
references to storage.set, key, and defaultNovelSettings in the persistence
helper).

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 649dc5bb-1eca-49fe-a624-ca89ad652a3f

📥 Commits

Reviewing files that changed from the base of the PR and between 29de6d4 and db25722.

⛔ Files ignored due to path filters (1)
  • pnpm-lock.yaml is excluded by !**/pnpm-lock.yaml
📒 Files selected for processing (62)
  • __mocks__/react-native-nitro-modules.js
  • package.json
  • src/database/manager/manager.ts
  • src/database/queries/ChapterQueries.ts
  • src/database/queries/NovelQueries.ts
  • src/database/utils/filter.ts
  • src/database/utils/parser.ts
  • src/hooks/__tests__/mocks.ts
  • src/hooks/__tests__/mocksContract.test.ts
  • src/hooks/__tests__/useNovel.test.ts
  • src/hooks/__tests__/useNovelStore.test.ts
  • src/hooks/persisted/__mocks__/useNovel.ts
  • src/hooks/persisted/__mocks__/useNovelSettings.ts
  • src/hooks/persisted/index.ts
  • src/hooks/persisted/useNovel.ts
  • src/hooks/persisted/useNovel/__tests__/bootstrapService.test.ts
  • src/hooks/persisted/useNovel/__tests__/chapterActions.test.ts
  • src/hooks/persisted/useNovel/__tests__/keyContract.test.ts
  • src/hooks/persisted/useNovel/__tests__/novelStore.chapterActions.test.ts
  • src/hooks/persisted/useNovel/__tests__/novelStore.chapterState.test.ts
  • src/hooks/persisted/useNovel/__tests__/persistence.test.ts
  • src/hooks/persisted/useNovel/__tests__/useNovelSettings.test.ts
  • src/hooks/persisted/useNovel/store-helper/bootstrapService.ts
  • src/hooks/persisted/useNovel/store-helper/contracts.ts
  • src/hooks/persisted/useNovel/store-helper/keyContract.ts
  • src/hooks/persisted/useNovel/store-helper/persistence.ts
  • src/hooks/persisted/useNovel/store/chapterActions.ts
  • src/hooks/persisted/useNovel/store/createStore.ts
  • src/hooks/persisted/useNovel/store/novelStore.actions.ts
  • src/hooks/persisted/useNovel/store/novelStore.chapterActions.ts
  • src/hooks/persisted/useNovel/store/novelStore.chapterState.ts
  • src/hooks/persisted/useNovel/store/novelStore.ts
  • src/hooks/persisted/useNovel/store/novelStore.types.ts
  • src/hooks/persisted/useNovel/types.ts
  • src/hooks/persisted/useNovel/useChapterOperations.ts
  • src/hooks/persisted/useNovelSettings.ts
  • src/hooks/persisted/usePlugins.ts
  • src/plugins/dev/DevNovelPlugin.ts
  • src/plugins/dev/DevPagedNovelPlugin.ts
  • src/plugins/dev/index.ts
  • src/plugins/pluginManager.ts
  • src/screens/browse/components/InstalledTab.tsx
  • src/screens/novel/NovelContext.tsx
  • src/screens/novel/NovelScreen.tsx
  • src/screens/novel/__tests__/NovelScreen.test.tsx
  • src/screens/novel/components/ChapterItem.tsx
  • src/screens/novel/components/Info/NovelInfoHeader.tsx
  • src/screens/novel/components/JumpToChapterModal.tsx
  • src/screens/novel/components/NovelScreenList.tsx
  • src/screens/novel/components/__tests__/NovelScreenList.test.tsx
  • src/screens/reader/components/ChapterDrawer/__tests__/ChapterDrawer.test.tsx
  • src/screens/reader/components/ChapterDrawer/index.tsx
  • src/screens/reader/components/ReaderAppbar.tsx
  • src/screens/reader/components/ReaderFooter.tsx
  • src/screens/reader/hooks/__tests__/useChapter.test.ts
  • src/screens/reader/hooks/useChapter.ts
  • src/screens/settings/SettingsAdvancedScreen.tsx
  • src/services/migrate/migrateNovel.ts
  • src/services/plugin/fetch.ts
  • src/services/updates/LibraryUpdateQueries.ts
  • src/utils/mmkv/zustand-adapter.ts
  • tsconfig.tsbuildinfo
💤 Files with no reviewable changes (1)
  • src/screens/browse/components/InstalledTab.tsx
✅ Files skipped from review due to trivial changes (12)
  • package.json
  • src/hooks/persisted/index.ts
  • src/screens/settings/SettingsAdvancedScreen.tsx
  • src/hooks/persisted/useNovel/store/novelStore.chapterState.ts
  • src/screens/reader/components/ReaderAppbar.tsx
  • src/plugins/dev/index.ts
  • src/hooks/persisted/mocks/useNovelSettings.ts
  • src/hooks/persisted/useNovel/tests/keyContract.test.ts
  • src/screens/reader/hooks/tests/useChapter.test.ts
  • src/hooks/persisted/useNovel/tests/bootstrapService.test.ts
  • src/hooks/persisted/useNovel/store-helper/keyContract.ts
  • src/hooks/persisted/useNovel/types.ts
🚧 Files skipped from review as they are similar to previous changes (10)
  • src/screens/novel/components/Info/NovelInfoHeader.tsx
  • src/screens/reader/components/ChapterDrawer/index.tsx
  • src/screens/novel/components/JumpToChapterModal.tsx
  • src/services/migrate/migrateNovel.ts
  • src/screens/reader/components/ChapterDrawer/tests/ChapterDrawer.test.tsx
  • src/database/queries/NovelQueries.ts
  • src/hooks/persisted/useNovel/tests/novelStore.chapterState.test.ts
  • src/screens/reader/components/ReaderFooter.tsx
  • src/screens/reader/hooks/useChapter.ts
  • src/hooks/tests/useNovelStore.test.ts

Comment on lines 179 to 185
const [data, setData] = useState<ReturnValue>(
() => db.executeSync(sqlString, params as any[]).rows as ReturnValue,
() =>
db.execute(sqlString, params as any[]).then(result => {
callback?.(result.rows as ReturnValue);
return result.rows;
}) as ReturnValue,
);
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

Critical: useState initializer returns a Promise, not the resolved value.

The useState initializer returns a Promise (db.execute(...).then(...)), which means React stores the Promise object itself as state, not the resolved data. This will cause type errors and rendering issues since data will be a Promise, not ReturnValue.

🐛 Suggested fix: Use null/undefined initial state and load asynchronously
-  const [data, setData] = useState<ReturnValue>(
-    () =>
-      db.execute(sqlString, params as any[]).then(result => {
-        callback?.(result.rows as ReturnValue);
-        return result.rows;
-      }) as ReturnValue,
-  );
+  const [data, setData] = useState<ReturnValue | null>(null);
+
+  useEffect(() => {
+    let cancelled = false;
+    db.execute(sqlString, params as any[]).then(result => {
+      if (!cancelled) {
+        setData(result.rows as ReturnValue);
+        callback?.(result.rows as ReturnValue);
+      }
+    });
+    return () => { cancelled = true; };
+    // eslint-disable-next-line react-hooks/exhaustive-deps
+  }, [sqlString, paramsKey]);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/database/manager/manager.ts` around lines 179 - 185, The state
initializer currently returns a Promise (db.execute(...).then(...)) so React
stores a Promise instead of the resolved ReturnValue; change to initialize data
to a safe empty value (null/undefined or empty array matching ReturnValue) and
move the db.execute call into an async effect: in a useEffect tied to
sqlString/params call db.execute(sqlString, params), await the result, then call
setData(result.rows as ReturnValue) and callback?.(result.rows), and handle
component unmount (cancellation flag) to avoid setting state after unmount;
update references to useState, setData, db.execute, callback, sqlString and
params accordingly.

Comment on lines +303 to 311
export const getCustomPages = (novelId: number) => {
return dbManager.allSync(
dbManager
.selectDistinct({ page: chapterSchema.page })
.from(chapterSchema)
.where(eq(chapterSchema.novelId, novelId))
.orderBy(asc(castInt(chapterSchema.page))),
);
};
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Sync change may cause test inconsistency.

The function now returns synchronously, but the test file (per context snippet at ChapterQueries.test.ts:566-577) uses await getCustomPages(novelId). While awaiting a non-Promise value works in JavaScript, this inconsistency should be addressed in tests for clarity.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/database/queries/ChapterQueries.ts` around lines 303 - 311, The
getCustomPages function was changed to return synchronously causing mismatch
with tests that await its result; restore an async contract by making
getCustomPages return a Promise (e.g., wrap the dbManager.allSync result in
Promise.resolve or switch back to the async dbManager.all call) so callers using
await (tests referencing getCustomPages) remain correct—update the
implementation in getCustomPages to return a Promise while keeping the same
result shape.

Comment on lines +60 to +78
toFilterObject(): FilterObject {
const result: FilterObject = {};
for (const [key, value] of this.filter.entries()) {
if (value === FILTER_STATES.OFF) continue;

switch (key) {
case 'read':
result.unread = value !== FILTER_STATES.ON;
break;
case 'downloaded':
result.isDownloaded = value === FILTER_STATES.ON;
break;
case 'bookmarked':
result.bookmark = value === FILTER_STATES.ON;
break;
}
}
return result;
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Find usages of toFilterObject to understand expected behavior
rg -n "toFilterObject" --type ts

Repository: CD-Z/lnreader

Length of output: 123


toFilterObject() method is never called and has incomplete logic for INDETERMINATE states.

The method is defined but unused in the codebase. When present, it has an asymmetry: the read filter always sets a value (inverting INDETERMINATE to unread=true), but downloaded and bookmarked filters only set values when state is ON, leaving them undefined for INDETERMINATE.

If this method is meant to be used, either:

  1. Remove the method if it's unused dead code, or
  2. Fix the logic to consistently handle INDETERMINATE:
case 'downloaded':
-  result.isDownloaded = value === FILTER_STATES.ON;
+  if (value !== FILTER_STATES.OFF) {
+    result.isDownloaded = value === FILTER_STATES.ON;
+  }
  break;
case 'bookmarked':
-  result.bookmark = value === FILTER_STATES.ON;
+  if (value !== FILTER_STATES.OFF) {
+    result.bookmark = value === FILTER_STATES.ON;
+  }
  break;
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
toFilterObject(): FilterObject {
const result: FilterObject = {};
for (const [key, value] of this.filter.entries()) {
if (value === FILTER_STATES.OFF) continue;
switch (key) {
case 'read':
result.unread = value !== FILTER_STATES.ON;
break;
case 'downloaded':
result.isDownloaded = value === FILTER_STATES.ON;
break;
case 'bookmarked':
result.bookmark = value === FILTER_STATES.ON;
break;
}
}
return result;
}
toFilterObject(): FilterObject {
const result: FilterObject = {};
for (const [key, value] of this.filter.entries()) {
if (value === FILTER_STATES.OFF) continue;
switch (key) {
case 'read':
result.unread = value !== FILTER_STATES.ON;
break;
case 'downloaded':
if (value !== FILTER_STATES.OFF) {
result.isDownloaded = value === FILTER_STATES.ON;
}
break;
case 'bookmarked':
if (value !== FILTER_STATES.OFF) {
result.bookmark = value === FILTER_STATES.ON;
}
break;
}
}
return result;
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/database/utils/filter.ts` around lines 60 - 78, toFilterObject currently
contains asymmetrical handling of FILTER_STATES (it inverts 'read' for
INDETERMINATE but only sets 'downloaded'/'bookmarked' when ON) and is unused;
either remove it or make its logic consistent: if you keep toFilterObject,
change the switch for keys 'read','downloaded','bookmarked' to explicitly handle
FILTER_STATES.ON, FILTER_STATES.OFF and FILTER_STATES.INDETERMINATE (set result
fields to true/false for ON/OFF and leave them undefined or set to undefined for
INDETERMINATE), using the FILTER_STATES enum and returning a proper
FilterObject; alternatively delete toFilterObject to remove dead code.

Comment on lines +38 to +55
export interface NovelStoreChapterActions {
chapterTextCache: ChapterTextCacheApi;
getNextChapterBatch: () => Promise<void>;
loadUpToBatch: (targetBatch: number) => Promise<void>;
updateChapter: (index: number, update: Partial<ChapterInfo>) => void;
setChapters: (chs: ChapterInfo[]) => void;
extendChapters: (chs: ChapterInfo[]) => void;
bookmarkChapters: (chapters: ChapterInfo[]) => void;
markPreviouschaptersRead: (chapterId: number) => void;
markChapterRead: (chapterId: number) => void;
markChaptersRead: (chapters: ChapterInfo[]) => void;
markPreviousChaptersUnread: (chapterId: number) => void;
markChaptersUnread: (chapters: ChapterInfo[]) => void;
updateChapterProgress: (chapterId: number, progress: number) => void;
deleteChapter: (chapter: ChapterInfo) => void;
deleteChapters: (chapters: ChapterInfo[]) => void;
refreshChapters: () => void;
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Check for naming inconsistency across codebase
echo "=== markPreviouschaptersRead (lowercase c) ==="
rg -n 'markPreviouschaptersRead' --type ts

echo ""
echo "=== markPreviousChaptersRead (uppercase C) ==="
rg -n 'markPreviousChaptersRead' --type ts

Repository: CD-Z/lnreader

Length of output: 2538


🏁 Script executed:

rg -n 'markPrevious.*[Uu]nread' --type ts

Repository: CD-Z/lnreader

Length of output: 3953


🏁 Script executed:

sed -n '38,55p' src/hooks/persisted/useNovel/store/novelStore.types.ts

Repository: CD-Z/lnreader

Length of output: 945


Typo in method name: markPreviouschaptersRead should be markPreviousChaptersRead.

The lowercase c in chapters breaks naming consistency with markPreviousChaptersUnread on line 49. This inconsistency appears throughout the codebase (25+ occurrences of the typo variant).

🔤 Proposed fix
-  markPreviouschaptersRead: (chapterId: number) => void;
+  markPreviousChaptersRead: (chapterId: number) => void;

Note: Fixing this will require updating method names and references across multiple implementation files including useChapterOperations.ts, novelStore.chapterActions.ts, chapterActions.ts, test files, and mocks.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
export interface NovelStoreChapterActions {
chapterTextCache: ChapterTextCacheApi;
getNextChapterBatch: () => Promise<void>;
loadUpToBatch: (targetBatch: number) => Promise<void>;
updateChapter: (index: number, update: Partial<ChapterInfo>) => void;
setChapters: (chs: ChapterInfo[]) => void;
extendChapters: (chs: ChapterInfo[]) => void;
bookmarkChapters: (chapters: ChapterInfo[]) => void;
markPreviouschaptersRead: (chapterId: number) => void;
markChapterRead: (chapterId: number) => void;
markChaptersRead: (chapters: ChapterInfo[]) => void;
markPreviousChaptersUnread: (chapterId: number) => void;
markChaptersUnread: (chapters: ChapterInfo[]) => void;
updateChapterProgress: (chapterId: number, progress: number) => void;
deleteChapter: (chapter: ChapterInfo) => void;
deleteChapters: (chapters: ChapterInfo[]) => void;
refreshChapters: () => void;
}
export interface NovelStoreChapterActions {
chapterTextCache: ChapterTextCacheApi;
getNextChapterBatch: () => Promise<void>;
loadUpToBatch: (targetBatch: number) => Promise<void>;
updateChapter: (index: number, update: Partial<ChapterInfo>) => void;
setChapters: (chs: ChapterInfo[]) => void;
extendChapters: (chs: ChapterInfo[]) => void;
bookmarkChapters: (chapters: ChapterInfo[]) => void;
markPreviousChaptersRead: (chapterId: number) => void;
markChapterRead: (chapterId: number) => void;
markChaptersRead: (chapters: ChapterInfo[]) => void;
markPreviousChaptersUnread: (chapterId: number) => void;
markChaptersUnread: (chapters: ChapterInfo[]) => void;
updateChapterProgress: (chapterId: number, progress: number) => void;
deleteChapter: (chapter: ChapterInfo) => void;
deleteChapters: (chapters: ChapterInfo[]) => void;
refreshChapters: () => void;
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/hooks/persisted/useNovel/store/novelStore.types.ts` around lines 38 - 55,
The method name `markPreviouschaptersRead` in the NovelStoreChapterActions
interface is misspelled; rename it to `markPreviousChaptersRead` (capital C) in
the interface and update every implementation and reference (e.g.,
functions/exports named markPreviouschaptersRead in useChapterOperations.ts,
novelStore.chapterActions.ts, chapterActions.ts, tests, and mocks) to the
corrected identifier; ensure all callers, type annotations, and exported objects
use the new name and run tests to catch remaining references.

Comment on lines +59 to +71
const transformAndSetChapters = useCallback(
async (chs: ChapterInfo[]) => {
_setChapters(transformChapters(chs));
},
[transformChapters, _setChapters],
);

const extendChapters = useCallback(
async (chs: ChapterInfo[]) => {
_setChapters(prev => prev.concat(transformChapters(chs)));
},
[transformChapters, _setChapters],
);
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Remove unnecessary async declarations.

Both transformAndSetChapters and extendChapters are declared async but contain no await expressions. This adds unnecessary Promise wrapping overhead.

🔧 Proposed fix
   const transformAndSetChapters = useCallback(
-    async (chs: ChapterInfo[]) => {
+    (chs: ChapterInfo[]) => {
       _setChapters(transformChapters(chs));
     },
     [transformChapters, _setChapters],
   );

   const extendChapters = useCallback(
-    async (chs: ChapterInfo[]) => {
+    (chs: ChapterInfo[]) => {
       _setChapters(prev => prev.concat(transformChapters(chs)));
     },
     [transformChapters, _setChapters],
   );
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const transformAndSetChapters = useCallback(
async (chs: ChapterInfo[]) => {
_setChapters(transformChapters(chs));
},
[transformChapters, _setChapters],
);
const extendChapters = useCallback(
async (chs: ChapterInfo[]) => {
_setChapters(prev => prev.concat(transformChapters(chs)));
},
[transformChapters, _setChapters],
);
const transformAndSetChapters = useCallback(
(chs: ChapterInfo[]) => {
_setChapters(transformChapters(chs));
},
[transformChapters, _setChapters],
);
const extendChapters = useCallback(
(chs: ChapterInfo[]) => {
_setChapters(prev => prev.concat(transformChapters(chs)));
},
[transformChapters, _setChapters],
);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/hooks/persisted/useNovel/useChapterOperations.ts` around lines 59 - 71,
The functions transformAndSetChapters and extendChapters are declared async but
never await anything, causing unnecessary Promise wrapping; remove the async
keyword from both useCallback declarations (the callbacks passed to useCallback
for transformAndSetChapters and extendChapters) so they become synchronous
functions that call _setChapters with transformChapters (and prev =>
prev.concat(transformChapters(chs)) for extendChapters) and keep the same
dependency array [transformChapters, _setChapters].

Comment on lines +78 to +81
const allInstalledPlugins = [...devPlugins, ...installedPlugins];

setFilteredInstalledPlugins(
installedPlugins.filter(plg => filter.includes(plg.lang)),
allInstalledPlugins.filter(plg => filter.includes(plg.lang)),
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Deduplicate dev plugins when merging with persisted installs.

If INSTALLED_PLUGINS already contains dev-novel or dev-paged-novel from an older dev build, allInstalledPlugins will contain both copies and the installed list will render duplicates. Because uninstallPlugin() now bails out for those IDs, users also can't clear the stale persisted entry anymore.

Suggested fix
-      const allInstalledPlugins = [...devPlugins, ...installedPlugins];
+      const devPluginIds = new Set(devPlugins.map(plg => plg.id));
+      const allInstalledPlugins = [
+        ...devPlugins,
+        ...installedPlugins.filter(plg => !devPluginIds.has(plg.id)),
+      ];
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const allInstalledPlugins = [...devPlugins, ...installedPlugins];
setFilteredInstalledPlugins(
installedPlugins.filter(plg => filter.includes(plg.lang)),
allInstalledPlugins.filter(plg => filter.includes(plg.lang)),
const devPluginIds = new Set(devPlugins.map(plg => plg.id));
const allInstalledPlugins = [
...devPlugins,
...installedPlugins.filter(plg => !devPluginIds.has(plg.id)),
];
setFilteredInstalledPlugins(
allInstalledPlugins.filter(plg => filter.includes(plg.lang)),
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/hooks/persisted/usePlugins.ts` around lines 78 - 81, When merging
devPlugins and installedPlugins into allInstalledPlugins, remove duplicate dev
entries so stale persisted dev IDs (e.g., "dev-novel"/"dev-paged-novel") don't
cause duplicates and cannot be uninstalled. Change the merge logic that builds
allInstalledPlugins (used by setFilteredInstalledPlugins and related to
uninstallPlugin()) to prefer installedPlugins and append only those devPlugins
whose id is not already present in installedPlugins (e.g., filter devPlugins by
id or lang against installedPlugins' ids) before filtering by lang.

Comment on lines +84 to 90
const layoutValue = useMemo(
() => ({
...novelHookContent,
navigationBarHeight: NavigationBarHeight.current,
statusBarHeight: StatusBarHeight.current,
chapterTextCache: chapterTextCache.current,
navigationBarHeight: navigationBarHeightRef.current,
statusBarHeight: statusBarHeightRef.current,
}),
[novelHookContent],
[],
);
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

layoutValue memoization captures stale ref values.

The useMemo has an empty dependency array [], so it only computes once on initial render. However, navigationBarHeightRef.current and statusBarHeightRef.current are updated on subsequent renders (lines 74-82), but layoutValue will never reflect these updates.

If stable layout values are intentional for avoiding re-renders, consider documenting this. Otherwise, include the ref current values or derived state in dependencies:

🔧 Proposed fix if values should update
   const layoutValue = useMemo(
     () => ({
       navigationBarHeight: navigationBarHeightRef.current,
       statusBarHeight: statusBarHeightRef.current,
     }),
-    [],
+    [navigationBarHeightRef.current, statusBarHeightRef.current],
   );

Note: Using .current in deps is generally discouraged. A cleaner approach would be to use state instead of refs if updates should propagate.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const layoutValue = useMemo(
() => ({
...novelHookContent,
navigationBarHeight: NavigationBarHeight.current,
statusBarHeight: StatusBarHeight.current,
chapterTextCache: chapterTextCache.current,
navigationBarHeight: navigationBarHeightRef.current,
statusBarHeight: statusBarHeightRef.current,
}),
[novelHookContent],
[],
);
const layoutValue = useMemo(
() => ({
navigationBarHeight: navigationBarHeightRef.current,
statusBarHeight: statusBarHeightRef.current,
}),
[navigationBarHeightRef.current, statusBarHeightRef.current],
);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/screens/novel/NovelContext.tsx` around lines 84 - 90, layoutValue is
memoized with an empty dependency array so it never reflects updates to
navigationBarHeightRef.current and statusBarHeightRef.current; update the
memoization to depend on those values (or better, convert the refs to state and
depend on the state) so layoutValue is recomputed when heights
change—specifically modify the useMemo that defines layoutValue to include
navigationBarHeightRef.current and statusBarHeightRef.current (or replace
navigationBarHeightRef/statusBarHeightRef with state setters and use that state
in the dependency array) to ensure the context value stays current.

Copy link
Copy Markdown

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

♻️ Duplicate comments (4)
src/hooks/__tests__/mocks.ts (2)

56-59: ⚠️ Potential issue | 🟡 Minor

Include totalChapters in the shared batchInformation mock.

The store shape now carries batchInformation.totalChapters; leaving it out here means tests built on this helper still exercise an outdated batch contract.

Possible fix
   batchInformation: {
     batch: 0,
     total: 0,
+    totalChapters: 0,
   },
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/hooks/__tests__/mocks.ts` around lines 56 - 59, The shared mock object
batchInformation in src/hooks/__tests__/mocks.ts is missing the required
totalChapters field; update the batchInformation mock (the object with keys
batch and total) to include totalChapters with an appropriate numeric default
(e.g., 0) so tests use the current store shape (refer to the batchInformation
mock object in the file).

98-123: ⚠️ Potential issue | 🟠 Major

Make the mock store obey the external-store contract.

useNovelStore() in src/screens/novel/NovelContext.tsx is selector-based, so a no-op subscribe() masks rerender behavior, and the singleton defaultMockNovelContext lets mutated store state leak between tests.

Possible fix
 export const createMockNovelStore = (
   stateOverrides: Record<string, unknown> = {},
 ) => {
   let state = createMockNovelStoreState(stateOverrides);
+  const listeners = new Set<() => void>();

   return {
     getState: jest.fn(() => state),
     setState: jest.fn(nextState => {
       const partial =
         typeof nextState === 'function' ? nextState(state) : nextState;
       state = {
         ...state,
         ...partial,
       };
+      listeners.forEach(listener => listener());
     }),
-    subscribe: jest.fn(() => () => {}),
+    subscribe: jest.fn(listener => {
+      listeners.add(listener);
+      return () => listeners.delete(listener);
+    }),
   };
 };
 
-const defaultMockNovelContext = {
-  novelStore: createMockNovelStore(),
+const createDefaultMockNovelContext = () => ({
+  novelStore: createMockNovelStore(),
   navigationBarHeight: 0,
   statusBarHeight: 0,
-};
+});
 
-export const mockUseNovelContext = jest.fn(() => defaultMockNovelContext);
+export const mockUseNovelContext = jest.fn(() =>
+  createDefaultMockNovelContext(),
+);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/hooks/__tests__/mocks.ts` around lines 98 - 123, The mock store currently
returns a singleton defaultMockNovelContext and a no-op subscribe which breaks
selector-based useNovelStore; update createMockNovelStore to implement the
external-store contract: maintain an internal listeners set, have
subscribe(listener) add the listener and return an unsubscribe function, and
have setState call each listener after updating state (and ensure getState
returns the current state). Also stop exporting a singleton
defaultMockNovelContext — make mockUseNovelContext return a fresh context object
(novelStore: createMockNovelStore(), navigationBarHeight: 0, statusBarHeight: 0)
on each call so mutated state does not leak between tests.
src/hooks/__tests__/useNovel.test.ts (1)

37-60: ⚠️ Potential issue | 🟡 Minor

Seed the same persistence keys this test asserts on.

This setup still writes keyContract.settings/lastRead, but the assertions below only verify novelPersistence.keys.settings/lastRead. If cleanup regresses for the asserted keys, this test stays green because those entries were never created.

Possible fix
     for (const novel of cachedNovels) {
       MMKVStorage.set(`${TRACKED_NOVEL_PREFIX}_${novel.id}`, 'tracked');
       MMKVStorage.set(
         keyContract.pageIndex({
           pluginId: novel.pluginId,
@@
       MMKVStorage.set(
         keyContract.settings({
           pluginId: novel.pluginId,
           novelPath: novel.path,
         }),
         JSON.stringify({ filter: [], showChapterTitles: true }),
       );
+      MMKVStorage.set(
+        novelPersistence.keys.settings({
+          pluginId: novel.pluginId,
+          novelPath: novel.path,
+        }),
+        JSON.stringify({ filter: [], showChapterTitles: true }),
+      );
       MMKVStorage.set(
         keyContract.lastRead({
           pluginId: novel.pluginId,
           novelPath: novel.path,
         }),
         JSON.stringify({ id: 1 }),
       );
+      MMKVStorage.set(
+        novelPersistence.keys.lastRead({
+          pluginId: novel.pluginId,
+          novelPath: novel.path,
+        }),
+        JSON.stringify({ id: 1 }),
+      );
     }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/hooks/__tests__/useNovel.test.ts` around lines 37 - 60, The test seeds
persistence keys but does not create the exact keys the assertions check
(novelPersistence.keys.settings and novelPersistence.keys.lastRead), so it can
pass falsely; update the setup inside the loop that iterates cachedNovels to
also write entries using novelPersistence.keys.settings({pluginId, novelPath})
and novelPersistence.keys.lastRead({pluginId, novelPath}) in addition to the
existing MMKVStorage.set calls (keep the existing TRACKED_NOVEL_PREFIX and
keyContract writes), so the test actually seeds the specific keys asserted by
the assertions; locate the loop using cachedNovels, MMKVStorage.set,
keyContract.settings/lastRead and add MMKVStorage.set calls for
novelPersistence.keys.settings and novelPersistence.keys.lastRead with the same
payloads.
src/screens/novel/components/__tests__/NovelScreenList.test.tsx (1)

260-275: ⚠️ Potential issue | 🔴 Critical

Remove the nonexistent getNextChapterBatch prop from these renders.

NovelScreenList gets that action from useNovelActions() in src/screens/novel/components/NovelScreenList.tsx:73-80; it is not part of NovelScreenListProps. Keeping it here makes both render paths drift from the real component API and can break type-checking.

Possible fix
 const renderList = () =>
   render(
     <NovelScreenList
       headerOpacity={headerOpacity as any}
       listRef={listRef as any}
@@
       }}
       selected={[]}
       setSelected={jest.fn()}
-      getNextChapterBatch={jest.fn()}
     />,
   );
@@
     view.rerender(
       <NovelScreenList
         headerOpacity={headerOpacity as any}
         listRef={listRef as any}
         navigation={navigation}
@@
         }}
         selected={[]}
         setSelected={jest.fn()}
-        getNextChapterBatch={jest.fn()}
       />,
     );

Also applies to: 303-316

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/screens/novel/components/__tests__/NovelScreenList.test.tsx` around lines
260 - 275, Tests pass a nonexistent prop getNextChapterBatch to the
NovelScreenList renders; remove that prop from the render calls (e.g., in
renderList and the other render block) so the JSX matches NovelScreenListProps
and the component's useNovelActions() consumption in NovelScreenList; update any
test references that assumed the prop to instead mock useNovelActions or assert
via the component's public API.
🧹 Nitpick comments (1)
src/hooks/persisted/useNovel/__tests__/bootstrapService.test.ts (1)

198-200: Make the dedupe test deterministic by removing real-time delay gating.

Using setTimeout(..., 10) introduces timing sensitivity. A manually-resolved deferred promise is more stable and faster.

♻️ Suggested refactor
   it('dedupes in-flight bootstrap per ${pluginId}_${novelPath}', async () => {
     setupDbFirstSuccess();
-    mockGetChapterCount.mockImplementation(
-      () => new Promise(resolve => setTimeout(() => resolve(mockChapters.length), 10)),
-    );
+    let release!: () => void;
+    const gate = new Promise<number>(resolve => {
+      release = () => resolve(mockChapters.length);
+    });
+    mockGetChapterCount.mockImplementation(() => gate);
     const service = createBootstrapService();

-    const [result1, result2] = await Promise.all([
+    const pending = Promise.all([
       service.bootstrapNovelAsync({
         novel: mockNovel,
         novelPath: NOVEL_PATH,
         pluginId: PLUGIN_ID,
         pageIndex: 0,
         settingsSort,
         settingsFilter,
       }),
       service.bootstrapNovelAsync({
         novel: mockNovel,
         novelPath: NOVEL_PATH,
         pluginId: PLUGIN_ID,
         pageIndex: 0,
         settingsSort,
         settingsFilter,
       }),
     ]);
+    release();
+    const [result1, result2] = await pending;

Also applies to: 203-220

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/hooks/persisted/useNovel/__tests__/bootstrapService.test.ts` around lines
198 - 200, The test currently uses setTimeout gating inside mockGetChapterCount
(mockGetChapterCount.mockImplementation(() => new Promise(resolve =>
setTimeout(() => resolve(mockChapters.length), 10)))) which makes the dedupe
test timing-dependent; replace that with a manually controlled deferred promise
pattern (create a deferred object with resolve/reject and return
deferred.promise from mockGetChapterCount) and then explicitly call
deferred.resolve(mockChapters.length) at the exact point in the test where you
want the mock to finish; apply the same change for the other mocked
implementations in this test (the related mocks around the other assertions) so
the test becomes deterministic and fast.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@src/database/queries/__tests__/NovelQueries.test.ts`:
- Around line 150-152: The tests use Boolean(...) which masks missing rows;
replace those assertions with explicit existence and boolean checks: assert the
query result and the fetched novel are defined (e.g.,
expect(result).toBeDefined(); expect(novel).toBeDefined()) and then assert the
inLibrary field equals true (e.g., expect(result.inLibrary).toBe(true);
expect(novel.inLibrary).toBe(true)). Update the same pattern at the other
occurrences referenced (lines around 168-170 and 214-215) and ensure you await
getNovelById if it is async before asserting.

In `@src/hooks/persisted/useNovel/__tests__/bootstrapService.test.ts`:
- Around line 89-94: The test sets up console spies in beforeEach
(jest.spyOn(console, 'time'), jest.spyOn(console, 'timeEnd'),
jest.spyOn(console, 'error')) but never restores them; add an afterEach block
that calls jest.restoreAllMocks() (or individually mockRestore() on those spies)
to restore original console methods after each test so spies do not leak across
tests.

---

Duplicate comments:
In `@src/hooks/__tests__/mocks.ts`:
- Around line 56-59: The shared mock object batchInformation in
src/hooks/__tests__/mocks.ts is missing the required totalChapters field; update
the batchInformation mock (the object with keys batch and total) to include
totalChapters with an appropriate numeric default (e.g., 0) so tests use the
current store shape (refer to the batchInformation mock object in the file).
- Around line 98-123: The mock store currently returns a singleton
defaultMockNovelContext and a no-op subscribe which breaks selector-based
useNovelStore; update createMockNovelStore to implement the external-store
contract: maintain an internal listeners set, have subscribe(listener) add the
listener and return an unsubscribe function, and have setState call each
listener after updating state (and ensure getState returns the current state).
Also stop exporting a singleton defaultMockNovelContext — make
mockUseNovelContext return a fresh context object (novelStore:
createMockNovelStore(), navigationBarHeight: 0, statusBarHeight: 0) on each call
so mutated state does not leak between tests.

In `@src/hooks/__tests__/useNovel.test.ts`:
- Around line 37-60: The test seeds persistence keys but does not create the
exact keys the assertions check (novelPersistence.keys.settings and
novelPersistence.keys.lastRead), so it can pass falsely; update the setup inside
the loop that iterates cachedNovels to also write entries using
novelPersistence.keys.settings({pluginId, novelPath}) and
novelPersistence.keys.lastRead({pluginId, novelPath}) in addition to the
existing MMKVStorage.set calls (keep the existing TRACKED_NOVEL_PREFIX and
keyContract writes), so the test actually seeds the specific keys asserted by
the assertions; locate the loop using cachedNovels, MMKVStorage.set,
keyContract.settings/lastRead and add MMKVStorage.set calls for
novelPersistence.keys.settings and novelPersistence.keys.lastRead with the same
payloads.

In `@src/screens/novel/components/__tests__/NovelScreenList.test.tsx`:
- Around line 260-275: Tests pass a nonexistent prop getNextChapterBatch to the
NovelScreenList renders; remove that prop from the render calls (e.g., in
renderList and the other render block) so the JSX matches NovelScreenListProps
and the component's useNovelActions() consumption in NovelScreenList; update any
test references that assumed the prop to instead mock useNovelActions or assert
via the component's public API.

---

Nitpick comments:
In `@src/hooks/persisted/useNovel/__tests__/bootstrapService.test.ts`:
- Around line 198-200: The test currently uses setTimeout gating inside
mockGetChapterCount (mockGetChapterCount.mockImplementation(() => new
Promise(resolve => setTimeout(() => resolve(mockChapters.length), 10)))) which
makes the dedupe test timing-dependent; replace that with a manually controlled
deferred promise pattern (create a deferred object with resolve/reject and
return deferred.promise from mockGetChapterCount) and then explicitly call
deferred.resolve(mockChapters.length) at the exact point in the test where you
want the mock to finish; apply the same change for the other mocked
implementations in this test (the related mocks around the other assertions) so
the test becomes deterministic and fast.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 94d4b2a5-bfca-43f2-b091-508b291091c6

📥 Commits

Reviewing files that changed from the base of the PR and between db25722 and 2a6e43b.

📒 Files selected for processing (13)
  • src/database/queries/__tests__/NovelQueries.test.ts
  • src/database/queries/__tests__/setup.ts
  • src/hooks/__tests__/mocks.ts
  • src/hooks/__tests__/useNovel.test.ts
  • src/hooks/__tests__/useNovelStore.test.ts
  • src/hooks/persisted/useNovel/__tests__/bootstrapService.test.ts
  • src/hooks/persisted/useNovel/__tests__/novelStore.chapterActions.test.ts
  • src/hooks/persisted/useNovel/__tests__/novelStore.chapterState.test.ts
  • src/hooks/persisted/useNovel/__tests__/useNovelSettings.test.ts
  • src/screens/novel/__tests__/NovelScreen.test.tsx
  • src/screens/novel/components/__tests__/NovelScreenList.test.tsx
  • src/screens/reader/components/ChapterDrawer/__tests__/ChapterDrawer.test.tsx
  • src/screens/reader/hooks/__tests__/useChapter.test.ts
💤 Files with no reviewable changes (1)
  • src/database/queries/tests/setup.ts
🚧 Files skipped from review as they are similar to previous changes (4)
  • src/screens/reader/components/ChapterDrawer/tests/ChapterDrawer.test.tsx
  • src/hooks/persisted/useNovel/tests/useNovelSettings.test.ts
  • src/hooks/persisted/useNovel/tests/novelStore.chapterState.test.ts
  • src/screens/reader/hooks/tests/useChapter.test.ts

Comment on lines +150 to +152
expect(Boolean(result?.inLibrary)).toBe(true);
const novel = getNovelById(novelId);
expect(Boolean(novel?.inLibrary)).toBe(true);
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Avoid Boolean(...) here; it masks broken query results.

These assertions no longer prove that inLibrary was actually persisted as a boolean. In the false cases, Boolean(novel?.inLibrary) also passes when getNovelById(...) returns undefined, so a missing row would be treated as success.

Suggested tightening
-      expect(Boolean(result?.inLibrary)).toBe(true);
+      expect(result).toBeDefined();
+      expect(result?.inLibrary).toBe(true);
       const novel = getNovelById(novelId);
-      expect(Boolean(novel?.inLibrary)).toBe(true);
+      expect(novel).toBeDefined();
+      expect(novel?.inLibrary).toBe(true);

-      expect(Boolean(result?.inLibrary)).toBe(false);
+      expect(result).toBeDefined();
+      expect(result?.inLibrary).toBe(false);
       const novel = getNovelById(novelId);
-      expect(Boolean(novel?.inLibrary)).toBe(false);
+      expect(novel).toBeDefined();
+      expect(novel?.inLibrary).toBe(false);

-      expect(Boolean(novel1?.inLibrary)).toBe(false);
-      expect(Boolean(novel2?.inLibrary)).toBe(false);
+      expect(novel1).toBeDefined();
+      expect(novel2).toBeDefined();
+      expect(novel1?.inLibrary).toBe(false);
+      expect(novel2?.inLibrary).toBe(false);

Also applies to: 168-170, 214-215

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/database/queries/__tests__/NovelQueries.test.ts` around lines 150 - 152,
The tests use Boolean(...) which masks missing rows; replace those assertions
with explicit existence and boolean checks: assert the query result and the
fetched novel are defined (e.g., expect(result).toBeDefined();
expect(novel).toBeDefined()) and then assert the inLibrary field equals true
(e.g., expect(result.inLibrary).toBe(true); expect(novel.inLibrary).toBe(true)).
Update the same pattern at the other occurrences referenced (lines around
168-170 and 214-215) and ensure you await getNovelById if it is async before
asserting.

Comment on lines +89 to +94
beforeEach(() => {
jest.clearAllMocks();
jest.spyOn(console, 'time').mockImplementation(() => {});
jest.spyOn(console, 'timeEnd').mockImplementation(() => {});
jest.spyOn(console, 'error').mockImplementation(() => {});
});
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Verify current mock lifecycle usage in this test file.
rg -n "jest\\.(clearAllMocks|resetAllMocks|restoreAllMocks)|spyOn\\(console" src/hooks/persisted/useNovel/__tests__/bootstrapService.test.ts

Repository: CD-Z/lnreader

Length of output: 284


🏁 Script executed:

cat -n src/hooks/persisted/useNovel/__tests__/bootstrapService.test.ts

Repository: CD-Z/lnreader

Length of output: 11845


🏁 Script executed:

rg -n "afterEach|afterAll" src/hooks/persisted/useNovel/__tests__/bootstrapService.test.ts

Repository: CD-Z/lnreader

Length of output: 39


🌐 Web query:

Jest clearAllMocks vs resetAllMocks vs restoreAllMocks documentation

💡 Result:

Jest provides three global methods to manage mock state across all mocks: jest.clearAllMocks, jest.resetAllMocks, and jest.restoreAllMocks. These are useful in beforeEach/afterEach hooks or jest.config.js for automatic cleanup. jest.clearAllMocks: Clears usage data from all mocks (mock.calls, mock.instances, mock.contexts, mock.results) but keeps any custom implementations. Use when you want to reset call history between tests without changing behavior. Equivalent to mockFn.mockClear on every mock. jest.resetAllMocks: Does everything clearAllMocks does, plus replaces all mock implementations with fresh jest.fn (empty functions returning undefined). Use for fully fresh mocks per test, losing prior setups. Equivalent to mockFn.mockReset on every mock. jest.restoreAllMocks: Does everything resetAllMocks does, but for spies created with jest.spyOn, restores original implementations instead of new mocks. Does not affect jest.fn mocks (resets them). Use when mixing spies and mocks, especially to undo partial module overrides. Equivalent to mockFn.mockRestore on every mock/spy. Configuration equivalents in jest.config.js: clearMocks: true, resetMocks: true, restoreMocks: true (run automatically before each test). Per-mock versions (mockFn.mockClear, etc.) exist for individual control, but *AllMocks apply globally. Key distinction: clearAllMocks preserves implementations; resetAllMocks resets them to default; restoreAllMocks prioritizes original impl for spies.

Citations:


Add afterEach to restore console spies after each test.

jest.spyOn(console, ...) creates spies that override the original methods. jest.clearAllMocks() only clears call history but does not restore the originals, so these spies persist across tests and can affect subsequent test runs.

✅ Suggested fix
  beforeEach(() => {
-    jest.clearAllMocks();
+    jest.resetAllMocks();
     jest.spyOn(console, 'time').mockImplementation(() => {});
     jest.spyOn(console, 'timeEnd').mockImplementation(() => {});
     jest.spyOn(console, 'error').mockImplementation(() => {});
   });
+
+  afterEach(() => {
+    jest.restoreAllMocks();
+  });
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
beforeEach(() => {
jest.clearAllMocks();
jest.spyOn(console, 'time').mockImplementation(() => {});
jest.spyOn(console, 'timeEnd').mockImplementation(() => {});
jest.spyOn(console, 'error').mockImplementation(() => {});
});
beforeEach(() => {
jest.resetAllMocks();
jest.spyOn(console, 'time').mockImplementation(() => {});
jest.spyOn(console, 'timeEnd').mockImplementation(() => {});
jest.spyOn(console, 'error').mockImplementation(() => {});
});
afterEach(() => {
jest.restoreAllMocks();
});
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/hooks/persisted/useNovel/__tests__/bootstrapService.test.ts` around lines
89 - 94, The test sets up console spies in beforeEach (jest.spyOn(console,
'time'), jest.spyOn(console, 'timeEnd'), jest.spyOn(console, 'error')) but never
restores them; add an afterEach block that calls jest.restoreAllMocks() (or
individually mockRestore() on those spies) to restore original console methods
after each test so spies do not leak across tests.

Copy link
Copy Markdown

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🧹 Nitpick comments (4)
src/screens/novel/components/NovelScreenList.tsx (2)

518-518: Clarify or remove the commented-out drawDistance prop.

The commented //drawDistance={1000} suggests this was disabled for debugging or performance reasons. If this is intentional, add a comment explaining why. If it was temporary, consider removing the dead code.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/screens/novel/components/NovelScreenList.tsx` at line 518, The
commented-out prop drawDistance={1000} in NovelScreenList.tsx is ambiguous;
either remove the dead code or restore it with an explanatory comment. Locate
the component JSX where drawDistance is present (search for the commented token
"drawDistance") and either delete the commented line if it was temporary, or
replace it with an inline comment like "// intentionally disabled due to X
(e.g., performance/visual overlap) — enable when Y" so future readers know why
drawDistance was turned off.

63-80: Consider consolidating multiple useNovelValue calls.

Each useNovelValue call creates a separate subscription. For tightly related state accessed together, a single selector returning an object could reduce re-renders:

const { chapters, fetching, loading, pages, novel } = useNovelStore(
  state => ({
    chapters: state.chapters,
    fetching: state.fetching,
    loading: state.loading,
    pages: state.pages,
    novel: state.novel,
  }),
  shallow
);

However, this is a minor optimization and the current approach works correctly.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/screens/novel/components/NovelScreenList.tsx` around lines 63 - 80,
Multiple separate useNovelValue subscriptions (chapters, fetching,
firstUnreadChapter, loading, pages, novel, batchInformation, novelSettings,
pageIndex, lastRead) cause extra re-renders; consolidate them into a single
selector to subscribe to related fields together. Replace the individual
useNovelValue(...) calls with one selector call (e.g., useNovelStore(state => ({
chapters: state.chapters, fetching: state.fetching, loading: state.loading,
pages: state.pages, novel: state.novel, batchInformation:
state.batchInformation, novelSettings: state.novelSettings, pageIndex:
state.pageIndex, firstUnreadChapter: state.firstUnreadChapter, lastRead:
state.lastRead }), shallow)) and keep the useNovelActions() extraction
(deleteChapter, setNovel, getNextChapterBatch, openPage, updateChapter,
refreshNovel) unchanged; import and apply shallow selector equality from your
zustand helper (shallow) to avoid unnecessary re-renders.
src/hooks/persisted/useNovel/__tests__/chapterActions.test.ts (1)

159-169: Potential flaky test: single microtask tick may be insufficient.

The await Promise.resolve() only advances one microtask. If deleteChapterAction internally chains multiple promises (e.g., deleteChapter().then(() => showToast(...))), the assertions may run before the toast is shown.

Consider using await flushPromises() or multiple ticks:

🔧 Suggested helper
+const flushPromises = () => new Promise(r => setImmediate(r));
+
 it('deleteChapterAction is safe no-op when novel is absent', async () => {
   // ...
   deleteChapterAction(makeChapter(1), undefined, state.mutate, deps);
-  await Promise.resolve();
+  await flushPromises();
   // ...
 });

This applies to all async tests in this file (lines 171-188, 190-225, 227-279).

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/hooks/persisted/useNovel/__tests__/chapterActions.test.ts` around lines
159 - 169, The tests calling deleteChapterAction use await Promise.resolve()
which may not drain all microtasks; replace those single-tick awaits with a
robust flush (e.g., await flushPromises() or await new Promise(setImmediate)) so
any chained promises inside deleteChapterAction (including
deps.deleteChapter().then(() => deps.showToast(...))) settle before assertions;
add a simple shared helper flushPromises() used by the tests and update all
occurrences in this file that currently use await Promise.resolve() (including
the tests around deleteChapterAction and other async tests referenced) to call
that helper.
src/hooks/persisted/useNovel/store/novelStore.actions.ts (1)

171-179: Fire-and-forget getChapters() call may cause race conditions.

setNovelSettings triggers state.actions.getChapters() without awaiting. If settings change rapidly (e.g., user toggles filters quickly), multiple concurrent fetches may complete out of order, leaving stale chapter data.

Consider either:

  1. Making setNovelSettings async and awaiting getChapters()
  2. Adding request cancellation/debouncing logic
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/hooks/persisted/useNovel/store/novelStore.actions.ts` around lines 171 -
179, The current setNovelSettings action calls state.actions.getChapters()
fire-and-for-get without waiting, which can lead to race conditions; update
setNovelSettings to either become async and await state.actions.getChapters()
(so callers can sequence updates) or implement request cancellation/debouncing
inside getChapters (or a shared request controller) to ensure only the latest
fetch resolves; locate the setNovelSettings function and the getChapters
implementation and add the chosen fix (await state.actions.getChapters() in
setNovelSettings or add an AbortController/debounce logic in getChapters and
ensure setNovelSettings uses it) so rapid settings changes won’t leave stale
chapter data.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@src/hooks/persisted/useNovel/store/novelStore.chapterActions.ts`:
- Around line 276-288: refreshChapters currently always passes an identity
function to refreshChaptersAction (transformChapters: chs => chs) so injected
transformations are ignored; change refreshChapters to accept an optional
transformChapters parameter (defaulting to chs => chs) and forward that
parameter into refreshChaptersAction's transformChapters field (keep other args:
novel, fetching, settingsSort via getSettingsSort, settingsFilter via
getSettingsFilter, currentPage, setChapters, deps: chapterActionsDependencies).
Update any callers to pass their transform function or rely on the default.

---

Nitpick comments:
In `@src/hooks/persisted/useNovel/__tests__/chapterActions.test.ts`:
- Around line 159-169: The tests calling deleteChapterAction use await
Promise.resolve() which may not drain all microtasks; replace those single-tick
awaits with a robust flush (e.g., await flushPromises() or await new
Promise(setImmediate)) so any chained promises inside deleteChapterAction
(including deps.deleteChapter().then(() => deps.showToast(...))) settle before
assertions; add a simple shared helper flushPromises() used by the tests and
update all occurrences in this file that currently use await Promise.resolve()
(including the tests around deleteChapterAction and other async tests
referenced) to call that helper.

In `@src/hooks/persisted/useNovel/store/novelStore.actions.ts`:
- Around line 171-179: The current setNovelSettings action calls
state.actions.getChapters() fire-and-for-get without waiting, which can lead to
race conditions; update setNovelSettings to either become async and await
state.actions.getChapters() (so callers can sequence updates) or implement
request cancellation/debouncing inside getChapters (or a shared request
controller) to ensure only the latest fetch resolves; locate the
setNovelSettings function and the getChapters implementation and add the chosen
fix (await state.actions.getChapters() in setNovelSettings or add an
AbortController/debounce logic in getChapters and ensure setNovelSettings uses
it) so rapid settings changes won’t leave stale chapter data.

In `@src/screens/novel/components/NovelScreenList.tsx`:
- Line 518: The commented-out prop drawDistance={1000} in NovelScreenList.tsx is
ambiguous; either remove the dead code or restore it with an explanatory
comment. Locate the component JSX where drawDistance is present (search for the
commented token "drawDistance") and either delete the commented line if it was
temporary, or replace it with an inline comment like "// intentionally disabled
due to X (e.g., performance/visual overlap) — enable when Y" so future readers
know why drawDistance was turned off.
- Around line 63-80: Multiple separate useNovelValue subscriptions (chapters,
fetching, firstUnreadChapter, loading, pages, novel, batchInformation,
novelSettings, pageIndex, lastRead) cause extra re-renders; consolidate them
into a single selector to subscribe to related fields together. Replace the
individual useNovelValue(...) calls with one selector call (e.g.,
useNovelStore(state => ({ chapters: state.chapters, fetching: state.fetching,
loading: state.loading, pages: state.pages, novel: state.novel,
batchInformation: state.batchInformation, novelSettings: state.novelSettings,
pageIndex: state.pageIndex, firstUnreadChapter: state.firstUnreadChapter,
lastRead: state.lastRead }), shallow)) and keep the useNovelActions() extraction
(deleteChapter, setNovel, getNextChapterBatch, openPage, updateChapter,
refreshNovel) unchanged; import and apply shallow selector equality from your
zustand helper (shallow) to avoid unnecessary re-renders.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 3239e8be-d500-40ad-9413-72546e5d2d7d

📥 Commits

Reviewing files that changed from the base of the PR and between 2a6e43b and 5af4698.

📒 Files selected for processing (12)
  • src/hooks/persisted/useNovel/__tests__/chapterActions.test.ts
  • src/hooks/persisted/useNovel/__tests__/novelStore.chapterActions.test.ts
  • src/hooks/persisted/useNovel/store-helper/bootstrapService.ts
  • src/hooks/persisted/useNovel/store/chapterActions.ts
  • src/hooks/persisted/useNovel/store/createStore.ts
  • src/hooks/persisted/useNovel/store/novelStore.actions.ts
  • src/hooks/persisted/useNovel/store/novelStore.chapterActions.ts
  • src/hooks/persisted/useNovel/store/novelStore.types.ts
  • src/screens/novel/NovelScreen.tsx
  • src/screens/novel/components/Info/NovelInfoHeader.tsx
  • src/screens/novel/components/NovelScreenList.tsx
  • src/services/updates/LibraryUpdateQueries.ts
💤 Files with no reviewable changes (1)
  • src/services/updates/LibraryUpdateQueries.ts
🚧 Files skipped from review as they are similar to previous changes (4)
  • src/screens/novel/NovelScreen.tsx
  • src/screens/novel/components/Info/NovelInfoHeader.tsx
  • src/hooks/persisted/useNovel/tests/novelStore.chapterActions.test.ts
  • src/hooks/persisted/useNovel/store/chapterActions.ts

Comment on lines +276 to +288
refreshChapters: () => {
const state = get();
refreshChaptersAction({
novel: state.novel,
fetching: state.fetching,
settingsSort: getSettingsSort(state.novelSettings),
settingsFilter: getSettingsFilter(state.novelSettings),
currentPage: state.pages[state.pageIndex] ?? '1',
transformChapters: chs => chs,
setChapters,
deps: chapterActionsDependencies,
});
},
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

refreshChapters ignores injected transformChapters.

Line 284 passes transformChapters: chs => chs (identity function) instead of the injected transformChapters parameter. This means any chapter transformations (sorting, filtering, augmentation) applied elsewhere won't be applied after refresh.

🐛 Proposed fix
     refreshChapters: () => {
       const state = get();
       refreshChaptersAction({
         novel: state.novel,
         fetching: state.fetching,
         settingsSort: getSettingsSort(state.novelSettings),
         settingsFilter: getSettingsFilter(state.novelSettings),
         currentPage: state.pages[state.pageIndex] ?? '1',
-        transformChapters: chs => chs,
+        transformChapters,
         setChapters,
         deps: chapterActionsDependencies,
       });
     },
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/hooks/persisted/useNovel/store/novelStore.chapterActions.ts` around lines
276 - 288, refreshChapters currently always passes an identity function to
refreshChaptersAction (transformChapters: chs => chs) so injected
transformations are ignored; change refreshChapters to accept an optional
transformChapters parameter (defaulting to chs => chs) and forward that
parameter into refreshChaptersAction's transformChapters field (keep other args:
novel, fetching, settingsSort via getSettingsSort, settingsFilter via
getSettingsFilter, currentPage, setChapters, deps: chapterActionsDependencies).
Update any callers to pass their transform function or rely on the default.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant