Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
c8f5bcd
init
CD-Z Feb 27, 2026
a3c7b57
fixed paged
CD-Z Feb 27, 2026
6da8ca0
init
CD-Z Apr 7, 2026
9b41dc3
feat: add zustand dependency and persistence key contract
CD-Z Apr 7, 2026
4ce7f22
refactor: extract bootstrap data loading into reusable service
CD-Z Mar 25, 2026
0dde694
refactor: move chapter mutations into store-ready action helpers
CD-Z Mar 25, 2026
b2b1a26
feat: add zustand novel store with cache and core actions
CD-Z Mar 25, 2026
4820a2a
refactor: bridge novel persistence contracts for migration safety
CD-Z Mar 25, 2026
9538c06
refactor: migrate NovelScreen domain flows to zustand selectors
CD-Z Mar 25, 2026
05f4ae3
refactor: migrate NovelScreenList to selector-based store access
CD-Z Mar 25, 2026
a3c4d1b
refactor: move reader chapter flows onto store boundaries
CD-Z Mar 25, 2026
e1315de
refactor: decouple useNovelSettings from broad context domain state
CD-Z Mar 25, 2026
55fff01
refactor: align migrateNovel with stable persistence contracts
CD-Z Mar 25, 2026
a6bf396
refactor: cut novel-reader consumers to store-only context boundary
CD-Z Apr 7, 2026
1e0c847
refactor: retire legacy useNovel and route cache cleanup export
CD-Z Apr 7, 2026
3830b61
test: update suites for store-only context boundary cutover
CD-Z Mar 25, 2026
6a34fe9
test: modernize store-era mocks and add contract coverage
CD-Z Apr 7, 2026
626cd54
test: finalize Task-15 sweep—remove dead useNovelData and lint clear …
CD-Z Mar 25, 2026
0548be9
remove imports from NovelScreen
CD-Z Mar 26, 2026
8eb37ec
reworked ai output
CD-Z Mar 27, 2026
354c638
improvements
CD-Z Mar 28, 2026
db25722
implemented synchronus novel and chapter fetch
CD-Z Apr 10, 2026
e9015ad
refactor tests
CD-Z Apr 11, 2026
1ecd3f3
fix db tests
CD-Z Apr 11, 2026
2a6e43b
Update remaining tests.
CD-Z Apr 11, 2026
5af4698
Harden chapter actions and bootstrap flows
CD-Z Apr 11, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -118,7 +118,8 @@
"react-native-worklets": "^0.8.1",
"react-native-zip-archive": "^7.0.2",
"sanitize-html": "^2.17.2",
"urlencode": "^2.0.0"
"urlencode": "^2.0.0",
"zustand": "^5.0.12"
Comment on lines +121 to +122
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🌐 Web query:

What is the latest version of zustand npm package?

💡 Result:

The latest version of the zustand npm package is 5.0.12.

Citations:


🌐 Web query:

Zustand 5.0.12 React 19 compatibility

💡 Result:

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

Citations:


Zustand 5.0.12 is not officially compatible with React 19.

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

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

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

},
"devDependencies": {
"@babel/core": "^7.29.0",
Expand Down
27 changes: 27 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

45 changes: 42 additions & 3 deletions src/database/manager/manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,9 +71,9 @@ class DbManager implements IDbManager {
>;
}

public async allSync<T extends ExecutableSelect>(
public allSync<T extends ExecutableSelect>(
query: T,
): Promise<Awaited<ReturnType<T['all']>>> {
): Awaited<ReturnType<T['all']>> {
const { sql: sqlString, params } = query.toSQL();
return db.executeSync(sqlString, params as any[]).rows as Awaited<
ReturnType<T['all']>
Expand Down Expand Up @@ -135,6 +135,40 @@ type FireOn = Array<{ table: TableNames; ids?: number[] }>;
export function useLiveQuery<T extends ExecutableSelect>(
query: T,
fireOn: FireOn,
callback?: (data: Awaited<ReturnType<T['all']>>) => void,
) {
type ReturnValue = Awaited<ReturnType<T['all']>>;

const { sql: sqlString, params } = query.toSQL();
const paramsKey = JSON.stringify(params);
const fireOnKey = JSON.stringify(fireOn);

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

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

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

Repository: CD-Z/lnreader

Length of output: 92


🏁 Script executed:

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

Repository: CD-Z/lnreader

Length of output: 2251


🏁 Script executed:

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

Repository: CD-Z/lnreader

Length of output: 1346


🏁 Script executed:

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

Repository: CD-Z/lnreader

Length of output: 1293


Make callback delivery lifecycle-safe.

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

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

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

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

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

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

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

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

});

useEffect(() => {
const unsub = db.reactiveExecute({
query: sqlString,
arguments: params as any[],
fireOn,
callback: (result: { rows: ReturnValue }) => {
setData(result.rows);
if (callback) callback(result.rows);
},
});
return unsub;
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [sqlString, paramsKey, fireOnKey]);

return data;
}
export function useLiveQueryAsync<T extends ExecutableSelect>(
query: T,
fireOn: FireOn,
callback?: (data: Awaited<ReturnType<T['all']>>) => void,
) {
type ReturnValue = Awaited<ReturnType<T['all']>>;

Expand All @@ -143,7 +177,11 @@ export function useLiveQuery<T extends ExecutableSelect>(
const fireOnKey = JSON.stringify(fireOn);

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,
);
Comment on lines 179 to 185
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🔴 Critical

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

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

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

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


useEffect(() => {
Expand All @@ -153,6 +191,7 @@ export function useLiveQuery<T extends ExecutableSelect>(
fireOn,
callback: (result: { rows: ReturnValue }) => {
setData(result.rows);
if (callback) callback(result.rows);
},
});
return unsub;
Expand Down
95 changes: 69 additions & 26 deletions src/database/queries/ChapterQueries.ts
Original file line number Diff line number Diff line change
Expand Up @@ -300,23 +300,61 @@ export const clearUpdates = async (): Promise<void> => {
// #endregion
// #region Selectors

export const getCustomPages = async (novelId: number) => {
return await dbManager
.selectDistinct({ page: chapterSchema.page })
.from(chapterSchema)
.where(eq(chapterSchema.novelId, novelId))
.orderBy(asc(castInt(chapterSchema.page)))
.all();
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))),
);
};
Comment on lines +303 to 311
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

Sync change may cause test inconsistency.

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

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

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


export const getNovelChapters = async (
novelId: number,
sort?: ChapterOrderKey,
filter?: ChapterFilterKey[],
page?: string,
limit: number = 1000,
): Promise<ChapterInfo[]> =>
dbManager
.select()
.from(chapterSchema)
.where(eq(chapterSchema.novelId, novelId));
.where(
and(
eq(chapterSchema.novelId, novelId),
!page ? sql.raw('true') : eq(chapterSchema.page, page),
chapterFilterToSQL(filter),
),
)
.orderBy(chapterOrderToSQL(sort))
.limit(limit)
.all();

export const getNovelChaptersSync = (
novelId: number,
sort?: ChapterOrderKey,
filter?: ChapterFilterKey[],
page?: string,
limit: number = 1000,
): ChapterInfo[] =>
dbManager.allSync(
dbManager
.select()
.from(chapterSchema)
.where(
and(
eq(chapterSchema.novelId, novelId),
!page ? sql.raw('true') : eq(chapterSchema.page, page),
chapterFilterToSQL(filter),
),
)
.orderBy(chapterOrderToSQL(sort))
.limit(limit), // Adding a limit to prevent potential performance issues with large datasets
);
/**
* @deprecated, use getNovelChapters with whereConditions instead
*/
export const getUnreadNovelChapters = async (
novelId: number,
): Promise<ChapterInfo[]> =>
Expand All @@ -326,7 +364,9 @@ export const getUnreadNovelChapters = async (
.where(
and(eq(chapterSchema.novelId, novelId), eq(chapterSchema.unread, true)),
);

/**
* @deprecated, use getNovelChapters with whereConditions instead
*/
export const getAllUndownloadedChapters = async (
novelId: number,
): Promise<ChapterInfo[]> =>
Expand All @@ -339,7 +379,9 @@ export const getAllUndownloadedChapters = async (
eq(chapterSchema.isDownloaded, false),
),
);

/**
* @deprecated, use getNovelChapters with whereConditions instead
*/
export const getAllUndownloadedAndUnreadChapters = async (
novelId: number,
): Promise<ChapterInfo[]> =>
Expand Down Expand Up @@ -408,8 +450,8 @@ export const getPageChaptersBatched = async (
page?: string,
batch: number = 0,
) => {
const limit = 300;
const offset = 300 * batch;
const limit = 1000;
const offset = 1000 * batch;
const query = dbManager
.select()
.from(chapterSchema)
Expand Down Expand Up @@ -451,20 +493,21 @@ export const getFirstUnreadChapter = (
filter?: ChapterFilterKey[],
page?: string,
) =>
dbManager
.select()
.from(chapterSchema)
.where(
and(
eq(chapterSchema.novelId, novelId),
eq(chapterSchema.page, page || '1'),
eq(chapterSchema.unread, true),
chapterFilterToSQL(filter),
),
)
.orderBy(asc(chapterSchema.position))
.limit(1)
.get();
dbManager.getSync(
dbManager
.select()
.from(chapterSchema)
.where(
and(
eq(chapterSchema.novelId, novelId),
eq(chapterSchema.page, page || '1'),
eq(chapterSchema.unread, true),
chapterFilterToSQL(filter),
),
)
.orderBy(asc(chapterSchema.position))
.limit(1),
);

export const getNovelChaptersByName = async (
novelId: number,
Expand Down
17 changes: 6 additions & 11 deletions src/database/queries/NovelQueries.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import { insertChapters } from './ChapterQueries';

import { showToast } from '@utils/showToast';
import { getString } from '@strings/translations';
import { BackupNovel, NovelInfo } from '../types';
import { BackupNovel, DBNovelInfo, NovelInfo } from '../types';
import { SourceNovel } from '@plugins/types';
import { NOVEL_STORAGE } from '@utils/Storages';
import { downloadFile } from '@plugins/helpers/fetch';
Expand Down Expand Up @@ -82,21 +82,16 @@ export const getAllNovels = async (): Promise<NovelInfo[]> => {
return dbManager.select().from(novelSchema).all();
};

export const getNovelById = async (
novelId: number,
): Promise<NovelInfo | undefined> => {
const res = dbManager
.select()
.from(novelSchema)
.where(eq(novelSchema.id, novelId))
.get();
return res;
export const getNovelById = (novelId: number): DBNovelInfo | undefined => {
return dbManager.getSync(
dbManager.select().from(novelSchema).where(eq(novelSchema.id, novelId)),
);
};

export const getNovelByPath = (
novelPath: string,
pluginId: string,
): NovelInfo | undefined => {
): DBNovelInfo | undefined => {
const res = dbManager.getSync(
dbManager
.select()
Expand Down
16 changes: 8 additions & 8 deletions src/database/queries/__tests__/NovelQueries.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -147,9 +147,9 @@ describe('NovelQueries', () => {
'test-plugin',
);

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

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

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

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

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

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

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

Also applies to: 168-170, 214-215

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

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

});

it('should remove novel from library', async () => {
Expand All @@ -165,9 +165,9 @@ describe('NovelQueries', () => {
'test-plugin',
);

expect(result?.inLibrary).toBe(false);
const novel = await getNovelById(novelId);
expect(novel?.inLibrary).toBe(false);
expect(Boolean(result?.inLibrary)).toBe(false);
const novel = getNovelById(novelId);
expect(Boolean(novel?.inLibrary)).toBe(false);
});

it('should assign default category when adding to library', async () => {
Expand Down Expand Up @@ -211,8 +211,8 @@ describe('NovelQueries', () => {

const novel1 = await getNovelById(novelId1);
const novel2 = await getNovelById(novelId2);
expect(novel1?.inLibrary).toBe(false);
expect(novel2?.inLibrary).toBe(false);
expect(Boolean(novel1?.inLibrary)).toBe(false);
expect(Boolean(novel2?.inLibrary)).toBe(false);
});

it('should handle empty array', async () => {
Expand Down
21 changes: 0 additions & 21 deletions src/database/queries/__tests__/setup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -114,27 +114,6 @@ jest.mock('expo-document-picker', () => ({
}),
}));

// Mock database utilities
jest.mock('@database/utils/parser', () => ({
chapterFilterToSQL: jest.fn().mockReturnValue(undefined),
chapterOrderToSQL: jest.fn().mockReturnValue(undefined),
}));

// Mock database constants
jest.mock('@database/constants', () => ({
ChapterFilterKey: {
UNREAD: 'unread',
DOWNLOADED: 'downloaded',
BOOKMARKED: 'bookmarked',
},
ChapterOrderKey: {
BY_SOURCE: 'bySource',
BY_SOURCE_DESC: 'bySourceDesc',
BY_CHAPTER_NUMBER: 'byChapterNumber',
BY_CHAPTER_NUMBER_DESC: 'byChapterNumberDesc',
},
}));

// Mock lodash-es to avoid ES module issues
jest.mock('lodash-es', () => {
const lodash = jest.requireActual('lodash');
Expand Down
Loading
Loading