fix(sync): push books row alongside in-reader progress auto-sync (#4209)

The library sync lane (useBooksSync) only runs while the library page is
mounted. While a reader stays open on one device, in-reader auto-sync
pushes `configs` but never re-pushes the `books` row, so other devices'
library pull-to-refresh keeps showing stale reading progress until the
source reader is closed.

useProgressSync.pushConfig now also forwards the in-memory library Book
through the books lane after pushing the config. useProgressAutoSave has
already merged config.progress into that Book via saveConfig, so the
books push carries the up-to-date progress.

Fixes #4198

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Huang Xin
2026-05-18 00:44:17 +08:00
committed by GitHub
parent 0fba5b7054
commit 952304a956
2 changed files with 141 additions and 1 deletions
@@ -0,0 +1,127 @@
import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest';
import { act, cleanup, renderHook } from '@testing-library/react';
const h = vi.hoisted(() => {
// Zustand-like store mock: callable selector returning `state`, plus `.getState()`.
const makeStore = <T,>(state: T) => {
const fn = () => state;
(fn as unknown as { getState: () => T }).getState = () => state;
return fn as (() => T) & { getState: () => T };
};
const book = {
hash: 'h1',
format: 'PDF',
metaHash: 'm1',
updatedAt: 2000,
progress: [5, 100] as [number, number],
};
const config = {
progress: [5, 100] as [number, number],
location: 'cfi-loc',
updatedAt: 1000,
};
const libraryBook = { hash: 'h1', updatedAt: 2000, progress: [5, 100] as [number, number] };
return {
makeStore,
book,
config,
libraryBook,
user: { id: 'u1' },
syncConfigsMock: vi.fn(async () => {}),
syncBooksMock: vi.fn(async () => {}),
state: { syncedConfigs: [] as unknown[] | null },
};
});
vi.mock('@/context/AuthContext', () => ({
useAuth: () => ({ user: h.user }),
}));
vi.mock('@/hooks/useSync', () => ({
useSync: () => ({
syncedConfigs: h.state.syncedConfigs,
syncConfigs: h.syncConfigsMock,
syncBooks: h.syncBooksMock,
}),
}));
vi.mock('@/hooks/useTranslation', () => ({
useTranslation: () => (s: string) => s,
}));
vi.mock('@/store/bookDataStore', () => ({
useBookDataStore: h.makeStore({
getConfig: () => h.config,
setConfig: vi.fn(),
getBookData: () => ({ book: h.book }),
}),
}));
vi.mock('@/store/readerStore', () => ({
useReaderStore: h.makeStore({
getView: () => ({
renderer: { getContents: () => [], primaryIndex: 0 },
goTo: vi.fn(),
}),
getProgress: () => ({ location: 'cfi-loc' }),
setHoveredBookKey: vi.fn(),
getViewState: () => ({ previewMode: false }),
}),
}));
vi.mock('@/store/settingsStore', () => ({
useSettingsStore: h.makeStore({ settings: { globalViewSettings: {} } }),
}));
vi.mock('@/store/libraryStore', () => ({
useLibraryStore: h.makeStore({ library: [h.libraryBook] }),
}));
vi.mock('@/utils/serializer', () => ({
serializeConfig: () => JSON.stringify({ progress: [5, 100], location: 'cfi-loc' }),
}));
vi.mock('@/utils/xcfi', () => ({
getCFIFromXPointer: vi.fn(async () => ''),
getXPointerFromCFI: vi.fn(async () => ({ xpointer: '' })),
}));
vi.mock('@/libs/document', () => ({
CFI: { compare: () => 0 },
}));
import { useProgressSync } from '@/app/reader/hooks/useProgressSync';
import { SYNC_PROGRESS_INTERVAL_SEC } from '@/services/constants';
const flushAutoSync = async () => {
await act(async () => {
vi.advanceTimersByTime(SYNC_PROGRESS_INTERVAL_SEC * 1000 + 100);
for (let i = 0; i < 10; i++) await Promise.resolve();
});
};
beforeEach(() => {
vi.useFakeTimers();
h.syncConfigsMock.mockClear();
h.syncBooksMock.mockClear();
h.state.syncedConfigs = [];
});
afterEach(() => {
vi.useRealTimers();
cleanup();
});
describe('useProgressSync', () => {
test('auto-sync push also pushes the books row so other devices see fresh progress', async () => {
renderHook(() => useProgressSync('h1-view1'));
await flushAutoSync();
// configs lane still pushed
expect(h.syncConfigsMock).toHaveBeenCalledWith(expect.any(Array), 'h1', 'm1', 'push');
// books lane also pushed with the in-memory library Book
expect(h.syncBooksMock).toHaveBeenCalledWith([h.libraryBook], 'push');
});
});
@@ -3,6 +3,7 @@ import { useAuth } from '@/context/AuthContext';
import { useSync } from '@/hooks/useSync';
import { BookConfig, FIXED_LAYOUT_FORMATS } from '@/types/book';
import { useBookDataStore } from '@/store/bookDataStore';
import { useLibraryStore } from '@/store/libraryStore';
import { useReaderStore } from '@/store/readerStore';
import { useSettingsStore } from '@/store/settingsStore';
import { useTranslation } from '@/hooks/useTranslation';
@@ -18,7 +19,7 @@ export const useProgressSync = (bookKey: string) => {
const { getConfig, setConfig, getBookData } = useBookDataStore();
const { getView, getProgress, setHoveredBookKey } = useReaderStore();
const { settings } = useSettingsStore();
const { syncedConfigs, syncConfigs } = useSync(bookKey);
const { syncedConfigs, syncConfigs, syncBooks } = useSync(bookKey);
const { user } = useAuth();
const progress = getProgress(bookKey);
@@ -36,6 +37,18 @@ export const useProgressSync = (bookKey: string) => {
);
delete compressedConfig.booknotes;
await syncConfigs([compressedConfig], bookHash, metaHash, 'push');
// Also push the corresponding `books` row. The library sync lane
// (useBooksSync) only runs while the library page is mounted, so while a
// reader stays open the server's `books` record is never re-pushed and
// other devices' library pull-to-refresh keeps showing stale progress
// (issue #4198). useProgressAutoSave has already merged config.progress
// into the in-memory library Book via saveConfig, so we just forward
// that book through the books lane.
const libraryBook = useLibraryStore.getState().library.find((b) => b.hash === bookHash);
if (libraryBook && !libraryBook.deletedAt) {
await syncBooks([libraryBook], 'push');
}
};
const pullConfig = async (bookKey: string) => {