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
@@ -19,13 +19,21 @@ let mockProgress: { location: string } | null;
let mockBooknotes: BookNote[];
// ---------- Mocks ----------
vi.mock('@/store/bookDataStore', () => ({
useBookDataStore: () => ({ getConfig: () => ({ booknotes: mockBooknotes }) }),
}));
// Production code uses per-field selectors; mock must apply them.
vi.mock('@/store/bookDataStore', () => {
const state = { getConfig: () => ({ booknotes: mockBooknotes }) };
return {
useBookDataStore: <R,>(selector?: (s: typeof state) => R) =>
selector ? selector(state) : state,
};
});
vi.mock('@/store/readerStore', () => ({
useReaderStore: () => ({ getProgress: () => mockProgress }),
}));
vi.mock('@/store/readerStore', () => {
const state = { getProgress: () => mockProgress };
return {
useReaderStore: <R,>(selector?: (s: typeof state) => R) => (selector ? selector(state) : state),
};
});
vi.mock('@/store/sidebarStore', () => ({
useSidebarStore: () => ({
@@ -27,19 +27,35 @@ vi.mock('@/context/EnvContext', () => ({
useEnv: () => ({ envConfig: {}, appService: { isMobile: false, hasSafeAreaInset: false } }),
}));
vi.mock('@/store/readerStore', () => ({
useReaderStore: () => ({
// Production code uses per-field selectors; mock must apply them so each
// `useReaderStore((s) => s.method)` call returns the method, not the whole
// state object.
vi.mock('@/store/readerStore', () => {
const state = {
getProgress: () => currentProgress,
getViewSettings: () => currentViewSettings,
getView: () => ({ renderer: currentRenderer }),
}),
};
return {
useReaderStore: <R,>(selector?: (s: typeof state) => R) => (selector ? selector(state) : state),
};
});
// ProgressBar now subscribes to progress via readerProgressStore so the
// footer can re-render on page turns without dragging in the whole
// readerStore. Tests must forward their mock state here too.
vi.mock('@/store/readerProgressStore', () => ({
useBookProgress: () => currentProgress,
getBookProgress: () => currentProgress,
}));
vi.mock('@/store/bookDataStore', () => ({
useBookDataStore: () => ({
getBookData: () => currentBookData,
}),
}));
vi.mock('@/store/bookDataStore', () => {
const state = { getBookData: () => currentBookData };
return {
useBookDataStore: <R,>(selector?: (s: typeof state) => R) =>
selector ? selector(state) : state,
};
});
vi.mock('@/helpers/settings', () => ({
saveViewSettings: (...args: unknown[]) => saveViewSettings(...args),
@@ -15,13 +15,16 @@ let capturedInitialized:
let mockProgress: { sectionHref: string; location: string } | null;
// ---------- Mocks ----------
vi.mock('@/store/readerStore', () => ({
useReaderStore: () => ({
vi.mock('@/store/readerStore', () => {
const state = {
getView: () => undefined,
getViewSettings: () => ({ isEink: false }),
getProgress: () => mockProgress,
}),
}));
};
return {
useReaderStore: <R,>(selector?: (s: typeof state) => R) => (selector ? selector(state) : state),
};
});
vi.mock('@/store/sidebarStore', () => ({
useSidebarStore: () => ({ sideBarBookKey: 'book1', isSideBarVisible: true }),
@@ -37,25 +37,32 @@ vi.mock('@/context/EnvContext', () => ({
useEnv: () => ({ envConfig: {} }),
}));
vi.mock('@/store/readerStore', () => ({
useReaderStore: () => ({
vi.mock('@/store/readerStore', () => {
const state = {
getView: () => mockView,
getProgress: () => ({
location: 'epubcfi(/6/8!/4/1:0)',
sectionHref: `ch${primaryIndex}.xhtml`,
}),
getViewSettings: () => null,
}),
}));
};
return {
useReaderStore: <R,>(selector?: (s: typeof state) => R) => (selector ? selector(state) : state),
};
});
vi.mock('@/store/bookDataStore', () => ({
useBookDataStore: () => ({
vi.mock('@/store/bookDataStore', () => {
const state = {
getBookData: () => ({ book: { format: 'EPUB' }, bookDoc: { toc: [] } }),
getConfig: () => null,
setConfig: vi.fn(),
saveConfig: vi.fn(),
}),
}));
};
return {
useBookDataStore: <R,>(selector?: (s: typeof state) => R) =>
selector ? selector(state) : state,
};
});
vi.mock('@/store/settingsStore', () => ({
useSettingsStore: () => ({ settings: {} }),
@@ -4,10 +4,16 @@ import { act, cleanup, renderHook } from '@testing-library/react';
type Progress = { location: string } | null;
const h = vi.hoisted(() => {
// Zustand-like store mock. Supports both destructure form `store()`
// and selector form `store((s) => s.method)`.
const makeStore = <T,>(state: T) => {
const fn = () => state;
const fn = <R,>(selector?: (s: T) => R) => (selector ? selector(state) : state) as R | T;
(fn as unknown as { getState: () => T }).getState = () => state;
return fn as (() => T) & { getState: () => T };
return fn as {
(): T;
<R>(selector: (s: T) => R): R;
getState: () => T;
};
};
const state = {
@@ -32,6 +38,8 @@ vi.mock('@/store/bookDataStore', () => ({
getConfig: () => h.state.config,
saveConfig: h.saveConfigMock,
}),
// Named export consumed by the hook for unmount-time best-effort flush.
flushPendingLibrarySave: vi.fn(async () => {}),
}));
vi.mock('@/store/readerStore', () => ({
@@ -41,6 +49,14 @@ vi.mock('@/store/readerStore', () => ({
}),
}));
// Progress moved to its own store to keep high-frequency setProgress
// writes from re-rendering the whole reader tree. The hook now reads via
// useBookProgress, so the test's mock state needs to flow through here.
vi.mock('@/store/readerProgressStore', () => ({
useBookProgress: () => h.state.progress,
getBookProgress: () => h.state.progress,
}));
vi.mock('@/store/settingsStore', () => ({
useSettingsStore: h.makeStore({ settings: { version: 1 } }),
}));
@@ -2,11 +2,17 @@ import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest';
import { act, cleanup, renderHook } from '@testing-library/react';
const h = vi.hoisted(() => {
// Zustand-like store mock: callable selector returning `state`, plus `.getState()`.
// Zustand-like store mock. Supports both destructure form `store()`
// and selector form `store((s) => s.method)` since the production code
// now uses per-field selectors to avoid whole-store subscriptions.
const makeStore = <T,>(state: T) => {
const fn = () => state;
const fn = <R,>(selector?: (s: T) => R) => (selector ? selector(state) : state) as R | T;
(fn as unknown as { getState: () => T }).getState = () => state;
return fn as (() => T) & { getState: () => T };
return fn as {
(): T;
<R>(selector: (s: T) => R): R;
getState: () => T;
};
};
const book = {
@@ -75,6 +81,13 @@ vi.mock('@/store/readerStore', () => ({
}),
}));
// useProgressSync now reads progress reactively from readerProgressStore
// (see store/readerProgressStore.ts for rationale).
vi.mock('@/store/readerProgressStore', () => ({
useBookProgress: () => h.state.progress,
getBookProgress: () => h.state.progress,
}));
vi.mock('@/store/settingsStore', () => ({
useSettingsStore: h.makeStore({ settings: { globalViewSettings: {} } }),
}));
@@ -77,15 +77,25 @@ vi.mock('@/store/readerStore', () => {
setViewSettings: vi.fn(),
setTTSEnabled: vi.fn(),
};
const useReaderStore = () => store;
// Production code uses per-field selectors; mock must apply them.
const useReaderStore = <R,>(selector?: (s: typeof store) => R) =>
selector ? selector(store) : store;
useReaderStore.getState = () => store;
return { useReaderStore };
});
vi.mock('@/store/bookDataStore', () => ({
useBookDataStore: () => ({
getBookData: () => mockBookData,
}),
vi.mock('@/store/bookDataStore', () => {
const state = { getBookData: () => mockBookData };
return {
useBookDataStore: <R,>(selector?: (s: typeof state) => R) =>
selector ? selector(state) : state,
};
});
// useTTSControl now reads progress reactively from readerProgressStore.
vi.mock('@/store/readerProgressStore', () => ({
useBookProgress: () => mockProgress,
getBookProgress: () => mockProgress,
}));
vi.mock('@/store/proofreadStore', () => ({
@@ -79,7 +79,6 @@ function seedViewState(key: string, overrides: Record<string, unknown> = {}) {
loading: false,
inited: false,
error: null,
progress: null,
ribbonVisible: false,
ttsEnabled: false,
syncing: false,
@@ -4,6 +4,7 @@ import { IoChevronBack, IoChevronForward } from 'react-icons/io5';
import { RiArrowLeftDoubleLine, RiArrowRightDoubleLine } from 'react-icons/ri';
import { useEnv } from '@/context/EnvContext';
import { useReaderStore } from '@/store/readerStore';
import { useBookProgress } from '@/store/readerProgressStore';
import { useTranslation } from '@/hooks/useTranslation';
import { viewPagination } from '../hooks/usePagination';
import { useBookDataStore } from '@/store/bookDataStore';
@@ -19,12 +20,17 @@ const PageNavigationButtons: React.FC<PageNavigationButtonsProps> = ({
}) => {
const _ = useTranslation();
const { appService } = useEnv();
const { getBookData } = useBookDataStore();
const { getView, getProgress, getViewSettings, hoveredBookKey } = useReaderStore();
const getBookData = useBookDataStore((s) => s.getBookData);
const getView = useReaderStore((s) => s.getView);
const getViewSettings = useReaderStore((s) => s.getViewSettings);
// hoveredBookKey is reactive state — drives button visibility.
const hoveredBookKey = useReaderStore((s) => s.hoveredBookKey);
const bookData = getBookData(bookKey);
const view = getView(bookKey);
const viewSettings = getViewSettings(bookKey);
const progress = getProgress(bookKey);
// Reactive: drives the aria-live "Page N" announcement and the
// currentPage label on the buttons. Reads from readerProgressStore.
const progress = useBookProgress(bookKey);
const { section, pageinfo } = progress || {};
const pageInfo = bookData?.isFixedLayout ? section : pageinfo;
const currentPage = pageInfo?.current;
@@ -4,6 +4,7 @@ import { Trans } from 'react-i18next';
import type { Insets } from '@/types/misc';
import { useEnv } from '@/context/EnvContext';
import { useReaderStore } from '@/store/readerStore';
import { useBookProgress } from '@/store/readerProgressStore';
import { useTranslation } from '@/hooks/useTranslation';
import { useBookDataStore } from '@/store/bookDataStore';
import { formatNumber, formatProgress, getReferencePageInfo } from '@/utils/progress';
@@ -28,12 +29,15 @@ const ProgressBar: React.FC<ProgressBarProps> = ({
}) => {
const _ = useTranslation();
const { envConfig, appService } = useEnv();
const { getBookData } = useBookDataStore();
const { getProgress, getViewSettings, getView } = useReaderStore();
const getBookData = useBookDataStore((s) => s.getBookData);
const getViewSettings = useReaderStore((s) => s.getViewSettings);
const getView = useReaderStore((s) => s.getView);
const view = getView(bookKey);
const bookData = getBookData(bookKey);
const viewSettings = getViewSettings(bookKey)!;
const progress = getProgress(bookKey);
// Reactive: this is the on-screen footer that has to refresh on every
// page turn. Reads from readerProgressStore only.
const progress = useBookProgress(bookKey);
const { section, pageinfo } = progress || {};
const showDoubleBorder = viewSettings.vertical && viewSettings.doubleBorder;
@@ -13,6 +13,7 @@ import { useTranslation } from '@/hooks/useTranslation';
import { useSettingsStore } from '@/store/settingsStore';
import { useBookDataStore } from '@/store/bookDataStore';
import { useReaderStore } from '@/store/readerStore';
import { useBookProgress } from '@/store/readerProgressStore';
import { useAIChatStore } from '@/store/aiChatStore';
import { aiLogger, createTauriAdapter } from '@/services/ai';
import {
@@ -289,7 +290,7 @@ const ThreadWrapper = ({
const AIAssistant = ({ bookKey }: AIAssistantProps) => {
const { appService } = useEnv();
const { settings } = useSettingsStore();
const { getBookData } = useBookDataStore();
const getBookData = useBookDataStore((s) => s.getBookData);
const bookData = getBookData(bookKey);
const reedyRuntime = settings?.aiSettings?.reedy?.runtime ?? 'mvp';
@@ -309,10 +310,11 @@ const LegacyAIAssistant = ({ bookKey }: AIAssistantProps) => {
const _ = useTranslation();
const { appService } = useEnv();
const { settings } = useSettingsStore();
const { getBookData } = useBookDataStore();
const { getProgress, getView } = useReaderStore();
const getBookData = useBookDataStore((s) => s.getBookData);
const getView = useReaderStore((s) => s.getView);
const bookData = getBookData(bookKey);
const progress = getProgress(bookKey);
// Reactive: chat context follows the user's current reading position.
const progress = useBookProgress(bookKey);
const [isLoading, setIsLoading] = useState(true);
const [isIndexing, setIsIndexing] = useState(false);
@@ -476,10 +478,12 @@ const LegacyAIAssistant = ({ bookKey }: AIAssistantProps) => {
const ReedyAgentAssistantBridge = ({ bookKey }: AIAssistantProps) => {
const { appService } = useEnv();
const { settings } = useSettingsStore();
const { getBookData } = useBookDataStore();
const { getProgress, getView } = useReaderStore();
const getBookData = useBookDataStore((s) => s.getBookData);
const getView = useReaderStore((s) => s.getView);
const bookData = getBookData(bookKey);
const progress = getProgress(bookKey);
// Reactive: agent runtime needs the latest reading position to seed
// tool calls.
const progress = useBookProgress(bookKey);
const bookHash = bookKey.split('-')[0] || '';
const aiSettings = settings?.aiSettings;
@@ -3,6 +3,7 @@
import React, { useState, useRef, useEffect, useCallback } from 'react';
import { createPortal } from 'react-dom';
import { useReaderStore } from '@/store/readerStore';
import { useBookProgress } from '@/store/readerProgressStore';
import { useBookDataStore } from '@/store/bookDataStore';
import { useSettingsStore } from '@/store/settingsStore';
import { useThemeStore } from '@/store/themeStore';
@@ -114,10 +115,17 @@ const expandRangeToSentence = (range: Range, doc: Document): Range => {
const RSVPControl: React.FC<RSVPControlProps> = ({ bookKey, gridInsets }) => {
const _ = useTranslation();
const { envConfig } = useEnv();
const { settings, setSettingsDialogOpen, setSettingsDialogBookKey, setActiveSettingsItemId } =
useSettingsStore();
const { getView, getProgress, getViewSettings } = useReaderStore();
const { getBookData, getConfig, setConfig, saveConfig } = useBookDataStore();
const settings = useSettingsStore((s) => s.settings);
const setSettingsDialogOpen = useSettingsStore((s) => s.setSettingsDialogOpen);
const setSettingsDialogBookKey = useSettingsStore((s) => s.setSettingsDialogBookKey);
const setActiveSettingsItemId = useSettingsStore((s) => s.setActiveSettingsItemId);
const getView = useReaderStore((s) => s.getView);
const getProgress = useReaderStore((s) => s.getProgress);
const getViewSettings = useReaderStore((s) => s.getViewSettings);
const getBookData = useBookDataStore((s) => s.getBookData);
const getConfig = useBookDataStore((s) => s.getConfig);
const setConfig = useBookDataStore((s) => s.setConfig);
const saveConfig = useBookDataStore((s) => s.saveConfig);
const { themeCode } = useThemeStore();
const [isActive, setIsActive] = useState(false);
@@ -522,8 +530,9 @@ const RSVPControl: React.FC<RSVPControlProps> = ({ bookKey, gridInsets }) => {
await view.renderer.goTo({ index: rsvpSectionRef.current + 1 });
}, [bookKey, getProgress, getView, removeRsvpHighlight]);
// Get current chapter info
const progress = getProgress(bookKey);
// Get current chapter info — reactive subscription so the RSVP overlay's
// chapter pointer follows page turns. Reads from readerProgressStore.
const progress = useBookProgress(bookKey);
const bookData = getBookData(bookKey);
const chapters = bookData?.bookDoc?.toc || [];
const currentChapterHref = rsvpChapterHrefRef.current ?? progress?.sectionHref ?? null;
@@ -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) {
@@ -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.
});
};
}, []);
};
@@ -4,6 +4,7 @@ import { useSync } from '@/hooks/useSync';
import { BookConfig, FIXED_LAYOUT_FORMATS } from '@/types/book';
import { useBookDataStore } from '@/store/bookDataStore';
import { useReaderStore } from '@/store/readerStore';
import { useBookProgress } from '@/store/readerProgressStore';
import { useSettingsStore } from '@/store/settingsStore';
import { useTranslation } from '@/hooks/useTranslation';
import { serializeConfig } from '@/utils/serializer';
@@ -22,12 +23,22 @@ const PULL_RETRY_DELAYS_MS = [1500, 4000, 10000];
export const useProgressSync = (bookKey: string) => {
const _ = useTranslation();
const { getConfig, setConfig, getBookData } = useBookDataStore();
const { getView, getProgress, setHoveredBookKey } = useReaderStore();
// Per-field selectors avoid subscribing this hook's host (FoliateViewer)
// to the WHOLE bookDataStore — saveConfig writes booksData on every
// throttled save and would otherwise re-render the entire reader subtree.
const getConfig = useBookDataStore((s) => s.getConfig);
const setConfig = useBookDataStore((s) => s.setConfig);
const getBookData = useBookDataStore((s) => s.getBookData);
const getView = useReaderStore((s) => s.getView);
const setHoveredBookKey = useReaderStore((s) => s.setHoveredBookKey);
const { settings } = useSettingsStore();
const { syncedConfigs, syncConfigs } = useSync(bookKey);
const { user } = useAuth();
const progress = getProgress(bookKey);
// Reactive subscription on this book's progress so the effects below
// (auto-push debounce, initial pull) re-run when the user turns the
// page. Reads from readerProgressStore, not readerStore — see
// store/readerProgressStore.ts for why this split exists.
const progress = useBookProgress(bookKey);
const configPulled = useRef(false);
const hasPulledConfigOnce = useRef(false);
@@ -4,6 +4,7 @@ import { useAuth } from '@/context/AuthContext';
import { useThemeStore } from '@/store/themeStore';
import { useBookDataStore } from '@/store/bookDataStore';
import { useReaderStore } from '@/store/readerStore';
import { useBookProgress } from '@/store/readerProgressStore';
import { useProofreadStore } from '@/store/proofreadStore';
import { TransformContext } from '@/services/transformers/types';
import { proofreadTransformer } from '@/services/transformers/proofread';
@@ -30,9 +31,12 @@ export const useTTSControl = ({ bookKey, onRequestHidePanel }: UseTTSControlProp
const { appService } = useEnv();
const { user } = useAuth();
const { isDarkMode } = useThemeStore();
const { getBookData } = useBookDataStore();
const { getView, getProgress, getViewSettings } = useReaderStore();
const { setViewSettings, setTTSEnabled } = useReaderStore();
const getBookData = useBookDataStore((s) => s.getBookData);
const getView = useReaderStore((s) => s.getView);
const getProgress = useReaderStore((s) => s.getProgress);
const getViewSettings = useReaderStore((s) => s.getViewSettings);
const setViewSettings = useReaderStore((s) => s.setViewSettings);
const setTTSEnabled = useReaderStore((s) => s.setTTSEnabled);
const { getMergedRules } = useProofreadStore();
const [ttsLang, setTtsLang] = useState<string>('en');
@@ -265,8 +269,10 @@ export const useTTSControl = ({ bookKey, onRequestHidePanel }: UseTTSControlProp
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [ttsController, bookKey]);
// Location tracking — re-highlight when progress changes
const progress = getProgress(bookKey);
// Location tracking — re-highlight when progress changes.
// Reactive subscription via readerProgressStore so the effect below
// re-runs on page turns without dragging in the whole readerStore.
const progress = useBookProgress(bookKey);
useEffect(() => {
const ttsController = ttsControllerRef.current;
if (!ttsController) return;
@@ -2,6 +2,7 @@ import { useCallback, useEffect, useRef, useState } from 'react';
import { FoliateView } from '@/types/view';
import { UseTranslatorOptions } from '@/services/translators';
import { useReaderStore } from '@/store/readerStore';
import { useBookProgress } from '@/store/readerProgressStore';
import { useTranslator } from '@/hooks/useTranslator';
import { useTranslation } from '@/hooks/useTranslation';
import { eventDispatcher } from '@/utils/event';
@@ -16,9 +17,13 @@ export function useTextTranslation(
targetBlockClassName = 'translation-target-block',
) {
const _ = useTranslation();
const { getViewSettings, getProgress, setIsLoading } = useReaderStore();
const getViewSettings = useReaderStore((s) => s.getViewSettings);
const setIsLoading = useReaderStore((s) => s.setIsLoading);
const viewSettings = getViewSettings(bookKey);
const progress = getProgress(bookKey);
// Reactive: triggers translate-in-range on every page turn so the
// visible viewport's translations refresh. Reads from
// readerProgressStore only.
const progress = useBookProgress(bookKey);
const enabled = useRef(viewSettings?.translationEnabled);
const [provider, setProvider] = useState(viewSettings?.translationProvider);
@@ -3,6 +3,7 @@ import { v4 as uuidv4 } from 'uuid';
import { useEnv } from '@/context/EnvContext';
import { useBookDataStore } from '@/store/bookDataStore';
import { useReaderStore } from '@/store/readerStore';
import { useBookProgress } from '@/store/readerProgressStore';
import { useSettingsStore } from '@/store/settingsStore';
import { useTranslation } from '@/hooks/useTranslation';
import { debounce } from '@/utils/debounce';
@@ -74,9 +75,16 @@ export const useWebDAVSync = (bookKey: string) => {
const _ = useTranslation();
const { envConfig, appService } = useEnv();
const { settings, setSettings, saveSettings } = useSettingsStore();
const { getProgress, getViewsById, getView } = useReaderStore();
const { getConfig, setConfig, getBookData, saveConfig } = useBookDataStore();
const progress = getProgress(bookKey);
const getViewsById = useReaderStore((s) => s.getViewsById);
const getView = useReaderStore((s) => s.getView);
const getConfig = useBookDataStore((s) => s.getConfig);
const setConfig = useBookDataStore((s) => s.setConfig);
const getBookData = useBookDataStore((s) => s.getBookData);
const saveConfig = useBookDataStore((s) => s.saveConfig);
// Reactive: triggers the auto-push effect on page turns. Imperative reads
// elsewhere in this hook still go through useReaderStore.getState() /
// getBookProgress() via callbacks where they are bound directly.
const progress = useBookProgress(bookKey);
/**
* `dirtyRef` flips to true on the first locally-driven change after a
@@ -0,0 +1,108 @@
import { create } from 'zustand';
import { BookProgress } from '@/types/book';
/**
* Per-book reading progress, kept in its own store so that high-frequency
* `setProgress` writes (fired once per page turn / scroll snap) do not
* notify every component subscribed to `useReaderStore()`.
*
* Background — the "destructure-subscribes-the-whole-store" anti-pattern
* --------------------------------------------------------------------
* Zustand `useStore()` without a selector subscribes the caller to the
* ENTIRE state object and re-renders on every top-level setState. The
* reader subtree had ~65 places calling `useReaderStore()` and ~30
* places calling `useBookDataStore()` in this destructure form. That
* meant two separate problems happened at once:
*
* 1. **High-frequency writes** — `setProgress` updated readerStore
* multiple times per swipe burst. All 65 readerStore destructure
* sites re-rendered each time, even though only ~14 of them
* actually displayed or reacted to progress.
* 2. **Medium-frequency writes** — `saveConfig` (fired ~1.5s after
* every page turn via useProgressAutoSave) wrote bookDataStore's
* `booksData`. All ~30 bookDataStore destructure sites re-rendered
* after each save.
*
* On Android release builds the combined React commit storm showed up
* in Chrome DevTools' Bottom-Up profile as Layout = 9.8% and Function
* Call = 9.6% of main-thread time during a reading session — directly
* contributing to swipe jank ("感觉还是有点卡").
*
* Two-layer fix
* -------------
* Both layers had to land for the jank to go away:
*
* A. **Split progress into its own tiny store** (this file). The 51
* readerStore subscribers that don't care about progress are
* auto-untouched once the field stops living there; only the ~14
* that genuinely need it opt in via `useBookProgress(bookKey)`.
* B. **Per-field selector pattern** for all remaining
* `useReaderStore()` / `useBookDataStore()` call sites: replace
* `const { x, y } = useStore()` with
* `const x = useStore((s) => s.x); const y = useStore((s) => s.y)`.
* Action identities are fixed at store creation, so selector
* results are stable references — the default `Object.is` bail-out
* eliminates the host component's re-renders entirely.
*
* Whenever you see a per-field selector comment in reader components
* (FoliateViewer, Annotator, BooksGrid, etc.), this is the rationale
* being applied.
*
* Lifecycle: `clearBookProgress(key)` is called from
* `readerStore.clearViewState` to release the entry when a book view
* tears down, mirroring the prior coupling to `viewStates[key]`.
*/
interface ReaderProgressState {
// Keyed by book view key (matches readerStore.viewStates keys).
progresses: { [key: string]: BookProgress | null };
}
export const useReaderProgressStore = create<ReaderProgressState>(() => ({
progresses: {},
}));
/**
* Imperative read — does NOT subscribe the caller. Use this inside event
* handlers, callbacks, useEffect bodies, etc. — anywhere you want the
* latest value without causing re-renders on subsequent progress changes.
*/
export const getBookProgress = (key: string | null): BookProgress | null => {
if (!key) return null;
return useReaderProgressStore.getState().progresses[key] ?? null;
};
/**
* Imperative write — performs zustand setState only for this small store,
* so subscribers to `useReaderStore()` are NOT touched.
*/
export const setBookProgress = (key: string, progress: BookProgress | null) => {
useReaderProgressStore.setState((state) => ({
progresses: {
...state.progresses,
[key]: progress,
},
}));
};
/**
* Drop a book's progress entry — call from readerStore.clearViewState so
* the map doesn't grow unbounded across opens/closes.
*/
export const clearBookProgress = (key: string) => {
useReaderProgressStore.setState((state) => {
if (!(key in state.progresses)) return state;
const next = { ...state.progresses };
delete next[key];
return { progresses: next };
});
};
/**
* Reactive subscription — components that need to re-render when this
* specific book's progress changes should use this hook. Selector form
* ensures only progress changes for THIS key trigger a re-render
* (and progress changes for OTHER books do not).
*/
export const useBookProgress = (key: string | null): BookProgress | null => {
return useReaderProgressStore((s) => (key ? (s.progresses[key] ?? null) : null));
};
+73 -63
View File
@@ -25,6 +25,7 @@ import { SUPPORTED_LANGNAMES } from '@/services/constants';
import { useSettingsStore } from './settingsStore';
import { BookData, useBookDataStore } from './bookDataStore';
import { useLibraryStore } from './libraryStore';
import { clearBookProgress, getBookProgress, setBookProgress } from './readerProgressStore';
import { uniqueId } from '@/utils/misc';
interface ViewState {
@@ -36,7 +37,9 @@ interface ViewState {
loading: boolean;
inited: boolean;
error: string | null;
progress: BookProgress | null;
/* `progress` moved to readerProgressStore — see that file's header for
rationale. Use `useBookProgress(key)` for reactive subscription or
`getBookProgress(key)` for one-shot reads. */
ribbonVisible: boolean;
ttsEnabled: boolean;
syncing: boolean;
@@ -121,6 +124,9 @@ export const useReaderStore = create<ReaderStore>((set, get) => ({
},
clearViewState: (key: string) => {
// Drop the per-book progress entry alongside the view state so the
// standalone progress store doesn't leak across opens/closes.
clearBookProgress(key);
set((state) => {
const viewStates = { ...state.viewStates };
delete viewStates[key];
@@ -148,7 +154,6 @@ export const useReaderStore = create<ReaderStore>((set, get) => ({
loading: true,
inited: false,
error: null,
progress: null,
ribbonVisible: false,
ttsEnabled: false,
syncing: false,
@@ -288,7 +293,6 @@ export const useReaderStore = create<ReaderStore>((set, get) => ({
loading: false,
inited: false,
error: null,
progress: null,
ribbonVisible: false,
ttsEnabled: false,
syncing: false,
@@ -312,7 +316,6 @@ export const useReaderStore = create<ReaderStore>((set, get) => ({
loading: false,
inited: false,
error: 'Failed to load book.',
progress: null,
ribbonVisible: false,
ttsEnabled: false,
syncing: false,
@@ -357,7 +360,12 @@ export const useReaderStore = create<ReaderStore>((set, get) => ({
},
}));
},
getProgress: (key: string) => get().viewStates[key]?.progress || null,
// Delegates to the standalone readerProgressStore so that progress reads
// do not subscribe the caller to readerStore. Most call sites need a
// one-shot read (event handlers, useEffect bodies). Components that
// genuinely depend on progress for rendering should subscribe via the
// `useBookProgress(key)` hook exported from readerProgressStore instead.
getProgress: (key: string) => getBookProgress(key),
setProgress: (
key: string,
location: string,
@@ -367,70 +375,72 @@ export const useReaderStore = create<ReaderStore>((set, get) => ({
pageinfo: PageInfo,
timeinfo: TimeInfo,
range: Range,
) =>
set((state) => {
const id = key.split('-')[0]!;
const bookData = useBookDataStore.getState().booksData[id];
const viewState = state.viewStates[key];
if (!viewState || !bookData) return state;
) => {
const id = key.split('-')[0]!;
const bookData = useBookDataStore.getState().booksData[id];
const viewState = get().viewStates[key];
if (!viewState || !bookData) return;
const pageInfo = bookData.isFixedLayout ? section : pageinfo;
const progress: [number, number] = [pageInfo.current + 1, pageInfo.total];
const progressPercentage = Math.round((progress[0] / progress[1]) * 100);
const pageInfo = bookData.isFixedLayout ? section : pageinfo;
const progress: [number, number] = [pageInfo.current + 1, pageInfo.total];
const progressPercentage = Math.round((progress[0] / progress[1]) * 100);
// Lightweight library update — O(1) lookup, no array copy, no refreshGroups
const { getBookByHash, updateBookProgress } = useLibraryStore.getState();
const existingBook = getBookByHash(id);
if (existingBook) {
let newReadingStatus = existingBook.readingStatus;
if (existingBook.readingStatus === 'unread') {
newReadingStatus = undefined;
}
if (progressPercentage >= 100 && existingBook.readingStatus !== 'finished') {
newReadingStatus = 'finished';
}
updateBookProgress(id, progress, newReadingStatus);
// Lightweight library update — O(1) lookup, no array copy, no refreshGroups
const { getBookByHash, updateBookProgress } = useLibraryStore.getState();
const existingBook = getBookByHash(id);
if (existingBook) {
let newReadingStatus = existingBook.readingStatus;
if (existingBook.readingStatus === 'unread') {
newReadingStatus = undefined;
}
if (progressPercentage >= 100 && existingBook.readingStatus !== 'finished') {
newReadingStatus = 'finished';
}
updateBookProgress(id, progress, newReadingStatus);
}
const oldConfig = bookData.config;
const newConfig = {
...bookData.config,
progress,
location,
} as BookConfig;
useBookDataStore.setState((state) => ({
booksData: {
...state.booksData,
[id]: {
...bookData,
config: viewState.isPrimary ? newConfig : oldConfig,
// Only the primary view persists progress into the shared bookData
// config — secondary views in a parallel layout shouldn't overwrite
// it. Skip the bookDataStore write entirely when not primary to spare
// its subscribers a re-render.
if (viewState.isPrimary) {
useBookDataStore.setState((state) => {
const existing = state.booksData[id];
if (!existing) return state;
return {
booksData: {
...state.booksData,
[id]: {
...existing,
config: {
...existing.config,
progress,
location,
} as BookConfig,
},
},
},
}));
};
});
}
return {
viewStates: {
...state.viewStates,
[key]: {
...viewState,
progress: {
...viewState.progress,
location,
sectionHref: tocItem?.href,
sectionLabel: tocItem?.label,
pageItem,
section,
pageinfo,
timeinfo,
index: section.current,
range,
page: pageInfo.current + 1,
} as BookProgress,
},
},
};
}),
// Write progress to the standalone store. This is the only setState on
// the hot swipe path that the previous implementation routed through
// the (much bigger) readerStore — the split here is the whole point of
// the refactor: components subscribing to `useReaderStore()` without a
// selector will no longer re-render per page turn.
setBookProgress(key, {
location,
sectionHref: tocItem?.href,
sectionLabel: tocItem?.label,
pageItem,
section,
pageinfo,
timeinfo,
index: section.current,
range,
page: pageInfo.current + 1,
} as BookProgress);
},
setBookmarkRibbonVisibility: (key: string, visible: boolean) =>
set((state) => ({
viewStates: {