Files
readest/apps/readest-app/src/app/reader/hooks/useKOSync.ts
T
loveheaven 59d4f0aa33 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.
2026-06-12 17:14:49 +02:00

414 lines
16 KiB
TypeScript

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';
import { Book, BookProgress, FIXED_LAYOUT_FORMATS } from '@/types/book';
import { BookDoc } from '@/libs/document';
import { debounce } from '@/utils/debounce';
import { eventDispatcher } from '@/utils/event';
import { getCFIFromXPointer, getXPointerFromCFI } from '@/utils/xcfi';
import { isMalformedLocationCfi } from '@/utils/cfi';
import {
formatProgressPercentage,
getLocalProgressPreview,
getProgressPercentage,
} from './kosyncPreview';
import { getRemoteFraction, getRemoteLocalFraction, isXPointerProgress } from './kosyncProgress';
import { useWindowActiveChanged } from './useWindowActiveChanged';
type SyncState = 'idle' | 'checking' | 'conflict' | 'synced' | 'error';
export interface SyncDetails {
book: Book;
bookDoc: BookDoc;
local: {
cfi?: string;
preview: string;
};
remote: KoSyncProgress & {
preview: string;
percentage?: number;
};
}
export const useKOSync = (bookKey: string) => {
const _ = useTranslation();
const { appService } = useEnv();
const { settings } = useSettingsStore();
// 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');
const [conflictDetails, setConflictDetails] = useState<SyncDetails | null>(null);
const [errorMessage] = useState<string | null>(null);
const hasPulledOnce = useRef(false);
// 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) {
setKOSyncClient(null);
return;
}
const client = new KOSyncClient(settings.kosync);
setKOSyncClient(client);
}, [settings]);
const generateKOProgress = useCallback(async () => {
const progress = getProgress(bookKey);
const bookData = getBookData(bookKey);
if (!progress || !bookData) return null;
let koProgress = '';
let percentage: number;
if (bookData.isFixedLayout) {
const page = progress.section?.current ?? 0;
const totalPages = progress.section?.total ?? 0;
koProgress = page.toString();
percentage = totalPages > 0 ? (page + 1) / totalPages : 0;
} else {
const view = getView(bookKey);
const config = getConfig(bookKey);
const cfi = progress.location;
if (!view || !cfi) return null;
if (isMalformedLocationCfi(cfi)) {
// A malformed empty-start/end CFI (cfi-inert skip-link bug) resolves to
// the wrong end of the section. Don't derive an XPointer from it — once
// pushed as an XPointer the "malformed" signal is lost and other devices
// can't discard it. Reuse the last known-good XPointer instead.
if (config?.xpointer) koProgress = config.xpointer;
} else {
try {
const koContents = view.renderer.getContents();
const koPrimaryIdx = view.renderer.primaryIndex;
const content = koContents.find((x) => x.index === koPrimaryIdx) ?? koContents[0];
// progress.location may be a CFI in a different spine section than the
// currently-rendered primary view (#primaryIndex can lag behind the
// viewport while scrolling). Resolve against the CFI's own section
// rather than forcing the primary view's document, which throws on a
// spine-index mismatch.
const xpointerResult = await getXPointerFromCFI(
cfi,
content?.doc,
content?.index,
bookData.bookDoc ?? undefined,
);
koProgress = xpointerResult.xpointer;
setConfig(bookKey, { xpointer: koProgress });
} catch (error) {
console.error('Failed to convert CFI to XPointer', error);
if (config?.xpointer) koProgress = config.xpointer;
}
}
const page = progress.pageinfo?.current ?? 0;
const totalPages = progress.pageinfo?.total ?? 0;
percentage = totalPages > 0 ? (page + 1) / totalPages : 0;
}
return { koProgress, percentage };
}, [bookKey, getProgress, getBookData, getView, getConfig, setConfig]);
const applyRemoteProgress = async (book: Book, bookDoc: BookDoc, remote: KoSyncProgress) => {
const view = getView(bookKey);
const bookData = getBookData(bookKey);
if (!view || !bookData) return;
if (FIXED_LAYOUT_FORMATS.has(book.format)) {
const pageToGo = parseInt(remote.progress!, 10);
if (isNaN(pageToGo)) return;
view.select(pageToGo - 1);
} else {
let navigated = false;
// KOReader stores positions as CREngine XPointers; convert and jump
// precisely when we have one.
if (isXPointerProgress(remote.progress)) {
try {
const content = view.renderer
.getContents()
.find((x) => x.index === view.renderer.primaryIndex);
const cfi = await getCFIFromXPointer(
remote.progress!,
content?.doc,
content?.index,
bookDoc,
);
view.goTo(cfi);
navigated = true;
} catch (error) {
console.error('Failed to convert XPointer to CFI', error);
}
}
// Other KOSync-compatible servers (e.g. Kavita) report progress in
// formats Readest can't resolve positionally — approximate with the
// reported percentage so "use remote" still moves the reader.
if (!navigated) {
const remoteFraction = getRemoteFraction(remote);
if (remoteFraction === undefined) return;
view.goToFraction(remoteFraction);
}
}
eventDispatcher.dispatch('hint', {
bookKey,
message: _('Reading Progress Synced'),
});
};
const promptedSync = async (
book: Book,
bookDoc: BookDoc,
local: BookProgress,
remote: KoSyncProgress,
) => {
let remotePreview = '';
const remotePercentage = remote.percentage || 0;
// Progress last pushed from this same device is just our own earlier
// position; only treat a sizeable jump (≥1%) as a conflict so we don't
// prompt on the sub-page drift between a push and the next pull.
const isSameDevice = !!remote.device_id && remote.device_id === settings.kosync.deviceId;
const conflictProgressDiffThreshold = isSameDevice ? 0.01 : 0.0001;
// The remote progress as a percentage to compare against the local one;
// refined to a locally-resolved fraction for reflowable books below.
let remoteComparePercentage = remotePercentage;
let showConflictDetails = false;
const isFixedLayout = FIXED_LAYOUT_FORMATS.has(book.format);
const localPreview = getLocalProgressPreview(local, isFixedLayout, _);
const localPercentage = getProgressPercentage(isFixedLayout ? local.section : local.pageinfo);
if (isFixedLayout) {
const localPageInfo = local.section;
const remotePage = parseInt(remote.progress!, 10);
if (!isNaN(remotePage) && remotePercentage > 0) {
const localTotalPages = localPageInfo?.total ?? 0;
const remoteTotalPages = Math.round(remotePage / remotePercentage);
const pagesMatch = Math.abs(localTotalPages - remoteTotalPages) <= 1;
if (pagesMatch) {
remotePreview = _('Page {{page}} of {{total}} ({{percentage}}%)', {
page: remotePage,
total: remoteTotalPages,
percentage: formatProgressPercentage(remotePercentage),
});
} else {
remotePreview = _('Approximately page {{page}} of {{total}} ({{percentage}}%)', {
page: remotePage,
total: remoteTotalPages,
percentage: formatProgressPercentage(remotePercentage),
});
}
showConflictDetails =
Math.abs(localPercentage - remotePercentage) > conflictProgressDiffThreshold;
} else {
remotePreview = _('Approximately {{percentage}}%', {
percentage: formatProgressPercentage(remotePercentage),
});
}
} else {
// KOReader's reported percentage comes from its own pagination, so it's
// not directly comparable to Readest's progress. Resolve the remote
// position to a local fraction for an apples-to-apples comparison and
// fall back to the reported percentage only when it can't be resolved
// locally (non-XPointer progress or a missing section).
const view = getView(bookKey);
const localFraction = view ? await getRemoteLocalFraction(remote, view, bookDoc) : undefined;
remoteComparePercentage = localFraction ?? remotePercentage;
remotePreview = _('Approximately {{percentage}}%', {
percentage: formatProgressPercentage(remoteComparePercentage),
});
showConflictDetails =
Math.abs(localPercentage - remoteComparePercentage) > conflictProgressDiffThreshold;
}
if (showConflictDetails) {
setConflictDetails({
book,
bookDoc,
local: { cfi: local.location, preview: localPreview },
remote: { ...remote, preview: remotePreview },
});
}
return showConflictDetails;
};
const pushProgress = useMemo(
() =>
debounce(async () => {
if (!bookKey || !appService || !kosyncClient || !hasPulledOnce.current) return;
const { settings } = useSettingsStore.getState();
if (['receive', 'disable'].includes(settings.kosync.strategy)) return;
const currentBook = getBookData(bookKey)?.book;
const progress = await generateKOProgress();
if (!currentBook || !progress || !progress.koProgress) return;
console.log('[KOSync] Pushing progress');
await kosyncClient.updateProgress(currentBook, progress.koProgress, progress.percentage);
}, 5000),
// eslint-disable-next-line react-hooks/exhaustive-deps
[bookKey, appService, kosyncClient],
);
const pullProgress = useCallback(
async () => {
if (!progress?.location || !appService || !kosyncClient) return;
const bookData = getBookData(bookKey);
const book = bookData?.book;
const bookDoc = bookData?.bookDoc;
if (!book || !bookDoc) return;
const { strategy, enabled } = settings.kosync;
if (!enabled) return;
hasPulledOnce.current = true;
if (strategy === 'send') {
setSyncState('synced');
return;
}
setSyncState('checking');
const remoteProgress = await kosyncClient.getProgress(book);
if (!remoteProgress || !remoteProgress.progress) {
setSyncState('synced');
return;
}
console.log('[KOSync] Pulled remote progress', { bookKey, remoteProgress });
const localTimestamp = bookData?.config?.updatedAt || book.updatedAt;
const remoteTimestamp = remoteProgress.timestamp
? remoteProgress.timestamp * 1000
: Date.now();
const remoteIsNewer = remoteTimestamp > localTimestamp;
if (strategy === 'receive' || (strategy === 'silent' && remoteIsNewer)) {
applyRemoteProgress(book, bookDoc, remoteProgress);
setSyncState('synced');
} else if (strategy === 'prompt') {
// Only stay in the conflict state when there's an actual conflict to
// resolve; otherwise return to 'synced' so auto-push keeps working.
const hasConflict = await promptedSync(book, bookDoc, progress, remoteProgress);
setSyncState(hasConflict ? 'conflict' : 'synced');
} else {
setSyncState('synced');
}
},
// eslint-disable-next-line react-hooks/exhaustive-deps
[bookKey, appService, kosyncClient, settings.kosync, progress],
);
// use a ref to track the current push/pull functions so they can change without triggering effects
const syncRefs = useRef({ pushProgress, pullProgress });
useEffect(() => {
syncRefs.current = { pushProgress, pullProgress };
}, [pushProgress, pullProgress]);
useEffect(() => {
const handlePushProgress = (event: CustomEvent) => {
const { pushProgress } = syncRefs.current;
if (event.detail.bookKey !== bookKey) return;
pushProgress();
pushProgress.flush();
};
const handleFlush = (event: CustomEvent) => {
const { pushProgress } = syncRefs.current;
if (event.detail.bookKey !== bookKey) return;
pushProgress.flush();
};
eventDispatcher.on('push-kosync', handlePushProgress);
eventDispatcher.on('flush-kosync', handleFlush);
return () => {
const { pushProgress } = syncRefs.current;
eventDispatcher.off('push-kosync', handlePushProgress);
eventDispatcher.off('flush-kosync', handleFlush);
pushProgress.flush();
};
}, [bookKey]);
useEffect(() => {
const handlePullProgress = (event: CustomEvent) => {
if (event.detail.bookKey !== bookKey) return;
const { pullProgress } = syncRefs.current;
pullProgress();
};
eventDispatcher.on('pull-kosync', handlePullProgress);
return () => {
eventDispatcher.off('pull-kosync', handlePullProgress);
};
}, [bookKey]);
// Pull: pull progress once when the book is opened
useEffect(() => {
if (!appService || !kosyncClient || !progress?.location) return;
if (hasPulledOnce.current) return;
syncRefs.current.pullProgress();
}, [appService, kosyncClient, progress?.location]);
// Push: auto-push progress when progress changes with a debounce
useEffect(() => {
if (syncState === 'synced' && progress) {
// Skip auto-pushes while previewing a deep-link target. Manual pushes
// via the 'push-kosync' event are still respected (explicit user intent).
if (useReaderStore.getState().getViewState(bookKey)?.previewMode) return;
const { strategy, enabled } = settings.kosync;
if (strategy !== 'receive' && enabled) {
syncRefs.current.pushProgress();
}
}
}, [progress, syncState, settings.kosync, bookKey]);
useWindowActiveChanged((isActive) => {
const { pushProgress, pullProgress } = syncRefs.current;
if (isActive) {
hasPulledOnce.current = false;
pullProgress();
} else {
pushProgress();
pushProgress.flush();
}
});
const resolveWithLocal = () => {
pushProgress();
pushProgress.flush();
setSyncState('synced');
setConflictDetails(null);
};
const resolveWithRemote = async () => {
const view = getView(bookKey);
const remote = conflictDetails?.remote;
const book = conflictDetails?.book;
const bookDoc = conflictDetails?.bookDoc;
if (!book || !bookDoc || !remote || !view) return;
if (!remote.progress && getRemoteFraction(remote) === undefined) return;
applyRemoteProgress(book, bookDoc, remote);
setSyncState('synced');
setConflictDetails(null);
};
return {
syncState,
conflictDetails,
errorMessage,
pushProgress,
pullProgress,
resolveWithLocal,
resolveWithRemote,
};
};