forked from akai/readest
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:
@@ -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));
|
||||
};
|
||||
@@ -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: {
|
||||
|
||||
Reference in New Issue
Block a user