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
@@ -2,6 +2,7 @@ import { useState, useEffect, useCallback, useRef, useMemo } from 'react';
import { useEnv } from '@/context/EnvContext';
import { useSettingsStore } from '@/store/settingsStore';
import { useReaderStore } from '@/store/readerStore';
import { useBookProgress } from '@/store/readerProgressStore';
import { useBookDataStore } from '@/store/bookDataStore';
import { useTranslation } from '@/hooks/useTranslation';
import { KOSyncClient, KoSyncProgress } from '@/services/sync/KOSyncClient';
@@ -38,8 +39,12 @@ export const useKOSync = (bookKey: string) => {
const _ = useTranslation();
const { appService } = useEnv();
const { settings } = useSettingsStore();
const { getProgress, getView } = useReaderStore();
const { getBookData, getConfig, setConfig } = useBookDataStore();
// Per-field selectors — methods are stable refs, so no subscription churn.
const getProgress = useReaderStore((s) => s.getProgress);
const getView = useReaderStore((s) => s.getView);
const getBookData = useBookDataStore((s) => s.getBookData);
const getConfig = useBookDataStore((s) => s.getConfig);
const setConfig = useBookDataStore((s) => s.setConfig);
const [kosyncClient, setKOSyncClient] = useState<KOSyncClient | null>(null);
const [syncState, setSyncState] = useState<SyncState>('idle');
@@ -47,7 +52,9 @@ export const useKOSync = (bookKey: string) => {
const [errorMessage] = useState<string | null>(null);
const hasPulledOnce = useRef(false);
const progress = getProgress(bookKey);
// Reactive subscription: drives the auto-push effect and the initial
// pull-on-open effect below. Reads from readerProgressStore.
const progress = useBookProgress(bookKey);
useEffect(() => {
if (!settings.kosync.username || !settings.kosync.userkey) {