forked from akai/readest
fix(sync): prevent cross-device progress overwrite; retry first pull on flaky networks (#4341)
Closes #4222. Three changes that together stabilize Readest sync across devices: **Stop the artificial updatedAt bump in useProgressAutoSave** saveConfig unconditionally bumps config.updatedAt = Date.now(), and useProgressAutoSave used to fire saveConfig on the very first relocate after book open — even though that relocate just reflects the position loaded from disk, not user action. The stale local config then looked "newer" than a fresher server-side push, so the next auto-push overwrote the other device's real progress via last-writer-wins. The hook now snapshots the loaded location and skips saveConfig when the in-memory location still matches it. **Retry the first config pull with backoff + release the gate** useProgressSync gates pushes behind a successful pull (so a brand-new import can't clobber the server's real progress). But handleAutoSync only re-arms on progress.location changes, so a single failed pull (Android cold-start contention, Wi-Fi/LTE handoff, captive portal) used to block every push for the whole reader session. The new pullWithRetry retries on backoff (1500/4000/10000 ms) and releases the gate after exhaustion — server-side last-writer-wins still protects the cross-device case (a stale local push with an older updated_at loses to a fresher server record). sync-book-progress events reset the chain so manual pull-to-refresh recovers cleanly. Fetch timeout bumped from 8s to 15s to better tolerate slow networks in that cold-start window. **Server piggybacks books.progress off configs push** /api/sync POST now updates books.progress + books.updated_at for each upserted config, gated by .lt('updated_at') so a concurrent newer books push is never downgraded and a missing row is a silent no-op (useBooksSync still seeds new rows from the library page). The in-reader syncBooks round-trip is dropped — the reader now sends one POST per auto-save instead of two, and the books row stays consistent with config pushes even while a reader stays open (#4198). Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
import { useCallback, useEffect } from 'react';
|
||||
import { useCallback, useEffect, useRef } from 'react';
|
||||
import { useEnv } from '@/context/EnvContext';
|
||||
import { useBookDataStore } from '@/store/bookDataStore';
|
||||
import { useReaderStore } from '@/store/readerStore';
|
||||
@@ -11,6 +11,15 @@ export const useProgressAutoSave = (bookKey: string) => {
|
||||
const { getProgress } = useReaderStore();
|
||||
const progress = getProgress(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
|
||||
// in-memory location matches — saveConfig unconditionally bumps
|
||||
// config.updatedAt, and a bump on the initial relocate makes the local
|
||||
// record look newer than a fresher server-side push, so the next sync
|
||||
// overwrites the server's progress with the stale local one (issue #4222).
|
||||
const lastSavedLocationRef = useRef<string | null>(null);
|
||||
const initializedRef = useRef(false);
|
||||
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
const saveBookConfig = useCallback(
|
||||
debounce(() => {
|
||||
@@ -20,13 +29,34 @@ export const useProgressAutoSave = (bookKey: string) => {
|
||||
if (useReaderStore.getState().getViewState(bookKey)?.previewMode) return;
|
||||
const config = getConfig(bookKey);
|
||||
if (!config) return;
|
||||
const currentLocation = config.location ?? null;
|
||||
if (!initializedRef.current) {
|
||||
initializedRef.current = true;
|
||||
lastSavedLocationRef.current = currentLocation;
|
||||
return;
|
||||
}
|
||||
if (currentLocation === lastSavedLocationRef.current) return;
|
||||
const settings = useSettingsStore.getState().settings;
|
||||
await saveConfig(envConfig, bookKey, config, settings);
|
||||
lastSavedLocationRef.current = currentLocation;
|
||||
}, 500);
|
||||
}, 1000),
|
||||
[],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
// Snapshot the loaded-from-disk location before any progress events fire,
|
||||
// so we don't treat the initial relocate as a user-driven change.
|
||||
if (!initializedRef.current) {
|
||||
const config = getConfig(bookKey);
|
||||
if (config) {
|
||||
initializedRef.current = true;
|
||||
lastSavedLocationRef.current = config.location ?? null;
|
||||
}
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [bookKey]);
|
||||
|
||||
useEffect(() => {
|
||||
saveBookConfig();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
|
||||
Reference in New Issue
Block a user