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
@@ -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;