perf(reader): split progress into its own store to cut React commit storm (#4557)

setProgress was called multiple times per swipe burst, each call writing
into readerStore.viewStates[key].progress. ~65 places in the reader
subtree subscribed to useReaderStore() without a selector, so every
setProgress fan-out re-rendered all of them -- even the 51 that didn't
care about progress. On Android release builds this showed up as
Layout = 9.8% and Function Call = 9.6% of main-thread self time in
Chrome DevTools' Bottom-Up profile during a reading session.

Fix:
- New tiny store store/readerProgressStore.ts holds the per-book
  BookProgress map. setBookProgress only fires its own subscribers.
- readerStore.setProgress now writes progress to the new store and only
  touches bookDataStore for the primary view (secondary parallel views
  shouldn't overwrite the shared config).
- readerStore.getProgress is kept as a delegating facade so existing
  imperative call sites don't break.
- Components / hooks that genuinely need to react to progress changes
  subscribe via the new useBookProgress(bookKey) hook. The handful of
  call sites that just want a one-shot read use getBookProgress(key) so
  they don't subscribe at all.
- readerStore.clearViewState calls clearBookProgress so the map doesn't
  grow unbounded across book opens/closes.

See store/readerProgressStore.ts header for the full rationale.
This commit is contained in:
loveheaven
2026-06-12 23:14:49 +08:00
committed by GitHub
parent 1c392de0fa
commit 59d4f0aa33
20 changed files with 406 additions and 139 deletions
@@ -1,15 +1,18 @@
import { useCallback, useEffect, useRef } from 'react';
import { useEnv } from '@/context/EnvContext';
import { useBookDataStore } from '@/store/bookDataStore';
import { useBookDataStore, flushPendingLibrarySave } from '@/store/bookDataStore';
import { useReaderStore } from '@/store/readerStore';
import { useBookProgress } from '@/store/readerProgressStore';
import { useSettingsStore } from '@/store/settingsStore';
import { debounce } from '@/utils/debounce';
export const useProgressAutoSave = (bookKey: string) => {
const { envConfig } = useEnv();
const { getConfig, saveConfig } = useBookDataStore();
const { getProgress } = useReaderStore();
const progress = getProgress(bookKey);
const getConfig = useBookDataStore((s) => s.getConfig);
const saveConfig = useBookDataStore((s) => s.saveConfig);
// Reactive subscription so the effect below fires the debounced save
// whenever this book's progress changes. Reads from readerProgressStore.
const progress = useBookProgress(bookKey);
// Tracks the location we last persisted (or, before the first save, the
// location loaded from disk at book open). We skip saveConfig when the
@@ -61,4 +64,18 @@ export const useProgressAutoSave = (bookKey: string) => {
saveBookConfig();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [progress, bookKey]);
// On unmount (book closed / navigated away), flush any pending throttled
// library.json write so the shelf reflects this session's last read
// position next time it loads. The per-book config.json is already on
// disk from the eager save in `saveConfig`, so this only catches the
// library-level rollup.
useEffect(() => {
return () => {
flushPendingLibrarySave().catch(() => {
// Best-effort on teardown — failures fall through to next launch's
// reconstruction from per-book config.json files.
});
};
}, []);
};