Files
readest/apps/readest-app/src/store/bookDataStore.ts
T
loveheaven 1c392de0fa perf(reader): throttle library.json writes and cache known dirs to cut IPC (#4556)
useProgressAutoSave fires saveConfig ~once per second of reading. Two
write-time sources were doubling its IPC cost:

1. saveConfig wrote the WHOLE library.json (+ backup) on every call, so a
   user with N books paid 2*JSON.stringify(N) per save. Chrome DevTools'
   Bottom-Up profile on a release Android build showed processIpcMessage
   chewing ~25% of main-thread time during a reading session.
2. nativeFileSystem.writeFile / copyFile defensively called plugin:fs|exists
   before every write to ensure the parent dir existed. Same dir gets
   probed once per save -- ~50% of IPC time per save was just exists()
   round-trips against directories that have been there since the book
   was opened.

Fix:
- LIBRARY_SAVE_THROTTLE_MS=30s coalesces a swipe burst into a single
  library.json write. Per-book config.json is still written eagerly --
  it's the sync source-of-truth and is small. flushPendingLibrarySave()
  is called on hook unmount + window blur so closing the book always
  flushes.
- In-process knownExistingDirs Set caches verified directories per app
  session. createDir adds, removeDir (incl. recursive) clears. Cold
  start still does the original exists+createDir dance once per dir.
2026-06-12 16:32:21 +02:00

195 lines
7.2 KiB
TypeScript

import { create } from 'zustand';
import { SystemSettings } from '@/types/settings';
import { Book, BookConfig, BookNote } from '@/types/book';
import { EnvConfigType } from '@/services/environment';
import { BookDoc } from '@/libs/document';
import { useLibraryStore } from './libraryStore';
// Throttle library.json writes triggered by per-book saveConfig.
//
// Why: `saveConfig` ran two large fs.writeFile IPC calls *every* invocation —
// one for the per-book config.json and one for the WHOLE library.json (because
// saveLibraryBooks writes a backup + the file itself). For a user with N
// books in their shelf, that's `2 * JSON.stringify(N entries)` of work + 2
// Tauri IPC round-trips per save. With auto-save firing once per second of
// reading (useProgressAutoSave), Chrome DevTools' Bottom-Up profile shows
// `processIpcMessage` chewing ~25% of main-thread time during a reading
// session — directly responsible for the swipe jank the user is reporting
// (touchmove gets queued behind IPC processing).
//
// The library array itself is updated immutably via setLibrary on every save
// (see `setLibrary(newLibrary)` below) so in-memory state and zustand
// subscribers see the change immediately. Disk persistence can be deferred:
// progress is also stored in each book's own config.json (which we still
// write every time), so even if the app dies between throttle ticks the
// shelf will reconstruct correct progress from those per-book files on
// next launch.
//
// LIBRARY_SAVE_THROTTLE_MS=30s: long enough to collapse a swipe burst into a
// single IPC, short enough that a user who closes the book within half a
// minute still sees the shelf update without a follow-up flush. Force-flush
// happens via flushPendingLibrarySave() on hook unmount + window blur.
const LIBRARY_SAVE_THROTTLE_MS = 30_000;
let librarySaveTimeoutId: ReturnType<typeof setTimeout> | null = null;
let librarySaveAppService: { saveLibraryBooks: (books: Book[]) => Promise<void> } | null = null;
const scheduleLibrarySave = (appService: {
saveLibraryBooks: (books: Book[]) => Promise<void>;
}) => {
librarySaveAppService = appService;
if (librarySaveTimeoutId != null) return;
librarySaveTimeoutId = setTimeout(() => {
librarySaveTimeoutId = null;
const svc = librarySaveAppService;
if (!svc) return;
const { library } = useLibraryStore.getState();
svc.saveLibraryBooks(library).catch((err) => {
console.warn('Throttled library save failed:', err);
});
}, LIBRARY_SAVE_THROTTLE_MS);
};
export const flushPendingLibrarySave = async () => {
if (librarySaveTimeoutId == null || !librarySaveAppService) return;
clearTimeout(librarySaveTimeoutId);
librarySaveTimeoutId = null;
const { library } = useLibraryStore.getState();
await librarySaveAppService.saveLibraryBooks(library);
};
export interface BookData {
/* Persistent data shared with different views of the same book */
id: string;
book: Book | null;
file: File | null;
config: BookConfig | null;
bookDoc: BookDoc | null;
isFixedLayout: boolean;
}
interface BookDataState {
booksData: { [id: string]: BookData };
getConfig: (key: string | null) => BookConfig | null;
setConfig: (key: string, partialConfig: Partial<BookConfig>) => void;
saveConfig: (
envConfig: EnvConfigType,
bookKey: string,
config: BookConfig,
settings: SystemSettings,
) => Promise<void>;
updateBooknotes: (key: string, booknotes: BookNote[]) => BookConfig | undefined;
getBookData: (keyOrId: string) => BookData | null;
clearBookData: (keyOrId: string) => void;
}
export const useBookDataStore = create<BookDataState>((set, get) => ({
booksData: {},
getBookData: (keyOrId: string) => {
const id = keyOrId.split('-')[0]!;
return get().booksData[id] || null;
},
clearBookData: (keyOrId: string) => {
const id = keyOrId.split('-')[0]!;
set((state) => {
const newBooksData = { ...state.booksData };
delete newBooksData[id];
return {
booksData: newBooksData,
};
});
},
getConfig: (key: string | null) => {
if (!key) return null;
const id = key.split('-')[0]!;
return get().booksData[id]?.config || null;
},
setConfig: (key: string, partialConfig: Partial<BookConfig>) => {
set((state: BookDataState) => {
const id = key.split('-')[0]!;
const config = state.booksData[id]?.config;
if (!config) {
console.warn('No config found for book', id);
return state;
}
return {
booksData: {
...state.booksData,
[id]: {
...state.booksData[id]!,
config: { ...config, ...partialConfig },
},
},
};
});
},
saveConfig: async (
envConfig: EnvConfigType,
bookKey: string,
config: BookConfig,
settings: SystemSettings,
) => {
const appService = await envConfig.getAppService();
const { library, hashIndex, setLibrary } = useLibraryStore.getState();
const hash = bookKey.split('-')[0]!;
const idx = hashIndex.get(hash);
if (idx === undefined) return;
// Immutably move the book to the front of the library with updated
// progress and timestamps. We do NOT mutate the existing book object or
// the existing library array — Zustand subscribers see fresh references
// and the visibleLibrary cache stays in sync via setLibrary's full update.
const now = Date.now();
const original = library[idx]!;
const updatedBook: Book = {
...original,
progress: config.progress,
updatedAt: now,
downloadedAt: original.downloadedAt || now,
};
const newLibrary = [updatedBook, ...library.slice(0, idx), ...library.slice(idx + 1)];
setLibrary(newLibrary);
// Refresh updatedAt immutably via the store rather than mutating the
// caller-provided object. This notifies Zustand subscribers and works
// regardless of whether the caller passed the shared store config.
get().setConfig(bookKey, { updatedAt: now });
const configToSave = { ...config, updatedAt: now };
// Per-book config: still write eagerly — it's small (one book's
// settings + booknotes) and is the source of truth used by sync to
// reconstruct the shelf if library.json is missing or stale.
await appService.saveBookConfig(updatedBook, configToSave, settings);
// Library JSON write: throttled (see scheduleLibrarySave docs) so a
// burst of saveConfig calls during reading doesn't fire IPC on every
// page turn.
scheduleLibrarySave(appService);
},
updateBooknotes: (key: string, booknotes: BookNote[]) => {
let updatedConfig: BookConfig | undefined;
set((state) => {
const id = key.split('-')[0]!;
const book = state.booksData[id];
if (!book) return state;
const dedupedBooknotes = Array.from(
new Map(booknotes.map((item) => [`${item.id}-${item.type}-${item.cfi}`, item])).values(),
);
updatedConfig = {
...book.config,
updatedAt: Date.now(),
booknotes: dedupedBooknotes,
};
return {
booksData: {
...state.booksData,
[id]: {
...book,
config: {
...book.config,
updatedAt: Date.now(),
booknotes: dedupedBooknotes,
},
},
},
};
});
return updatedConfig;
},
}));