Conversation
|
Note Reviews pausedIt 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 Use the following commands to manage reviews:
Use the checkboxes below for quick actions:
📝 WalkthroughWalkthroughReplaces 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
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
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
Estimated code review effort🎯 5 (Critical) | ⏱️ ~120 minutes Poem
🚥 Pre-merge checks | ✅ 1 | ❌ 2❌ Failed checks (1 warning, 1 inconclusive)
✅ Passed checks (1 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
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. Comment |
There was a problem hiding this comment.
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 | 🔴 CriticalBug: Comparing ref object instead of
ref.current.
scrollToIndexis auseRefobject, which is neverundefined. The condition should checkscrollToIndex.current === undefinedinstead.🐛 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 unnecessaryasynckeyword from synchronous functions.
transformAndSetChaptersandextendChaptersdon't contain anyawaitexpressions. Theasynckeyword causes them to returnPromise<void>instead ofvoid, 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:chaptersparameter is declared but unused.The
chaptersfield inUseChapterOperationsParamsis 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.
defaultNovelSettingsreferencesNovelSettingsbefore 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: Replaceconsole.errorwith a project logger or suppress the lint rule.ESLint flags
console.errorstatements. 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 extractinguseNovelDomainValueto 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
fetchingistrue(should not callgetNextChapterBatch)- When
batch >= total(should not callgetNextChapterBatch)- When
pagesarray 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
useNovelDomainValuecall 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
novelStorecutover, 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 overvoid.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 forfollowNovel.The
void deps.switchNovelToLibrary(...)pattern triggers the ESLint warning. Additionally, ifswitchNovelToLibraryfails, 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 stabilizingroute.paramsto prevent store recreation.Including
route.paramsdirectly in the dependency array may cause unnecessary store recreation if the params object reference changes between renders while the actualpathandpluginIdvalues remain the same. SincepathandpluginIdare already extracted and used individually, consider removingroute.paramsfrom the deps or extracting theidcheck 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
parseNovelandparsePage. 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
⛔ Files ignored due to path filters (1)
pnpm-lock.yamlis excluded by!**/pnpm-lock.yaml
📒 Files selected for processing (47)
.github/workflows/build.yml.gitignore__mocks__/react-native-mmkv.jspackage.jsonsrc/hooks/__tests__/mocks.tssrc/hooks/__tests__/mocksContract.test.tssrc/hooks/__tests__/useNovel.test.tssrc/hooks/__tests__/useNovelStore.test.tssrc/hooks/persisted/__mocks__/useNovel.tssrc/hooks/persisted/__mocks__/useNovelSettings.tssrc/hooks/persisted/index.tssrc/hooks/persisted/useNovel.tssrc/hooks/persisted/useNovel/__tests__/bootstrapService.test.tssrc/hooks/persisted/useNovel/__tests__/chapterActions.test.tssrc/hooks/persisted/useNovel/__tests__/keyContract.test.tssrc/hooks/persisted/useNovel/__tests__/persistence.test.tssrc/hooks/persisted/useNovel/__tests__/useNovelSettings.test.tssrc/hooks/persisted/useNovel/bootstrapService.tssrc/hooks/persisted/useNovel/chapterActions.tssrc/hooks/persisted/useNovel/contracts.tssrc/hooks/persisted/useNovel/keyContract.tssrc/hooks/persisted/useNovel/novelStore.tssrc/hooks/persisted/useNovel/persistence.tssrc/hooks/persisted/useNovel/types.tssrc/hooks/persisted/useNovel/useChapterOperations.tssrc/hooks/persisted/useNovelSettings.tssrc/hooks/persisted/usePlugins.tssrc/plugins/dev/DevNovelPlugin.tssrc/plugins/dev/DevPagedNovelPlugin.tssrc/plugins/dev/index.tssrc/plugins/pluginManager.tssrc/screens/browse/components/InstalledTab.tsxsrc/screens/novel/NovelContext.tsxsrc/screens/novel/NovelScreen.tsxsrc/screens/novel/__tests__/NovelScreen.test.tsxsrc/screens/novel/components/Info/NovelInfoHeader.tsxsrc/screens/novel/components/NovelScreenList.tsxsrc/screens/novel/components/__tests__/NovelScreenList.test.tsxsrc/screens/reader/components/ChapterDrawer/__tests__/ChapterDrawer.test.tsxsrc/screens/reader/components/ChapterDrawer/index.tsxsrc/screens/reader/hooks/useChapter.tssrc/screens/settings/SettingsAdvancedScreen.tsxsrc/services/migrate/migrateNovel.tssrc/services/plugin/fetch.tssrc/services/updates/LibraryUpdateQueries.tssrc/utils/mmkv/zustand-adapter.tstsconfig.tsbuildinfo
💤 Files with no reviewable changes (1)
- src/screens/browse/components/InstalledTab.tsx
__mocks__/react-native-mmkv.js
Outdated
| 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); | ||
| } | ||
| }), | ||
| ]; |
There was a problem hiding this comment.
Hook setters are recreated on each call, losing mock call history.
createTupleHook returns a new jest.fn() on every invocation. This means:
- Each component render gets a different setter reference.
- 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.
| 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.
| "urlencode": "^2.0.0", | ||
| "zustand": "^5.0.12" |
There was a problem hiding this comment.
🧩 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:
- 1: https://github.com/pmndrs/zustand/releases
- 2: https://www.npmjs.com/package/zustand
- 3: https://github.com/pmndrs/zustand
- 4: https://www.npmjs.com/package/zustand?activeTab=versions
🌐 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:
- 1: https://www.npmjs.com/package/zustand
- 2: https://github.com/pmndrs/zustand/blob/main/package.json
- 3: https://registry.npmjs.org/zustand
- 4: https://depfixer.com/compatibility/react-19-zustand-5
- 5: Peer Dependency Conflicts between react and zustand when upgrade to next15 stable version. pmndrs/zustand#2843
- 6: https://github.com/pmndrs/zustand/releases/tag/v5.0.12
- 7: https://github.com/pmndrs/zustand/releases
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.
| batchInformation: { | ||
| batch: 0, | ||
| total: 0, | ||
| }, |
There was a problem hiding this comment.
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.
| 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`.
| 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); |
There was a problem hiding this comment.
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.
| 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); | ||
| }; |
There was a problem hiding this comment.
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.
| 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.
| 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; |
There was a problem hiding this comment.
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).
src/screens/novel/NovelScreen.tsx
Outdated
| const selectMarkPreviouschaptersRead = (state: NovelStoreState) => | ||
| state.markPreviouschaptersRead; |
There was a problem hiding this comment.
🛠️ 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 2Repository: 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 1Repository: 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 1Repository: 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
selectMarkPreviouschaptersReadtoselectMarkPreviousChaptersRead(line 59) - Rename
markPreviouschaptersReadproperty in store (novelStore.ts) - Rename
markPreviouschaptersReadActionfunction (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.
| 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}`); |
There was a problem hiding this comment.
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.
| 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.
There was a problem hiding this comment.
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 | 🔴 CriticalUpdate all Jest mocks to export
useNovelValueanduseNovelActionsinstead of the non-existentuseNovelContext.The mocks in test files reference
useNovelContext, which is not actually exported fromNovelContext.tsx. The actual exports areuseNovelValueanduseNovelActions. This mismatch causes test failures withTypeError: useNovelValue is not a function.Update the following mock definitions to match the real export surface:
src/screens/novel/__tests__/NovelScreen.test.tsx:31src/screens/novel/components/__tests__/NovelScreenList.test.tsx:9src/hooks/__tests__/mocks.ts:125src/screens/reader/hooks/__tests__/useChapter.test.ts:23src/screens/reader/components/ChapterDrawer/__tests__/ChapterDrawer.test.tsx:13src/hooks/persisted/useNovel/__tests__/useNovelSettings.test.ts:6Each mock should export
useNovelValueanduseNovelActionsmatching the signatures inNovelContext.tsx(e.g.,useNovelValue: (key) => mockValue(key)anduseNovelActions: () => 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:setChapterSortdependency array may cause stale closures.The callback references
novelSettings?.showChapterTitlesand_filter, butnovelSettingsobject itself changes on every settings update. Consider whether including the entirenovelSettingsobject 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 whetherlayoutValuememoization is correct.The
useMemohas an empty dependency array, but the value depends onnavigationBarHeightRef.currentandstatusBarHeightRef.currentwhich 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
📒 Files selected for processing (22)
src/database/manager/manager.tssrc/database/queries/NovelQueries.tssrc/hooks/__tests__/useNovelStore.test.tssrc/hooks/persisted/useNovel/__tests__/novelStore.chapterActions.test.tssrc/hooks/persisted/useNovel/__tests__/novelStore.chapterState.test.tssrc/hooks/persisted/useNovel/bootstrapService.tssrc/hooks/persisted/useNovel/novelStore.actions.tssrc/hooks/persisted/useNovel/novelStore.chapterActions.tssrc/hooks/persisted/useNovel/novelStore.chapterState.tssrc/hooks/persisted/useNovel/novelStore.tssrc/hooks/persisted/useNovel/novelStore.types.tssrc/hooks/persisted/useNovelSettings.tssrc/screens/novel/NovelContext.tsxsrc/screens/novel/NovelScreen.tsxsrc/screens/novel/components/Info/NovelInfoHeader.tsxsrc/screens/novel/components/JumpToChapterModal.tsxsrc/screens/novel/components/NovelScreenList.tsxsrc/screens/reader/components/ChapterDrawer/index.tsxsrc/screens/reader/components/ReaderAppbar.tsxsrc/screens/reader/components/ReaderFooter.tsxsrc/screens/reader/hooks/__tests__/useChapter.test.tssrc/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
| const [data, setData] = useState<ReturnValue>(() => { | ||
| const r = db.executeSync(sqlString, params as any[]).rows as ReturnValue; | ||
| if (callback) callback(r); | ||
| return r; |
There was a problem hiding this comment.
🧩 Analysis chain
🏁 Script executed:
# First, find and read the file
wc -l src/database/manager/manager.tsRepository: 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.
src/database/queries/NovelQueries.ts
Outdated
| export const getNovelById = (novelId: number): NovelInfo | undefined => { | ||
| return dbManager.getSync( | ||
| dbManager.select().from(novelSchema).where(eq(novelSchema.id, novelId)), | ||
| ); | ||
| }; |
There was a problem hiding this comment.
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'; | |||
There was a problem hiding this comment.
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.
src/hooks/persisted/useNovel/__tests__/novelStore.chapterState.test.ts
Outdated
Show resolved
Hide resolved
2547835 to
06f0f66
Compare
…mocksContract Final validation confirms mock-contract test suite clean and target file deletion verified with zero stale references in src/ scope.
There was a problem hiding this comment.
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 | 🔴 CriticalFix 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 | 🟡 MinorKeep this mock aligned with the current novel-store contract.
batchInformation.totalChaptersis still missing, and the promise-returning actions here now resolve toundefinedimmediately because they are plainjest.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 | 🟠 MajorUse
useMemoto recreate the filter manager when the filter or its setter changes.The
useRefinitialization freezesfilterManager.currentto a single instance. WhenwriteNovelSettings()updates_filterfrom 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 viacycle()andset()to operate on outdated data.The provided fix is correct: replace
useRefwithuseMemo([_filter, setChapterFilter]), update allfilterManager.current?.method()calls tofilterManager.method(), and include[filterManager]in the dependency arrays of the callbacks. This ensures the manager is recreated whenever external state changes and theeslint-disable-next-linecomments 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 | 🟠 MajorRemove debug console output from the update path.
console.logon Line 197 is still violatingno-consoleand 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 | 🟡 MinorRemove 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 | 🟡 MinorSeed the legacy keys you assert on.
This test now verifies deletion of
novelPersistence.keys.settings()andnovelPersistence.keys.lastRead(), but the setup still only writes thekeyContract.*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 | 🟡 MinorDifferentiate unsupported pagination from a novel fetch failure.
For
page !== '1'withoutparsePage(), 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 | 🟡 MinorRemove 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 | 🟠 MajorExport the selector hooks from the shared
NovelContextmock too.
src/hooks/persisted/useNovelSettings.tsnow importsuseNovelValueanduseNovelAction, andsrc/screens/novel/components/NovelScreenList.tsximportsuseNovelValueanduseNovelActions. With onlyuseNovelContextmocked 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 | 🟠 MajorMake 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, anddefaultMockNovelContextshares 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 | 🟡 MinorInclude
totalChaptersin the shared batch mock.
batchInformation.totalChaptersis 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 | 🟡 MinorRename 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 typedimport, 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 | 🟠 MajorUpdate the
NovelContextmock to export the selector hooks this component now uses.
NovelScreenListno longer reads everything fromuseNovelContext(). With only that one export mocked here, the test will still fail onuseNovelValue/useNovelActionsbefore 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 | 🟠 MajorDon't turn loader failures into empty successful states.
The catch blocks only
console.errorand 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 | 🟠 MajorScope the inflight cache to the full bootstrap request.
The promise cache is keyed only by
pluginId+novelPath, but the returned chapter state also depends onpageIndex,settingsSort, andsettingsFilter. Two concurrent bootstraps for the same novel with different reader settings can reuse the wrong promise. Additionally, the module-levelMapleaks dedupe across separatecreateBootstrapService()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 | 🟡 MinorFix the zero-based batch total calculation.
Batch
0is already loaded, soMath.floor(chapterCount / 1000)overstatestotalfor 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 | 🟠 MajorReplace console statements with proper logging to fix pipeline failures.
Multiple
console.time,console.error, andconsole.timeEndstatements are causing ESLintno-consolefailures. 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 | 🟠 MajorState/persistence inconsistency: clamped value persisted but raw value stored in memory.
Line 194 persists
Math.min(progress, 100), but line 204 stores the rawprogressin local state. Ifprogress > 100, the DB will have100while 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 | 🟡 MinorMissing error handling on delete operations.
deleteChapteranddeleteChaptersuse.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 | 🟡 MinorReject negative or fractional page indices.
Number.isFiniteaccepts values like-1and1.5, so the bridge can persist/read indices that causepages[pageIndex]to resolve toundefined, 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: Replaceconsole.errorwith a logging utility.The
console.errorstatements violate theno-consoleESLint 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
setItemandremoveItem.🤖 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
setItemfailures 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 movingparseTimeoutside 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 shadowedReactvariable in mock factories.ESLint flags that
Reactis 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 tokeyContract.*.Looking at
persistence.ts(context snippet 2),novelPersistence.keys.pageIndexcallskeyContract.pageIndexinternally. 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 duplicatedtotalPagescalculation logic.Both
parseNovel(lines 258-294) andparsePage(lines 318-354) contain identical logic for:
- Fetching
currentChaptersfrom the database- Calculating
totalPagesbased on settingsSince 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
parseNovelandparsePage.🤖 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
setoperation will overwrite regardless, making thedeleteunnecessary.♻️ 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
⛔ Files ignored due to path filters (1)
pnpm-lock.yamlis excluded by!**/pnpm-lock.yaml
📒 Files selected for processing (62)
__mocks__/react-native-nitro-modules.jspackage.jsonsrc/database/manager/manager.tssrc/database/queries/ChapterQueries.tssrc/database/queries/NovelQueries.tssrc/database/utils/filter.tssrc/database/utils/parser.tssrc/hooks/__tests__/mocks.tssrc/hooks/__tests__/mocksContract.test.tssrc/hooks/__tests__/useNovel.test.tssrc/hooks/__tests__/useNovelStore.test.tssrc/hooks/persisted/__mocks__/useNovel.tssrc/hooks/persisted/__mocks__/useNovelSettings.tssrc/hooks/persisted/index.tssrc/hooks/persisted/useNovel.tssrc/hooks/persisted/useNovel/__tests__/bootstrapService.test.tssrc/hooks/persisted/useNovel/__tests__/chapterActions.test.tssrc/hooks/persisted/useNovel/__tests__/keyContract.test.tssrc/hooks/persisted/useNovel/__tests__/novelStore.chapterActions.test.tssrc/hooks/persisted/useNovel/__tests__/novelStore.chapterState.test.tssrc/hooks/persisted/useNovel/__tests__/persistence.test.tssrc/hooks/persisted/useNovel/__tests__/useNovelSettings.test.tssrc/hooks/persisted/useNovel/store-helper/bootstrapService.tssrc/hooks/persisted/useNovel/store-helper/contracts.tssrc/hooks/persisted/useNovel/store-helper/keyContract.tssrc/hooks/persisted/useNovel/store-helper/persistence.tssrc/hooks/persisted/useNovel/store/chapterActions.tssrc/hooks/persisted/useNovel/store/createStore.tssrc/hooks/persisted/useNovel/store/novelStore.actions.tssrc/hooks/persisted/useNovel/store/novelStore.chapterActions.tssrc/hooks/persisted/useNovel/store/novelStore.chapterState.tssrc/hooks/persisted/useNovel/store/novelStore.tssrc/hooks/persisted/useNovel/store/novelStore.types.tssrc/hooks/persisted/useNovel/types.tssrc/hooks/persisted/useNovel/useChapterOperations.tssrc/hooks/persisted/useNovelSettings.tssrc/hooks/persisted/usePlugins.tssrc/plugins/dev/DevNovelPlugin.tssrc/plugins/dev/DevPagedNovelPlugin.tssrc/plugins/dev/index.tssrc/plugins/pluginManager.tssrc/screens/browse/components/InstalledTab.tsxsrc/screens/novel/NovelContext.tsxsrc/screens/novel/NovelScreen.tsxsrc/screens/novel/__tests__/NovelScreen.test.tsxsrc/screens/novel/components/ChapterItem.tsxsrc/screens/novel/components/Info/NovelInfoHeader.tsxsrc/screens/novel/components/JumpToChapterModal.tsxsrc/screens/novel/components/NovelScreenList.tsxsrc/screens/novel/components/__tests__/NovelScreenList.test.tsxsrc/screens/reader/components/ChapterDrawer/__tests__/ChapterDrawer.test.tsxsrc/screens/reader/components/ChapterDrawer/index.tsxsrc/screens/reader/components/ReaderAppbar.tsxsrc/screens/reader/components/ReaderFooter.tsxsrc/screens/reader/hooks/__tests__/useChapter.test.tssrc/screens/reader/hooks/useChapter.tssrc/screens/settings/SettingsAdvancedScreen.tsxsrc/services/migrate/migrateNovel.tssrc/services/plugin/fetch.tssrc/services/updates/LibraryUpdateQueries.tssrc/utils/mmkv/zustand-adapter.tstsconfig.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
| 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, | ||
| ); |
There was a problem hiding this comment.
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.
| 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))), | ||
| ); | ||
| }; |
There was a problem hiding this comment.
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.
| 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; | ||
| } |
There was a problem hiding this comment.
🧩 Analysis chain
🏁 Script executed:
#!/bin/bash
# Find usages of toFilterObject to understand expected behavior
rg -n "toFilterObject" --type tsRepository: 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:
- Remove the method if it's unused dead code, or
- 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.
| 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.
src/hooks/persisted/useNovel/__tests__/novelStore.chapterActions.test.ts
Outdated
Show resolved
Hide resolved
| 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; | ||
| } |
There was a problem hiding this comment.
🧩 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 tsRepository: CD-Z/lnreader
Length of output: 2538
🏁 Script executed:
rg -n 'markPrevious.*[Uu]nread' --type tsRepository: CD-Z/lnreader
Length of output: 3953
🏁 Script executed:
sed -n '38,55p' src/hooks/persisted/useNovel/store/novelStore.types.tsRepository: 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.
| 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.
| 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], | ||
| ); |
There was a problem hiding this comment.
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.
| 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].
| const allInstalledPlugins = [...devPlugins, ...installedPlugins]; | ||
|
|
||
| setFilteredInstalledPlugins( | ||
| installedPlugins.filter(plg => filter.includes(plg.lang)), | ||
| allInstalledPlugins.filter(plg => filter.includes(plg.lang)), |
There was a problem hiding this comment.
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.
| 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.
| const layoutValue = useMemo( | ||
| () => ({ | ||
| ...novelHookContent, | ||
| navigationBarHeight: NavigationBarHeight.current, | ||
| statusBarHeight: StatusBarHeight.current, | ||
| chapterTextCache: chapterTextCache.current, | ||
| navigationBarHeight: navigationBarHeightRef.current, | ||
| statusBarHeight: statusBarHeightRef.current, | ||
| }), | ||
| [novelHookContent], | ||
| [], | ||
| ); |
There was a problem hiding this comment.
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.
| 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.
There was a problem hiding this comment.
Actionable comments posted: 2
♻️ Duplicate comments (4)
src/hooks/__tests__/mocks.ts (2)
56-59:⚠️ Potential issue | 🟡 MinorInclude
totalChaptersin the sharedbatchInformationmock.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 | 🟠 MajorMake the mock store obey the external-store contract.
useNovelStore()insrc/screens/novel/NovelContext.tsxis selector-based, so a no-opsubscribe()masks rerender behavior, and the singletondefaultMockNovelContextlets 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 | 🟡 MinorSeed the same persistence keys this test asserts on.
This setup still writes
keyContract.settings/lastRead, but the assertions below only verifynovelPersistence.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 | 🔴 CriticalRemove the nonexistent
getNextChapterBatchprop from these renders.
NovelScreenListgets that action fromuseNovelActions()insrc/screens/novel/components/NovelScreenList.tsx:73-80; it is not part ofNovelScreenListProps. 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
📒 Files selected for processing (13)
src/database/queries/__tests__/NovelQueries.test.tssrc/database/queries/__tests__/setup.tssrc/hooks/__tests__/mocks.tssrc/hooks/__tests__/useNovel.test.tssrc/hooks/__tests__/useNovelStore.test.tssrc/hooks/persisted/useNovel/__tests__/bootstrapService.test.tssrc/hooks/persisted/useNovel/__tests__/novelStore.chapterActions.test.tssrc/hooks/persisted/useNovel/__tests__/novelStore.chapterState.test.tssrc/hooks/persisted/useNovel/__tests__/useNovelSettings.test.tssrc/screens/novel/__tests__/NovelScreen.test.tsxsrc/screens/novel/components/__tests__/NovelScreenList.test.tsxsrc/screens/reader/components/ChapterDrawer/__tests__/ChapterDrawer.test.tsxsrc/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
| expect(Boolean(result?.inLibrary)).toBe(true); | ||
| const novel = getNovelById(novelId); | ||
| expect(Boolean(novel?.inLibrary)).toBe(true); |
There was a problem hiding this comment.
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.
| beforeEach(() => { | ||
| jest.clearAllMocks(); | ||
| jest.spyOn(console, 'time').mockImplementation(() => {}); | ||
| jest.spyOn(console, 'timeEnd').mockImplementation(() => {}); | ||
| jest.spyOn(console, 'error').mockImplementation(() => {}); | ||
| }); |
There was a problem hiding this comment.
🧩 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.tsRepository: CD-Z/lnreader
Length of output: 284
🏁 Script executed:
cat -n src/hooks/persisted/useNovel/__tests__/bootstrapService.test.tsRepository: CD-Z/lnreader
Length of output: 11845
🏁 Script executed:
rg -n "afterEach|afterAll" src/hooks/persisted/useNovel/__tests__/bootstrapService.test.tsRepository: 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:
- 1: https://jestjs.io/docs/mock-function-api/
- 2: https://jestjs.io/docs/jest-object
- 3: https://jestjs.io/docs/next/jest-object
- 4: https://stackoverflow.com/questions/58151010/difference-between-resetallmocks-resetmodules-resetmoduleregistry-restoreallm/66361187
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.
| 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.
There was a problem hiding this comment.
Actionable comments posted: 1
🧹 Nitpick comments (4)
src/screens/novel/components/NovelScreenList.tsx (2)
518-518: Clarify or remove the commented-outdrawDistanceprop.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 multipleuseNovelValuecalls.Each
useNovelValuecall 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. IfdeleteChapterActioninternally 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-forgetgetChapters()call may cause race conditions.
setNovelSettingstriggersstate.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:
- Making
setNovelSettingsasync and awaitinggetChapters()- 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
📒 Files selected for processing (12)
src/hooks/persisted/useNovel/__tests__/chapterActions.test.tssrc/hooks/persisted/useNovel/__tests__/novelStore.chapterActions.test.tssrc/hooks/persisted/useNovel/store-helper/bootstrapService.tssrc/hooks/persisted/useNovel/store/chapterActions.tssrc/hooks/persisted/useNovel/store/createStore.tssrc/hooks/persisted/useNovel/store/novelStore.actions.tssrc/hooks/persisted/useNovel/store/novelStore.chapterActions.tssrc/hooks/persisted/useNovel/store/novelStore.types.tssrc/screens/novel/NovelScreen.tsxsrc/screens/novel/components/Info/NovelInfoHeader.tsxsrc/screens/novel/components/NovelScreenList.tsxsrc/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
| 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, | ||
| }); | ||
| }, |
There was a problem hiding this comment.
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.
Summary by CodeRabbit
New Features
Improvements
Chores