fix(sync): flush pending Readest cloud push when the reader closes (#4535)

Closing a book within the SYNC_PROGRESS_INTERVAL_SEC (3s) auto-sync
debounce window saved progress locally but dropped the pending Readest
cloud push on teardown. The `sync-book-progress` close handler only
reset the pull gate and re-pulled — it never pushed — so other devices
stayed on the previous cloud-synced position until the book was
reopened (issue #4532).

Flush the debounced push at the start of `handleSyncBookProgress`,
before the pull gate is reset, so the latest local position reaches the
cloud before the view tears down. `syncConfig` reads `configPulled`
synchronously, so flushing while the gate is still open takes the push
branch. Mirrors the existing KOSync close-time `pushProgress.flush()`.
The manual Sync button shares the event and now becomes a true two-way
sync (push local, then pull remote).

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Huang Xin
2026-06-11 23:32:14 +08:00
committed by GitHub
parent 1e26c5d765
commit 1a85f251c0
2 changed files with 50 additions and 9 deletions
@@ -276,4 +276,37 @@ describe('useProgressSync', () => {
await advance(1500);
expect(pullCallCount()).toBe(callsBeforeRefresh + 2);
});
test('sync-book-progress flushes the pending cloud push on book close', async () => {
// Reproduces issue #4532: the reader is closed inside the 3s auto-sync
// debounce window, so the pending Readest cloud push would otherwise be
// dropped on unmount and never reach the cloud.
// Mount: the empty pull settles and opens the gate (configPulled = true).
const { rerender } = renderHook(() => useProgressSync('h1-view1'));
await advance(0);
expect(pushCallCount()).toBe(0);
// User paginates to a new position — this arms the 3s auto-sync debounce.
h.state.progress = { location: 'cfi-loc-next' };
await act(async () => {
rerender();
await flushMicrotasks();
});
// The debounce has not fired yet, so nothing has been pushed.
expect(pushCallCount()).toBe(0);
// Closing the reader dispatches sync-book-progress within the debounce
// window — before the 3s timer would have fired.
await act(async () => {
const listeners = h.eventListeners.get('sync-book-progress');
listeners?.forEach((fn) =>
fn(new CustomEvent('sync-book-progress', { detail: { bookKey: 'h1-view1' } })),
);
await flushMicrotasks();
});
// The pending push is flushed immediately — Device A's last local position
// reaches the cloud before the reader tears down.
expect(pushCallCount()).toBeGreaterThanOrEqual(1);
});
});
@@ -130,9 +130,24 @@ export const useProgressSync = (bookKey: string) => {
}
};
// 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.
@@ -143,7 +158,8 @@ export const useProgressSync = (bookKey: string) => {
}
};
// Push: ad-hoc push when the book is closed
// 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 () => {
@@ -152,14 +168,6 @@ export const useProgressSync = (bookKey: string) => {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [bookKey]);
// eslint-disable-next-line react-hooks/exhaustive-deps
const handleAutoSync = useCallback(
debounce(() => {
syncConfig();
}, SYNC_PROGRESS_INTERVAL_SEC * 1000),
[],
);
// Push: auto-push progress when progress changes with a debounce
useEffect(() => {
if (!progress?.location || !user) return;