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;