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:
@@ -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) => {
|
||||
|
||||
Reference in New Issue
Block a user