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:
Huang Xin
2026-05-28 21:55:07 +08:00
committed by GitHub
parent 48d52ea898
commit e29331bea9
6 changed files with 415 additions and 28 deletions
@@ -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