diff --git a/patches/@legendapp__list@3.3.3.patch b/patches/@legendapp__list@3.3.3.patch
new file mode 100644
index 00000000..914356bb
--- /dev/null
+++ b/patches/@legendapp__list@3.3.3.patch
@@ -0,0 +1,62 @@
+diff --git a/react-native.js b/react-native.js
+index 229f09a8230ab24079d471f65d28d36908f0f2ea..5c9a6c2501d8de3c44e138593831f3442b4b9204 100644
+--- a/react-native.js
++++ b/react-native.js
+@@ -7256,15 +7256,17 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded
+ useWindowScroll: useWindowScrollResolved
+ };
+ state.refScroller = refScroller;
+- if (!isFirstLocal && previousAdaptiveRender && !experimental_adaptiveRender) {
+- resetAdaptiveRender(ctx);
+- }
+- if (shouldResetFreshDataLayout) {
+- resetInitialRenderState(ctx, {
+- resetInitialScroll: !!initialScrollProp,
+- resetLayout: true
+- });
+- }
++ React2.useLayoutEffect(() => {
++ if (!isFirstLocal && previousAdaptiveRender && !experimental_adaptiveRender) {
++ resetAdaptiveRender(ctx);
++ }
++ if (shouldResetFreshDataLayout) {
++ resetInitialRenderState(ctx, {
++ resetInitialScroll: !!initialScrollProp,
++ resetLayout: true
++ });
++ }
++ }, [experimental_adaptiveRender, initialScrollProp, isFirstLocal, previousAdaptiveRender, shouldResetFreshDataLayout]);
+ const memoizedLastItemKeys = React2.useMemo(() => {
+ if (!dataProp.length) return [];
+ return Array.from(
+diff --git a/react-native.mjs b/react-native.mjs
+index c2e0f3823f524e31e2105a5fbda76be78c926226..dc0c9c6d5b887968c631dd0ba1646fd40ba28c63 100644
+--- a/react-native.mjs
++++ b/react-native.mjs
+@@ -7235,15 +7235,17 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded
+ useWindowScroll: useWindowScrollResolved
+ };
+ state.refScroller = refScroller;
+- if (!isFirstLocal && previousAdaptiveRender && !experimental_adaptiveRender) {
+- resetAdaptiveRender(ctx);
+- }
+- if (shouldResetFreshDataLayout) {
+- resetInitialRenderState(ctx, {
+- resetInitialScroll: !!initialScrollProp,
+- resetLayout: true
+- });
+- }
++ useLayoutEffect(() => {
++ if (!isFirstLocal && previousAdaptiveRender && !experimental_adaptiveRender) {
++ resetAdaptiveRender(ctx);
++ }
++ if (shouldResetFreshDataLayout) {
++ resetInitialRenderState(ctx, {
++ resetInitialScroll: !!initialScrollProp,
++ resetLayout: true
++ });
++ }
++ }, [experimental_adaptiveRender, initialScrollProp, isFirstLocal, previousAdaptiveRender, shouldResetFreshDataLayout]);
+ const memoizedLastItemKeys = useMemo(() => {
+ if (!dataProp.length) return [];
+ return Array.from(
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 13f727b8..0b4ff1e9 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -4,6 +4,9 @@ settings:
autoInstallPeers: true
excludeLinksFromLockfile: false
+patchedDependencies:
+ '@legendapp/list@3.3.3': 37a9c8d7d9c1bd33c957ba6ea78291c7b08532dc32de4925f3d612cb41f1a659
+
importers:
.:
@@ -16,7 +19,7 @@ importers:
version: 5.2.14(8883bda3e7ffd4622bceabc44263e41c)
'@legendapp/list':
specifier: ^3.3.3
- version: 3.3.3(react-native@0.86.0(@babel/core@7.29.7(supports-color@9.4.0))(@react-native/jest-preset@0.86.0(@babel/core@7.29.7(supports-color@9.4.0))(react@19.2.3))(@react-native/metro-config@0.86.0(@babel/core@7.29.7(supports-color@9.4.0)))(@types/react@19.2.17)(react@19.2.3)(supports-color@9.4.0))(react@19.2.3)
+ version: 3.3.3(patch_hash=37a9c8d7d9c1bd33c957ba6ea78291c7b08532dc32de4925f3d612cb41f1a659)(react-native@0.86.0(@babel/core@7.29.7(supports-color@9.4.0))(@react-native/jest-preset@0.86.0(@babel/core@7.29.7(supports-color@9.4.0))(react@19.2.3))(@react-native/metro-config@0.86.0(@babel/core@7.29.7(supports-color@9.4.0)))(@types/react@19.2.17)(react@19.2.3)(supports-color@9.4.0))(react@19.2.3)
'@noble/ciphers':
specifier: ^2.2.0
version: 2.2.0
@@ -7559,7 +7562,7 @@ snapshots:
dependencies:
jsbi: 4.3.2
- '@legendapp/list@3.3.3(react-native@0.86.0(@babel/core@7.29.7(supports-color@9.4.0))(@react-native/jest-preset@0.86.0(@babel/core@7.29.7(supports-color@9.4.0))(react@19.2.3))(@react-native/metro-config@0.86.0(@babel/core@7.29.7(supports-color@9.4.0)))(@types/react@19.2.17)(react@19.2.3)(supports-color@9.4.0))(react@19.2.3)':
+ '@legendapp/list@3.3.3(patch_hash=37a9c8d7d9c1bd33c957ba6ea78291c7b08532dc32de4925f3d612cb41f1a659)(react-native@0.86.0(@babel/core@7.29.7(supports-color@9.4.0))(@react-native/jest-preset@0.86.0(@babel/core@7.29.7(supports-color@9.4.0))(react@19.2.3))(@react-native/metro-config@0.86.0(@babel/core@7.29.7(supports-color@9.4.0)))(@types/react@19.2.17)(react@19.2.3)(supports-color@9.4.0))(react@19.2.3)':
dependencies:
react: 19.2.3
use-sync-external-store: 1.6.0(react@19.2.3)
diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml
index 763c1c77..5272b6ed 100644
--- a/pnpm-workspace.yaml
+++ b/pnpm-workspace.yaml
@@ -29,3 +29,6 @@ onlyBuiltDependencies:
- better-sqlite3
- esbuild
- protobufjs
+
+patchedDependencies:
+ '@legendapp/list@3.3.3': patches/@legendapp__list@3.3.3.patch
diff --git a/src/components/SearchbarV2/SearchbarV2.tsx b/src/components/SearchbarV2/SearchbarV2.tsx
index 12ef5b15..bdcafca0 100644
--- a/src/components/SearchbarV2/SearchbarV2.tsx
+++ b/src/components/SearchbarV2/SearchbarV2.tsx
@@ -3,7 +3,7 @@ import { StyleSheet, View, TextInput } from 'react-native';
import IconButtonV2 from '../IconButtonV2/IconButtonV2';
import { ThemeColors } from '../../theme/types';
-import { Menu } from '@components';
+import Menu from '../Menu';
import { MaterialDesignIconName } from '@type/icon';
import { getString } from '@i18n/translations';
import { Pressable } from 'react-native-gesture-handler';
diff --git a/src/components/__tests__/LegendListPatch.test.tsx b/src/components/__tests__/LegendListPatch.test.tsx
new file mode 100644
index 00000000..3f84601a
--- /dev/null
+++ b/src/components/__tests__/LegendListPatch.test.tsx
@@ -0,0 +1,43 @@
+import { render } from '@testing-library/react-native';
+import { Text } from 'react-native';
+import { LegendList } from '@legendapp/list/react-native';
+
+const renderItem = ({ item }: { item: string }) => {item};
+
+describe('LegendList render lifecycle patch', () => {
+ it('does not update its container layer while rendering fresh data', () => {
+ const consoleError = jest
+ .spyOn(console, 'error')
+ .mockImplementation(() => undefined);
+
+ const view = render(
+ ,
+ );
+
+ view.rerender(
+ ,
+ );
+ view.rerender(
+ ,
+ );
+
+ expect(consoleError.mock.calls.flat().join(' ')).not.toContain(
+ 'Cannot update a component',
+ );
+ });
+});
diff --git a/src/database/manager/liveQuery.ts b/src/database/manager/liveQuery.ts
new file mode 100644
index 00000000..cd65b5a5
--- /dev/null
+++ b/src/database/manager/liveQuery.ts
@@ -0,0 +1,50 @@
+import { useEffect, useRef, useState } from 'react';
+import type { Query } from 'drizzle-orm';
+
+import { db } from '@database/db';
+import type { FireOn } from './manager';
+
+interface ExecutableSelect {
+ toSQL(): Query;
+ all(): Promise;
+}
+
+export function useLiveQuery(
+ query: T,
+ fireOn: FireOn,
+ callback?: (data: Awaited>) => void,
+) {
+ type ReturnValue = Awaited>;
+
+ const { sql: sqlString, params } = query.toSQL();
+ const paramsKey = JSON.stringify(params);
+ const fireOnKey = JSON.stringify(fireOn);
+ const callbackRef = useRef(callback);
+
+ const [data, setData] = useState(
+ () => db.executeSync(sqlString, params as any[]).rows as ReturnValue,
+ );
+
+ useEffect(() => {
+ callbackRef.current = callback;
+ }, [callback]);
+
+ useEffect(() => {
+ callbackRef.current?.(data);
+ }, [data]);
+
+ useEffect(() => {
+ const unsubscribe = db.reactiveExecute({
+ query: sqlString,
+ arguments: params as any[],
+ fireOn,
+ callback: (result: { rows: ReturnValue }) => {
+ setData(result.rows);
+ },
+ });
+ return unsubscribe;
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [sqlString, paramsKey, fireOnKey]);
+
+ return data;
+}
diff --git a/src/database/manager/manager.ts b/src/database/manager/manager.ts
index 2d81003a..cc5690d7 100644
--- a/src/database/manager/manager.ts
+++ b/src/database/manager/manager.ts
@@ -1,9 +1,8 @@
-import { db, drizzleDb } from '@database/db';
+import type { drizzleDb } from '@database/db';
import type { SQLBatchTuple, Scalar } from '@op-engineering/op-sqlite';
import { IDbManager } from './manager.d';
import { DbTaskQueue } from './queue';
import { Schema } from '../schema';
-import { useEffect, useRef, useState } from 'react';
import { GetSelectTableName } from 'drizzle-orm/query-builders/select.types';
import {
AnyColumn,
@@ -21,7 +20,7 @@ type TransactionParameter = Parameters<
interface ExecutableSelect {
toSQL(): Query;
- all(): Promise; // Or TResult[] if you are using a synchronous driver
+ all(): Promise;
get(): Promise;
}
@@ -140,39 +139,4 @@ export const createDbManager = (dbInstance: DrizzleDb) => {
};
type TableNames = GetSelectTableName;
-type FireOn = { table: TableNames; ids?: number[] }[];
-
-export function useLiveQuery(
- query: T,
- fireOn: FireOn,
- callback?: (data: Awaited>) => void,
-) {
- type ReturnValue = Awaited>;
-
- const { sql: sqlString, params } = query.toSQL();
- const paramsKey = JSON.stringify(params);
- const fireOnKey = JSON.stringify(fireOn);
- const cb = useRef(callback ?? (() => {}));
-
- const [data, setData] = useState(() => {
- const r = db.executeSync(sqlString, params as any[]).rows as ReturnValue;
- cb.current(r);
- return r;
- });
-
- useEffect(() => {
- const unsub = db.reactiveExecute({
- query: sqlString,
- arguments: params as any[],
- fireOn,
- callback: (result: { rows: ReturnValue }) => {
- setData(result.rows);
- cb.current(result.rows);
- },
- });
- return unsub;
- // eslint-disable-next-line react-hooks/exhaustive-deps
- }, [sqlString, paramsKey, fireOnKey]);
-
- return data;
-}
+export type FireOn = { table: TableNames; ids?: number[] }[];
diff --git a/src/screens/settings/SettingsRepositoryScreen/SettingsRepositoryScreen.tsx b/src/screens/settings/SettingsRepositoryScreen/SettingsRepositoryScreen.tsx
index f5af05ef..67fe781b 100644
--- a/src/screens/settings/SettingsRepositoryScreen/SettingsRepositoryScreen.tsx
+++ b/src/screens/settings/SettingsRepositoryScreen/SettingsRepositoryScreen.tsx
@@ -22,7 +22,7 @@ import {
RootStackParamList,
} from '@navigators/types';
import { showToast } from '@utils/showToast';
-import { useLiveQuery } from '@database/manager/manager';
+import { useLiveQuery } from '@database/manager/liveQuery';
import { repositorySchema } from '@database/schema';
import { dbManager } from '@database/db';
diff --git a/src/services/backgroundTasks/BackgroundTaskQueue.ts b/src/services/backgroundTasks/BackgroundTaskQueue.ts
index c7526645..04d8169a 100644
--- a/src/services/backgroundTasks/BackgroundTaskQueue.ts
+++ b/src/services/backgroundTasks/BackgroundTaskQueue.ts
@@ -17,8 +17,7 @@ import {
createBackgroundTaskMetadata,
fromNativeTaskRecord,
} from './taskDefinitions';
-
-export const BACKGROUND_TASKS_STORE_KEY = 'APP_SERVICE';
+import { BACKGROUND_TASKS_STORE_KEY } from './constants';
const makeTemporaryId = () =>
`pending-${Date.now()}-${Math.random().toString(36).slice(2)}`;
diff --git a/src/services/backgroundTasks/constants.ts b/src/services/backgroundTasks/constants.ts
new file mode 100644
index 00000000..da9ecce6
--- /dev/null
+++ b/src/services/backgroundTasks/constants.ts
@@ -0,0 +1 @@
+export const BACKGROUND_TASKS_STORE_KEY = 'APP_SERVICE';
diff --git a/src/services/backgroundTasks/index.ts b/src/services/backgroundTasks/index.ts
index 8e882c9f..dff624f1 100644
--- a/src/services/backgroundTasks/index.ts
+++ b/src/services/backgroundTasks/index.ts
@@ -1,5 +1,5 @@
export { backgroundTasks } from './backgroundTasks';
-export { BACKGROUND_TASKS_STORE_KEY } from './BackgroundTaskQueue';
+export { BACKGROUND_TASKS_STORE_KEY } from './constants';
export { runHeadlessBackgroundTask } from './headlessTask';
export type {
BackgroundTask,
diff --git a/src/services/backup/utils.ts b/src/services/backup/utils.ts
index 6f962892..27ff6058 100644
--- a/src/services/backup/utils.ts
+++ b/src/services/backup/utils.ts
@@ -16,8 +16,8 @@ import {
import { BackupCategory, BackupNovel } from '@database/types';
import { BackupEntryName } from './types';
import { ROOT_STORAGE } from '@utils/Storages';
-import { BACKGROUND_TASKS_STORE_KEY } from '@services/backgroundTasks/BackgroundTaskQueue';
-import NativeFile from '@modules/native-file'
+import { BACKGROUND_TASKS_STORE_KEY } from '@services/backgroundTasks/constants';
+import NativeFile from '@modules/native-file';
import { showToast } from '@utils/showToast';
import { getString } from '@i18n/translations';