59d4f0aa33
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.
284 lines
12 KiB
TypeScript
284 lines
12 KiB
TypeScript
import { useCallback, useEffect, useRef } from 'react';
|
|
import { useAuth } from '@/context/AuthContext';
|
|
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';
|
|
import { CFI } from '@/libs/document';
|
|
import { debounce } from '@/utils/debounce';
|
|
import { eventDispatcher } from '@/utils/event';
|
|
import { DEFAULT_BOOK_SEARCH_CONFIG, SYNC_PROGRESS_INTERVAL_SEC } from '@/services/constants';
|
|
import { getCFIFromXPointer, getXPointerFromCFI } from '@/utils/xcfi';
|
|
import { isMalformedLocationCfi } from '@/utils/cfi';
|
|
|
|
// Backoff schedule for the first-pull retry on book open. After these
|
|
// attempts the gate releases unconditionally so the user's progress can
|
|
// still sync out even if the server keeps timing out (high Android network
|
|
// concurrency, captive portal, transient 5xx). Total window ≈ 15.5s.
|
|
const PULL_RETRY_DELAYS_MS = [1500, 4000, 10000];
|
|
|
|
export const useProgressSync = (bookKey: string) => {
|
|
const _ = useTranslation();
|
|
// 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();
|
|
// 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);
|
|
const pullAttempt = useRef(0);
|
|
const pullInFlight = useRef(false);
|
|
const pullRetryTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
|
|
const clearPendingPullRetry = () => {
|
|
if (pullRetryTimer.current !== null) {
|
|
clearTimeout(pullRetryTimer.current);
|
|
pullRetryTimer.current = null;
|
|
}
|
|
};
|
|
|
|
const pushConfig = async (bookKey: string, config: BookConfig | null) => {
|
|
const book = getBookData(bookKey)?.book;
|
|
if (!config || !book || !user) return;
|
|
const bookHash = book.hash;
|
|
const metaHash = book.metaHash;
|
|
const newConfig = { ...config, bookHash, metaHash };
|
|
const compressedConfig = JSON.parse(
|
|
serializeConfig(newConfig, settings.globalViewSettings, DEFAULT_BOOK_SEARCH_CONFIG),
|
|
);
|
|
delete compressedConfig.booknotes;
|
|
// The /api/sync POST handler piggybacks books.progress + books.updated_at
|
|
// off this configs push (saves the separate syncBooks round-trip that
|
|
// used to keep the library record fresh while a reader stayed open —
|
|
// see issue #4198). useBooksSync still seeds new books rows when the
|
|
// user is on the library page.
|
|
await syncConfigs([compressedConfig], bookHash, metaHash, 'push');
|
|
};
|
|
|
|
const pullConfig = async (bookKey: string) => {
|
|
const book = getBookData(bookKey)?.book;
|
|
if (!user || !book) return;
|
|
const bookHash = bookKey.split('-')[0]!;
|
|
const metaHash = book.metaHash;
|
|
await syncConfigs([], bookHash, metaHash, 'pull');
|
|
};
|
|
|
|
// Drives the pull on book open. A successful pull is signalled by the
|
|
// [syncedConfigs] effect below flipping `configPulled.current` to true and
|
|
// clearing the retry state — so this function just kicks off the next
|
|
// pull and (re)schedules a retry. If the gate is still closed after
|
|
// PULL_RETRY_DELAYS_MS is exhausted, release it unconditionally so the
|
|
// user's auto-push isn't blocked by a server outage. Re-entry while a
|
|
// pull is in flight or a retry timer is pending is a no-op.
|
|
const pullWithRetry = useCallback(async () => {
|
|
if (configPulled.current) return;
|
|
if (pullInFlight.current) return;
|
|
if (pullRetryTimer.current !== null) return;
|
|
pullInFlight.current = true;
|
|
try {
|
|
await pullConfig(bookKey);
|
|
} finally {
|
|
pullInFlight.current = false;
|
|
}
|
|
if (configPulled.current) return;
|
|
if (pullAttempt.current >= PULL_RETRY_DELAYS_MS.length) {
|
|
// Best-effort release. The server-side last-writer-wins compare still
|
|
// protects the cross-device case (a stale local push with an older
|
|
// updated_at will lose to a fresher server record).
|
|
configPulled.current = true;
|
|
return;
|
|
}
|
|
const delay = PULL_RETRY_DELAYS_MS[pullAttempt.current]!;
|
|
pullAttempt.current += 1;
|
|
pullRetryTimer.current = setTimeout(() => {
|
|
pullRetryTimer.current = null;
|
|
if (!configPulled.current) pullWithRetry();
|
|
}, delay);
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [bookKey]);
|
|
|
|
const syncConfig = async () => {
|
|
if (!configPulled.current) {
|
|
pullWithRetry();
|
|
} else {
|
|
// Skip pushes while previewing a deep-link target — the position in
|
|
// memory reflects the annotation, not what the user is actually reading.
|
|
if (useReaderStore.getState().getViewState(bookKey)?.previewMode) return;
|
|
const config = getConfig(bookKey);
|
|
const view = getView(bookKey);
|
|
const book = getBookData(bookKey)?.book;
|
|
if (config && view && book && config.progress && config.progress[0] > 0) {
|
|
try {
|
|
const contents = view.renderer.getContents();
|
|
const primaryIndex = view.renderer.primaryIndex;
|
|
const content = contents.find((x) => x.index === primaryIndex) ?? contents[0];
|
|
if (content && !FIXED_LAYOUT_FORMATS.has(book.format)) {
|
|
const { doc, index } = content;
|
|
const xpointerResult = await getXPointerFromCFI(config.location!, doc, index || 0);
|
|
config.xpointer = xpointerResult.xpointer;
|
|
}
|
|
} catch (error) {
|
|
console.warn('Failed to convert CFI to XPointer', error);
|
|
}
|
|
pushConfig(bookKey, config);
|
|
}
|
|
}
|
|
};
|
|
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
const handleAutoSync = useCallback(
|
|
debounce(() => {
|
|
syncConfig();
|
|
}, SYNC_PROGRESS_INTERVAL_SEC * 1000),
|
|
[],
|
|
);
|
|
|
|
const handleSyncBookProgress = async (event: CustomEvent) => {
|
|
const { bookKey: syncBookKey } = event.detail;
|
|
if (syncBookKey === bookKey) {
|
|
// Flush any pending debounced push first so the latest local progress
|
|
// reaches the cloud before we (re)pull. This covers the book-close case
|
|
// (issue #4532): the reader can tear down inside the SYNC_PROGRESS_INTERVAL_SEC
|
|
// auto-sync window, which would otherwise drop the pending push and leave
|
|
// other devices on the previous cloud-synced position. Must run while the
|
|
// gate below is still open so syncConfig takes the push branch.
|
|
handleAutoSync.flush();
|
|
// Manual pull-to-refresh: tear down any prior retry chain so the new
|
|
// attempt starts fresh, rather than being short-circuited by the
|
|
// "retry already pending" guard in pullWithRetry.
|
|
configPulled.current = false;
|
|
pullAttempt.current = 0;
|
|
clearPendingPullRetry();
|
|
await pullWithRetry();
|
|
}
|
|
};
|
|
|
|
// Push: flush the pending push + pull when the book is closed or the user
|
|
// taps the manual Sync button.
|
|
useEffect(() => {
|
|
eventDispatcher.on('sync-book-progress', handleSyncBookProgress);
|
|
return () => {
|
|
eventDispatcher.off('sync-book-progress', handleSyncBookProgress);
|
|
};
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [bookKey]);
|
|
|
|
// Push: auto-push progress when progress changes with a debounce
|
|
useEffect(() => {
|
|
if (!progress?.location || !user) return;
|
|
handleAutoSync();
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [progress?.location]);
|
|
|
|
// Pull: pull progress once when the book is opened, with retry on failure
|
|
useEffect(() => {
|
|
if (!progress || hasPulledConfigOnce.current) return;
|
|
hasPulledConfigOnce.current = true;
|
|
pullWithRetry();
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [progress]);
|
|
|
|
// Clean up any pending retry timer on unmount so it doesn't fire after the
|
|
// reader has been torn down.
|
|
useEffect(() => {
|
|
return () => clearPendingPullRetry();
|
|
}, []);
|
|
|
|
const applyRemoteProgress = async (syncedConfigs: BookConfig[]) => {
|
|
const config = getConfig(bookKey);
|
|
const book = getBookData(bookKey)?.book;
|
|
if (!syncedConfigs || syncedConfigs.length === 0 || !config || !book) return;
|
|
|
|
const bookHash = bookKey.split('-')[0]!;
|
|
const metaHash = book.metaHash;
|
|
let syncedConfig = syncedConfigs.filter(
|
|
(c) => c.bookHash === bookHash || c.metaHash === metaHash,
|
|
)[0];
|
|
if (syncedConfig) {
|
|
// Discard a malformed synced location (an empty-start/end range CFI left by
|
|
// the cfi-inert skip-link bug, e.g. `epubcfi(/6/24!/4,,/20/1:58)`) so it
|
|
// can't move the reader or be persisted — it resolves to a section-spanning
|
|
// range and jumps to the wrong end of the section. A valid xpointer below
|
|
// can still recover the real position.
|
|
if (syncedConfig.location && isMalformedLocationCfi(syncedConfig.location)) {
|
|
syncedConfig = { ...syncedConfig, location: undefined };
|
|
}
|
|
const configCFI = config?.location;
|
|
let remoteCFILocation = syncedConfig.location;
|
|
const xpointer = syncedConfig.xpointer;
|
|
const bookData = getBookData(bookKey);
|
|
const view = getView(bookKey);
|
|
if (xpointer && view && bookData && bookData.bookDoc) {
|
|
const pContents = view.renderer.getContents();
|
|
const pIdx = view.renderer.primaryIndex;
|
|
const content = pContents.find((x) => x.index === pIdx) ?? pContents[0];
|
|
const candidateCFI = await getCFIFromXPointer(
|
|
xpointer,
|
|
content?.doc,
|
|
content?.index,
|
|
bookData.bookDoc,
|
|
);
|
|
if (!remoteCFILocation || CFI.compare(remoteCFILocation, candidateCFI) < 0) {
|
|
remoteCFILocation = candidateCFI;
|
|
}
|
|
}
|
|
const filteredSyncedConfig = Object.fromEntries(
|
|
Object.entries(syncedConfig).filter(([_, value]) => value !== null && value !== undefined),
|
|
);
|
|
if (syncedConfig.updatedAt >= config.updatedAt) {
|
|
setConfig(bookKey, { ...config, ...filteredSyncedConfig });
|
|
} else {
|
|
setConfig(bookKey, { ...filteredSyncedConfig, ...config });
|
|
}
|
|
if (remoteCFILocation && configCFI) {
|
|
if (CFI.compare(configCFI, remoteCFILocation) < 0) {
|
|
// While previewing a deep-link target, do NOT yank the view to the
|
|
// remote position — the user came here to look at a specific
|
|
// annotation. The local config still gets updated above; the next
|
|
// open will resolve to the synced position normally.
|
|
const isPreview = useReaderStore.getState().getViewState(bookKey)?.previewMode;
|
|
if (view && !isPreview) {
|
|
view.goTo(remoteCFILocation);
|
|
setHoveredBookKey(null);
|
|
eventDispatcher.dispatch('hint', {
|
|
bookKey,
|
|
message: _('Reading Progress Synced'),
|
|
});
|
|
}
|
|
}
|
|
}
|
|
}
|
|
};
|
|
|
|
// Pull: proccess the pulled progress
|
|
useEffect(() => {
|
|
if (!configPulled.current && syncedConfigs) {
|
|
configPulled.current = true;
|
|
// Pull succeeded — cancel any in-flight retry chain and reset the
|
|
// attempt counter so a future sync-book-progress event starts clean.
|
|
pullAttempt.current = 0;
|
|
clearPendingPullRetry();
|
|
applyRemoteProgress(syncedConfigs).catch((error) => {
|
|
console.error('Failed to apply remote progress', error);
|
|
});
|
|
}
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [syncedConfigs]);
|
|
};
|