From 59d4f0aa33c722f26d2b6fa728d1b8713a1dbed3 Mon Sep 17 00:00:00 2001 From: loveheaven Date: Fri, 12 Jun 2026 23:14:49 +0800 Subject: [PATCH] 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. --- .../components/BooknoteView.test.tsx | 20 ++- .../__tests__/components/ProgressBar.test.tsx | 32 +++-- .../src/__tests__/components/TOCView.test.tsx | 11 +- .../components/rsvp-section-advance.test.tsx | 23 +-- .../hooks/useProgressAutoSave.test.tsx | 20 ++- .../__tests__/hooks/useProgressSync.test.tsx | 19 ++- .../__tests__/hooks/useTTSControl.test.tsx | 20 ++- .../src/__tests__/store/reader-store.test.ts | 1 - .../components/PageNavigationButtons.tsx | 12 +- .../src/app/reader/components/ProgressBar.tsx | 10 +- .../components/notebook/AIAssistant.tsx | 18 ++- .../reader/components/rsvp/RSVPControl.tsx | 21 ++- .../src/app/reader/hooks/useKOSync.ts | 13 +- .../app/reader/hooks/useProgressAutoSave.ts | 25 +++- .../src/app/reader/hooks/useProgressSync.ts | 17 ++- .../src/app/reader/hooks/useTTSControl.ts | 16 ++- .../app/reader/hooks/useTextTranslation.ts | 9 +- .../src/app/reader/hooks/useWebDAVSync.ts | 14 +- .../src/store/readerProgressStore.ts | 108 ++++++++++++++ apps/readest-app/src/store/readerStore.ts | 136 ++++++++++-------- 20 files changed, 406 insertions(+), 139 deletions(-) create mode 100644 apps/readest-app/src/store/readerProgressStore.ts diff --git a/apps/readest-app/src/__tests__/components/BooknoteView.test.tsx b/apps/readest-app/src/__tests__/components/BooknoteView.test.tsx index c02b6714..f63ad6ee 100644 --- a/apps/readest-app/src/__tests__/components/BooknoteView.test.tsx +++ b/apps/readest-app/src/__tests__/components/BooknoteView.test.tsx @@ -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: (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: (selector?: (s: typeof state) => R) => (selector ? selector(state) : state), + }; +}); vi.mock('@/store/sidebarStore', () => ({ useSidebarStore: () => ({ diff --git a/apps/readest-app/src/__tests__/components/ProgressBar.test.tsx b/apps/readest-app/src/__tests__/components/ProgressBar.test.tsx index 856aa706..a41a0b87 100644 --- a/apps/readest-app/src/__tests__/components/ProgressBar.test.tsx +++ b/apps/readest-app/src/__tests__/components/ProgressBar.test.tsx @@ -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: (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: (selector?: (s: typeof state) => R) => + selector ? selector(state) : state, + }; +}); vi.mock('@/helpers/settings', () => ({ saveViewSettings: (...args: unknown[]) => saveViewSettings(...args), diff --git a/apps/readest-app/src/__tests__/components/TOCView.test.tsx b/apps/readest-app/src/__tests__/components/TOCView.test.tsx index f9ade085..a3c47eb4 100644 --- a/apps/readest-app/src/__tests__/components/TOCView.test.tsx +++ b/apps/readest-app/src/__tests__/components/TOCView.test.tsx @@ -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: (selector?: (s: typeof state) => R) => (selector ? selector(state) : state), + }; +}); vi.mock('@/store/sidebarStore', () => ({ useSidebarStore: () => ({ sideBarBookKey: 'book1', isSideBarVisible: true }), diff --git a/apps/readest-app/src/__tests__/components/rsvp-section-advance.test.tsx b/apps/readest-app/src/__tests__/components/rsvp-section-advance.test.tsx index bb0254f8..fe4ed5cd 100644 --- a/apps/readest-app/src/__tests__/components/rsvp-section-advance.test.tsx +++ b/apps/readest-app/src/__tests__/components/rsvp-section-advance.test.tsx @@ -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: (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: (selector?: (s: typeof state) => R) => + selector ? selector(state) : state, + }; +}); vi.mock('@/store/settingsStore', () => ({ useSettingsStore: () => ({ settings: {} }), diff --git a/apps/readest-app/src/__tests__/hooks/useProgressAutoSave.test.tsx b/apps/readest-app/src/__tests__/hooks/useProgressAutoSave.test.tsx index a06645d5..aed75f5c 100644 --- a/apps/readest-app/src/__tests__/hooks/useProgressAutoSave.test.tsx +++ b/apps/readest-app/src/__tests__/hooks/useProgressAutoSave.test.tsx @@ -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 = (state: T) => { - const fn = () => state; + const fn = (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; + (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 } }), })); diff --git a/apps/readest-app/src/__tests__/hooks/useProgressSync.test.tsx b/apps/readest-app/src/__tests__/hooks/useProgressSync.test.tsx index b7c39397..7ad7382e 100644 --- a/apps/readest-app/src/__tests__/hooks/useProgressSync.test.tsx +++ b/apps/readest-app/src/__tests__/hooks/useProgressSync.test.tsx @@ -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 = (state: T) => { - const fn = () => state; + const fn = (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; + (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: {} } }), })); diff --git a/apps/readest-app/src/__tests__/hooks/useTTSControl.test.tsx b/apps/readest-app/src/__tests__/hooks/useTTSControl.test.tsx index 192609f7..62b0e86e 100644 --- a/apps/readest-app/src/__tests__/hooks/useTTSControl.test.tsx +++ b/apps/readest-app/src/__tests__/hooks/useTTSControl.test.tsx @@ -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 = (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: (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', () => ({ diff --git a/apps/readest-app/src/__tests__/store/reader-store.test.ts b/apps/readest-app/src/__tests__/store/reader-store.test.ts index c5aad1bd..e66ecdbb 100644 --- a/apps/readest-app/src/__tests__/store/reader-store.test.ts +++ b/apps/readest-app/src/__tests__/store/reader-store.test.ts @@ -79,7 +79,6 @@ function seedViewState(key: string, overrides: Record = {}) { loading: false, inited: false, error: null, - progress: null, ribbonVisible: false, ttsEnabled: false, syncing: false, diff --git a/apps/readest-app/src/app/reader/components/PageNavigationButtons.tsx b/apps/readest-app/src/app/reader/components/PageNavigationButtons.tsx index f55e259d..927d386d 100644 --- a/apps/readest-app/src/app/reader/components/PageNavigationButtons.tsx +++ b/apps/readest-app/src/app/reader/components/PageNavigationButtons.tsx @@ -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 = ({ }) => { 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; diff --git a/apps/readest-app/src/app/reader/components/ProgressBar.tsx b/apps/readest-app/src/app/reader/components/ProgressBar.tsx index 04b74089..4fa4ff3d 100644 --- a/apps/readest-app/src/app/reader/components/ProgressBar.tsx +++ b/apps/readest-app/src/app/reader/components/ProgressBar.tsx @@ -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 = ({ }) => { 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; diff --git a/apps/readest-app/src/app/reader/components/notebook/AIAssistant.tsx b/apps/readest-app/src/app/reader/components/notebook/AIAssistant.tsx index f03f2e54..923737ce 100644 --- a/apps/readest-app/src/app/reader/components/notebook/AIAssistant.tsx +++ b/apps/readest-app/src/app/reader/components/notebook/AIAssistant.tsx @@ -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; diff --git a/apps/readest-app/src/app/reader/components/rsvp/RSVPControl.tsx b/apps/readest-app/src/app/reader/components/rsvp/RSVPControl.tsx index 3726cba1..e8361162 100644 --- a/apps/readest-app/src/app/reader/components/rsvp/RSVPControl.tsx +++ b/apps/readest-app/src/app/reader/components/rsvp/RSVPControl.tsx @@ -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 = ({ 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 = ({ 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; diff --git a/apps/readest-app/src/app/reader/hooks/useKOSync.ts b/apps/readest-app/src/app/reader/hooks/useKOSync.ts index b2d77b02..f1b31b65 100644 --- a/apps/readest-app/src/app/reader/hooks/useKOSync.ts +++ b/apps/readest-app/src/app/reader/hooks/useKOSync.ts @@ -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(null); const [syncState, setSyncState] = useState('idle'); @@ -47,7 +52,9 @@ export const useKOSync = (bookKey: string) => { const [errorMessage] = useState(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) { diff --git a/apps/readest-app/src/app/reader/hooks/useProgressAutoSave.ts b/apps/readest-app/src/app/reader/hooks/useProgressAutoSave.ts index 4bb3c9d1..74bf9ca7 100644 --- a/apps/readest-app/src/app/reader/hooks/useProgressAutoSave.ts +++ b/apps/readest-app/src/app/reader/hooks/useProgressAutoSave.ts @@ -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. + }); + }; + }, []); }; diff --git a/apps/readest-app/src/app/reader/hooks/useProgressSync.ts b/apps/readest-app/src/app/reader/hooks/useProgressSync.ts index 44a82be4..fc8ed9e0 100644 --- a/apps/readest-app/src/app/reader/hooks/useProgressSync.ts +++ b/apps/readest-app/src/app/reader/hooks/useProgressSync.ts @@ -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); diff --git a/apps/readest-app/src/app/reader/hooks/useTTSControl.ts b/apps/readest-app/src/app/reader/hooks/useTTSControl.ts index 7ec424e9..9e540769 100644 --- a/apps/readest-app/src/app/reader/hooks/useTTSControl.ts +++ b/apps/readest-app/src/app/reader/hooks/useTTSControl.ts @@ -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('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; diff --git a/apps/readest-app/src/app/reader/hooks/useTextTranslation.ts b/apps/readest-app/src/app/reader/hooks/useTextTranslation.ts index 1fb997ee..b9b243c5 100644 --- a/apps/readest-app/src/app/reader/hooks/useTextTranslation.ts +++ b/apps/readest-app/src/app/reader/hooks/useTextTranslation.ts @@ -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); diff --git a/apps/readest-app/src/app/reader/hooks/useWebDAVSync.ts b/apps/readest-app/src/app/reader/hooks/useWebDAVSync.ts index 314bed89..938a1787 100644 --- a/apps/readest-app/src/app/reader/hooks/useWebDAVSync.ts +++ b/apps/readest-app/src/app/reader/hooks/useWebDAVSync.ts @@ -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 diff --git a/apps/readest-app/src/store/readerProgressStore.ts b/apps/readest-app/src/store/readerProgressStore.ts new file mode 100644 index 00000000..ff12f7d2 --- /dev/null +++ b/apps/readest-app/src/store/readerProgressStore.ts @@ -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(() => ({ + 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)); +}; diff --git a/apps/readest-app/src/store/readerStore.ts b/apps/readest-app/src/store/readerStore.ts index 4bcae2c1..440b6a1a 100644 --- a/apps/readest-app/src/store/readerStore.ts +++ b/apps/readest-app/src/store/readerStore.ts @@ -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((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((set, get) => ({ loading: true, inited: false, error: null, - progress: null, ribbonVisible: false, ttsEnabled: false, syncing: false, @@ -288,7 +293,6 @@ export const useReaderStore = create((set, get) => ({ loading: false, inited: false, error: null, - progress: null, ribbonVisible: false, ttsEnabled: false, syncing: false, @@ -312,7 +316,6 @@ export const useReaderStore = create((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((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((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: {