From 1a85f251c034e0d4acc6a880edba822be8f734eb Mon Sep 17 00:00:00 2001 From: Huang Xin Date: Thu, 11 Jun 2026 23:32:14 +0800 Subject: [PATCH] fix(sync): flush pending Readest cloud push when the reader closes (#4535) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../__tests__/hooks/useProgressSync.test.tsx | 33 +++++++++++++++++++ .../src/app/reader/hooks/useProgressSync.ts | 26 ++++++++++----- 2 files changed, 50 insertions(+), 9 deletions(-) diff --git a/apps/readest-app/src/__tests__/hooks/useProgressSync.test.tsx b/apps/readest-app/src/__tests__/hooks/useProgressSync.test.tsx index 7cb78404..b7c39397 100644 --- a/apps/readest-app/src/__tests__/hooks/useProgressSync.test.tsx +++ b/apps/readest-app/src/__tests__/hooks/useProgressSync.test.tsx @@ -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); + }); }); diff --git a/apps/readest-app/src/app/reader/hooks/useProgressSync.ts b/apps/readest-app/src/app/reader/hooks/useProgressSync.ts index e98d999e..44a82be4 100644 --- a/apps/readest-app/src/app/reader/hooks/useProgressSync.ts +++ b/apps/readest-app/src/app/reader/hooks/useProgressSync.ts @@ -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;