Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
a7ec856
init
CD-Z Feb 27, 2026
016829d
fixed paged
CD-Z Feb 27, 2026
2926090
init
CD-Z Feb 27, 2026
f221096
chore: ignore local orchestration workspace files
CD-Z Mar 25, 2026
f36a7e3
feat: add zustand dependency and persistence key contract
CD-Z Mar 25, 2026
8cc87fa
refactor: extract bootstrap data loading into reusable service
CD-Z Mar 25, 2026
4ff0e9e
refactor: move chapter mutations into store-ready action helpers
CD-Z Mar 25, 2026
826e1e2
feat: add zustand novel store with cache and core actions
CD-Z Mar 25, 2026
82fbaf4
refactor: bridge novel persistence contracts for migration safety
CD-Z Mar 25, 2026
02160b0
refactor: migrate NovelScreen domain flows to zustand selectors
CD-Z Mar 25, 2026
d7cf10f
refactor: migrate NovelScreenList to selector-based store access
CD-Z Mar 25, 2026
c4d297c
refactor: move reader chapter flows onto store boundaries
CD-Z Mar 25, 2026
91a37ff
refactor: decouple useNovelSettings from broad context domain state
CD-Z Mar 25, 2026
f1a32a7
refactor: align migrateNovel with stable persistence contracts
CD-Z Mar 25, 2026
d428a5c
refactor: cut novel-reader consumers to store-only context boundary
CD-Z Mar 25, 2026
5c45ebb
refactor: retire legacy useNovel and route cache cleanup export
CD-Z Mar 25, 2026
d814b48
test: update suites for store-only context boundary cutover
CD-Z Mar 25, 2026
73628cc
test: modernize store-era mocks and add contract coverage
CD-Z Mar 25, 2026
d3465fa
ci: run db and rn test gates before android build
CD-Z Mar 25, 2026
3a4f569
test: finalize Task-15 sweep—remove dead useNovelData and lint clear …
CD-Z Mar 25, 2026
ce3ea89
remove imports from NovelScreen
CD-Z Mar 26, 2026
13b3412
reworked ai output
CD-Z Mar 27, 2026
29de6d4
improvements
CD-Z Mar 28, 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
6 changes: 6 additions & 0 deletions .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,12 @@ jobs:
- name: Install Dependencies
run: pnpm install --frozen-lockfile

- name: Run DB Tests
run: pnpm test:db

- name: Run RN Tests
run: pnpm test:rn

- name: Create Environment File
run: |
cat > .env << EOF
Expand Down
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -93,3 +93,4 @@ flake.lock
.agents/
.claude/
.jj/
.sisyphus/
122 changes: 118 additions & 4 deletions __mocks__/react-native-mmkv.js
Original file line number Diff line number Diff line change
@@ -1,9 +1,123 @@
// Mock for react-native-mmkv (v3 uses NitroModules under the hood)
const strings = new Map();
const numbers = new Map();
const booleans = new Map();
const buffers = new Map();

class MMKV {
set(key, value) {
if (typeof value === 'string') {
strings.set(key, value);
numbers.delete(key);
booleans.delete(key);
buffers.delete(key);
return;
}

if (typeof value === 'number') {
numbers.set(key, value);
strings.delete(key);
booleans.delete(key);
buffers.delete(key);
return;
}

if (typeof value === 'boolean') {
booleans.set(key, value);
strings.delete(key);
numbers.delete(key);
buffers.delete(key);
return;
}

buffers.set(key, value);
strings.delete(key);
numbers.delete(key);
booleans.delete(key);
}

getString(key) {
return strings.get(key);
}

getNumber(key) {
return numbers.get(key);
}

getBoolean(key) {
return booleans.get(key);
}

getBuffer(key) {
return buffers.get(key);
}

contains(key) {
return (
strings.has(key) ||
numbers.has(key) ||
booleans.has(key) ||
buffers.has(key)
);
}

delete(key) {
strings.delete(key);
numbers.delete(key);
booleans.delete(key);
buffers.delete(key);
}

clearAll() {
strings.clear();
numbers.clear();
booleans.clear();
buffers.clear();
}
}

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);
}
}),
];
Comment on lines +79 to +101
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

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

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

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

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

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

Remember to clear setterCache in a beforeEach if needed.

📝 Committable suggestion

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

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

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


module.exports = {
MMKV,
useMMKVString: createTupleHook(key => strings.get(key)),
useMMKVNumber: createTupleHook(key => numbers.get(key)),
useMMKVBoolean: createTupleHook(key => booleans.get(key)),
useMMKVObject: createTupleHook(key => {
const json = strings.get(key);
if (!json) {
return undefined;
}

try {
return JSON.parse(json);
} catch {
return undefined;
}
}),
NitroModules: {
createHybridObject: jest.fn(() => {
// Return a mock object that won't be used since MMKV has its own mock
return {};
}),
createHybridObject: jest.fn(() => ({})),
},
};
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -114,7 +114,8 @@
"react-native-worklets": "^0.7.4",
"react-native-zip-archive": "^7.0.2",
"sanitize-html": "^2.17.1",
"urlencode": "^2.0.0"
"urlencode": "^2.0.0",
"zustand": "^5.0.12"
Comment on lines +117 to +118
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,
);

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
13 changes: 4 additions & 9 deletions src/database/queries/NovelQueries.ts
Original file line number Diff line number Diff line change
Expand Up @@ -82,15 +82,10 @@ 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): NovelInfo | undefined => {
return dbManager.getSync(
dbManager.select().from(novelSchema).where(eq(novelSchema.id, novelId)),
);
};
Comment on lines +85 to 89
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Don't silently flip this shared query to sync.

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

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

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


export const getNovelByPath = (
Expand Down
96 changes: 96 additions & 0 deletions src/hooks/__tests__/mocks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,3 +29,99 @@ jest.mock('@hooks/persisted/useTrackedNovel');
jest.mock('@hooks/persisted/useUpdates');
jest.mock('@services/plugin/fetch');
jest.mock('@components/Context/LibraryContext');

const createMockChapterTextCache = () => {
const cache = new Map<number, string | Promise<string>>();

return {
read: jest.fn((chapterId: number) => cache.get(chapterId)),
write: jest.fn((chapterId: number, value: string | Promise<string>) => {
cache.set(chapterId, value);
}),
remove: jest.fn((chapterId: number) => cache.delete(chapterId)),
clear: jest.fn(() => cache.clear()),
};
};

export const createMockNovelStoreState = (
overrides: Record<string, unknown> = {},
) => ({
loading: false,
fetching: false,
pageIndex: 0,
pages: ['1'],
novel: undefined,
chapters: [],
firstUnreadChapter: undefined,
batchInformation: {
batch: 0,
total: 0,
},
Comment on lines +56 to +59
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

Include totalChapters in the shared mock batch state.

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

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

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

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

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

novelSettings: {
filter: [],
showChapterTitles: true,
},
chapterTextCache: createMockChapterTextCache(),
lastRead: undefined,

bootstrapNovel: jest.fn().mockResolvedValue(true),
getChapters: jest.fn().mockResolvedValue(undefined),
getNextChapterBatch: jest.fn().mockResolvedValue(undefined),
loadUpToBatch: jest.fn().mockResolvedValue(undefined),
refreshNovel: jest.fn().mockResolvedValue(undefined),

setNovel: jest.fn(),
setPages: jest.fn(),
setPageIndex: jest.fn(),
openPage: jest.fn().mockResolvedValue(undefined),
setNovelSettings: jest.fn(),
setLastRead: jest.fn(),
followNovel: jest.fn(),

updateChapter: jest.fn(),
setChapters: jest.fn(),
extendChapters: jest.fn(),

bookmarkChapters: jest.fn(),
markPreviouschaptersRead: jest.fn(),
markChapterRead: jest.fn(),
markChaptersRead: jest.fn(),
markPreviousChaptersUnread: jest.fn(),
markChaptersUnread: jest.fn(),
updateChapterProgress: jest.fn(),
deleteChapter: jest.fn(),
deleteChapters: jest.fn(),
refreshChapters: jest.fn(),
...overrides,
});

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);
Comment on lines +98 to +123
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Make the mock store obey the external-store contract.

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

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

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

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


jest.mock('@screens/novel/NovelContext', () => ({
useNovelContext: () => mockUseNovelContext(),
}));
Loading
Loading