fix(sync): remove the mixed-fleet info toast, closes #5720 (#5726)

The mixed-fleet probe compared native book rows against a fixed anchor
(readestCloud.disabledAt or the earliest providerSelectedAt) while the
"shown once" latch lived in process-local state, so any row written
after the anchor kept re-firing the "Another device is still syncing
this library via Readest Cloud" toast on every app launch with no way
to dismiss it. Remove the probe, its store latch, and the toast; the
provider gating of the native sync channels is unchanged.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Huang Xin 2026-08-15 23:51:08 +08:00 committed by GitHub
parent 6f67be7030
commit a0a152ae5f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 3 additions and 276 deletions

View file

@ -66,10 +66,6 @@ vi.mock('@/services/sync/file/runLibrarySync', () => ({
runFileLibrarySyncPass: vi.fn(async () => ({ booksSynced: 0 })),
}));
vi.mock('@/services/sync/fleetDetection', () => ({
checkMixedFleetOnce: vi.fn(),
}));
const { useBooksSync } = await import('@/app/library/hooks/useBooksSync');
const { useLibraryStore } = await import('@/store/libraryStore');

View file

@ -64,10 +64,6 @@ vi.mock('@/services/sync/file/runLibrarySync', () => ({
runFileLibrarySyncPass: vi.fn(async () => ({ booksSynced: 0 })),
}));
vi.mock('@/services/sync/fleetDetection', () => ({
checkMixedFleetOnce: vi.fn(),
}));
vi.mock('@/services/rss/feedBook', () => ({ ensureFeedBookCover }));
const { useBooksSync } = await import('@/app/library/hooks/useBooksSync');

View file

@ -61,10 +61,6 @@ vi.mock('@/services/sync/file/runLibrarySync', () => ({
runFileLibrarySyncPass: vi.fn(async () => ({ booksSynced: 0 })),
}));
vi.mock('@/services/sync/fleetDetection', () => ({
checkMixedFleetOnce: vi.fn(),
}));
vi.mock('@/services/rss/feedBook', () => ({ ensureFeedBookCover }));
const { useBooksSync } = await import('@/app/library/hooks/useBooksSync');

View file

@ -49,8 +49,6 @@ const runFileLibrarySyncPass = vi.hoisted(() =>
vi.fn(async (): Promise<{ booksSynced: number } | null> => ({ booksSynced: 1 })),
);
const checkMixedFleetOnce = vi.hoisted(() => vi.fn(async () => false));
vi.mock('@/context/AuthContext', () => ({
useAuth: () => ({ user: { id: 'user-1' } }),
}));
@ -88,10 +86,6 @@ vi.mock('@/services/sync/file/runLibrarySync', () => ({
runFileLibrarySyncPass,
}));
vi.mock('@/services/sync/fleetDetection', () => ({
checkMixedFleetOnce,
}));
const { useBooksSync } = await import('@/app/library/hooks/useBooksSync');
const { useLibraryStore } = await import('@/store/libraryStore');
const { eventDispatcher } = await import('@/utils/event');
@ -236,28 +230,3 @@ describe('useBooksSync pullLibrary routing (issue #5062)', () => {
expect(toastCalls[0]?.[1]).toMatchObject({ type: 'info', message: '4 book(s) synced' });
});
});
describe('useBooksSync handleAutoSync mixed-fleet probe gate (issue #5062)', () => {
it('runs the mixed-fleet probe when Readest Cloud is off', async () => {
routing.readestEnabled = false;
routing.backends = ['webdav'];
renderHook(() => useBooksSync());
await waitFor(() => expect(checkMixedFleetOnce).toHaveBeenCalled());
});
it('does not run the mixed-fleet probe when Readest Cloud is on', async () => {
routing.readestEnabled = true;
routing.backends = [];
renderHook(() => useBooksSync());
// A probe warning "another device still syncs via Readest Cloud" is
// meaningless while this device also syncs via Readest Cloud. Wait for
// the native pull (which does run in this scenario) as a sync point
// before asserting the probe never fired.
await waitFor(() => expect(syncState.syncBooks).toHaveBeenCalled());
expect(checkMixedFleetOnce).not.toHaveBeenCalled();
});
});

View file

@ -1,146 +0,0 @@
import { describe, test, expect, beforeEach, vi } from 'vitest';
import type { SystemSettings } from '@/types/settings';
import { useFileSyncStore } from '@/store/fileSyncStore';
vi.mock('@/utils/event', () => ({
eventDispatcher: {
dispatch: vi.fn(),
},
}));
import { checkMixedFleetOnce } from '@/services/sync/fleetDetection';
import { eventDispatcher } from '@/utils/event';
import type { SyncClient } from '@/libs/sync';
const translationFn = (key: string) => key;
const makeSyncClient = (books: unknown[] | null): SyncClient =>
({
pullChanges: vi.fn(async () => ({ books, configs: null, notes: null })),
}) as unknown as SyncClient;
const settingsWith = (patch: Partial<SystemSettings>): SystemSettings =>
({
version: 1,
webdav: { enabled: false },
googleDrive: { enabled: false },
...patch,
}) as SystemSettings;
beforeEach(() => {
vi.clearAllMocks();
useFileSyncStore.setState({
byKind: {},
activeKind: null,
lastErrorByKind: {},
fleetNoticeShown: false,
});
});
describe('checkMixedFleetOnce', () => {
test('no probe when readest is the provider', async () => {
const client = makeSyncClient([]);
expect(await checkMixedFleetOnce(client, settingsWith({}), translationFn)).toBe(false);
expect(client.pullChanges).not.toHaveBeenCalled();
});
test('no probe without a providerSelectedAt anchor', async () => {
const client = makeSyncClient([]);
const settings = settingsWith({ webdav: { enabled: true } } as Partial<SystemSettings>);
expect(await checkMixedFleetOnce(client, settings, translationFn)).toBe(false);
expect(client.pullChanges).not.toHaveBeenCalled();
});
test('probes read-only since the selection anchor and notifies when another writer exists', async () => {
const client = makeSyncClient([{ book_hash: 'h1' }]);
const settings = settingsWith({
webdav: { enabled: true, providerSelectedAt: 12345 },
} as Partial<SystemSettings>);
expect(await checkMixedFleetOnce(client, settings, translationFn)).toBe(true);
expect(client.pullChanges).toHaveBeenCalledWith(12345, 'books', undefined, undefined, 1);
expect(vi.mocked(eventDispatcher.dispatch)).toHaveBeenCalledWith(
'toast',
expect.objectContaining({
message: expect.stringContaining('Another device is still syncing'),
}),
);
});
test('notifies only once per session', async () => {
const client = makeSyncClient([{ book_hash: 'h1' }]);
const settings = settingsWith({
webdav: { enabled: true, providerSelectedAt: 12345 },
} as Partial<SystemSettings>);
await checkMixedFleetOnce(client, settings, translationFn);
await checkMixedFleetOnce(client, settings, translationFn);
expect(vi.mocked(eventDispatcher.dispatch)).toHaveBeenCalledTimes(1);
});
test('quiet when no newer rows exist', async () => {
const client = makeSyncClient([]);
const settings = settingsWith({
webdav: { enabled: true, providerSelectedAt: 12345 },
} as Partial<SystemSettings>);
expect(await checkMixedFleetOnce(client, settings, translationFn)).toBe(false);
expect(vi.mocked(eventDispatcher.dispatch)).not.toHaveBeenCalled();
});
test('probe failures are silent (offline is not a fleet problem)', async () => {
const client = {
pullChanges: vi.fn(async () => {
throw new Error('Not authenticated');
}),
} as unknown as SyncClient;
const settings = settingsWith({
webdav: { enabled: true, providerSelectedAt: 12345 },
} as Partial<SystemSettings>);
expect(await checkMixedFleetOnce(client, settings, translationFn)).toBe(false);
expect(vi.mocked(eventDispatcher.dispatch)).not.toHaveBeenCalled();
});
test('does not probe when Readest Cloud is enabled alongside a backend', async () => {
const pullChanges = vi.fn();
const settings = {
readestCloud: { enabled: true },
googleDrive: { enabled: true, providerSelectedAt: 1000 },
} as unknown as SystemSettings;
expect(await checkMixedFleetOnce({ pullChanges } as never, settings, translationFn)).toBe(
false,
);
expect(pullChanges).not.toHaveBeenCalled();
});
test('probes since readestCloud.disabledAt when Readest Cloud is off', async () => {
const pullChanges = vi.fn().mockResolvedValue({ books: [{ hash: 'h' }] });
const settings = {
readestCloud: { enabled: false, disabledAt: 5000 },
googleDrive: { enabled: true, providerSelectedAt: 1000 },
} as unknown as SystemSettings;
expect(await checkMixedFleetOnce({ pullChanges } as never, settings, translationFn)).toBe(true);
expect(pullChanges).toHaveBeenCalledWith(5000, 'books', undefined, undefined, 1);
});
test('falls back to the earliest providerSelectedAt for a legacy user', async () => {
const pullChanges = vi.fn().mockResolvedValue({ books: [] });
const settings = {
// No readestCloud field: derived off, because a backend is enabled.
// webdav is enumerated before onedrive by getEnabledFileSyncBackends,
// but its providerSelectedAt is the LATER one, so a correct
// implementation must take the minimum across backends rather than
// the first enabled backend's value.
webdav: { enabled: true, providerSelectedAt: 9000 },
onedrive: { enabled: true, providerSelectedAt: 3000 },
} as unknown as SystemSettings;
await checkMixedFleetOnce({ pullChanges } as never, settings, translationFn);
expect(pullChanges).toHaveBeenCalledWith(3000, 'books', undefined, undefined, 1);
});
});

View file

@ -18,8 +18,6 @@ import { isDemoBook } from '@/services/demoBooks';
import { isFeedBook } from '@/services/rss/feedBookUrl';
import { ensureFeedBookCover } from '@/services/rss/feedBook';
import { runFileLibrarySyncPass } from '@/services/sync/file/runLibrarySync';
import { checkMixedFleetOnce } from '@/services/sync/fleetDetection';
import { useSyncContext } from '@/context/SyncContext';
import {
pickFresherReadingStatus,
needsCoverRefresh,
@ -35,7 +33,6 @@ export const useBooksSync = () => {
const { library, isSyncing, libraryLoaded } = useLibraryStore();
const { setLibrary, setIsSyncing, setSyncProgress } = useLibraryStore();
const { useSyncInited, syncedBooks, syncBooks, lastSyncedAtBooks } = useSync();
const { syncClient } = useSyncContext();
const isPullingRef = useRef(false);
const getNewBooks = useCallback(() => {
@ -136,15 +133,10 @@ export const useBooksSync = () => {
throttle(
async () => {
if (isPullingRef.current) return;
// Readest Cloud unchecked: the native book channel is gated, so the
// interval runs the read-only mixed-fleet probe instead — a device
// still writing natively would otherwise fork progress silently
// (the auto library sync itself is useLibraryFileSync's).
// Readest Cloud unchecked: the native book channel is gated (the auto
// library sync itself is useLibraryFileSync's).
const settingsNow = useSettingsStore.getState().settings;
if (!isReadestCloudEnabled(settingsNow)) {
void checkMixedFleetOnce(syncClient, settingsNow, _);
return;
}
if (!isReadestCloudEnabled(settingsNow)) return;
const newBooks = getNewBooks();
if (!newBooks.lastSyncedAt) return;
isPullingRef.current = true;

View file

@ -1,70 +0,0 @@
import type { SyncClient } from '@/libs/sync';
import type { SystemSettings } from '@/types/settings';
import type { TranslationFunc } from '@/hooks/useTranslation';
import { useFileSyncStore } from '@/store/fileSyncStore';
import {
getEnabledFileSyncBackends,
isReadestCloudEnabled,
settingsKeyForBackend,
} from '@/services/sync/cloudSyncProvider';
import { eventDispatcher } from '@/utils/event';
/**
* Mixed-fleet detection (UC1). The cloud sync provider selection is
* device-local by design, so a desktop on WebDAV and a phone on Readest
* Cloud fork reading progress with zero errors on either side the
* failure would otherwise present as "sync stopped working". The probe
* runs only while Readest Cloud is switched off on this device.
*
* While the native book/progress/note channels are gated, probe
* `/api/sync` READ-ONLY for any book row newer than the moment this
* device stopped writing them (`readestCloud.disabledAt`, falling back to
* the earliest `providerSelectedAt` for legacy users). Any newer row means
* another device is still on Readest Cloud. Nothing from the probe is
* applied locally, and no native writes resume.
*
* The notice fires once per app session (state in fileSyncStore,
* process-local); probe failures are silent offline or logged-out is
* not a fleet problem, and the probe re-runs on the ordinary auto-sync
* cadence.
*/
export const checkMixedFleetOnce = async (
syncClient: SyncClient,
settings: SystemSettings,
_: TranslationFunc,
): Promise<boolean> => {
// Nothing to warn about while this device still writes the native rows: a
// peer on Readest Cloud is not a fork, it is the same channel (#5062).
if (isReadestCloudEnabled(settings)) return false;
if (useFileSyncStore.getState().fleetNoticeShown) return false;
// When this device stopped writing native rows. `disabledAt` is exact; users
// who predate the Readest Cloud switch (it was derived, never stored) fall
// back to the earliest moment they selected a third-party backend.
const selectedAts = getEnabledFileSyncBackends(settings)
.map((kind) => settings[settingsKeyForBackend(kind)]?.providerSelectedAt)
.filter((t): t is number => typeof t === 'number');
const since =
settings.readestCloud?.disabledAt ??
(selectedAts.length > 0 ? Math.min(...selectedAts) : undefined);
if (!since) return false;
try {
const result = await syncClient.pullChanges(since, 'books', undefined, undefined, 1);
if ((result.books?.length ?? 0) > 0) {
useFileSyncStore.getState().setFleetNoticeShown();
console.info(
'[cloudSync] mixed fleet detected: native book rows newer than this device provider selection',
);
eventDispatcher.dispatch('toast', {
type: 'info',
timeout: 8000,
message: _('Another device is still syncing this library via Readest Cloud'),
});
return true;
}
} catch {
// Best-effort probe: offline / logged-out / server errors are silent.
}
return false;
};

View file

@ -55,8 +55,6 @@ interface FileSyncState {
* durable "last synced" timestamp lives in the provider settings slice.
*/
lastErrorByKind: Partial<Record<FileSyncBackendKind, string | null>>;
/** Once-per-session latch for the mixed-fleet notice (see fleetDetection.ts). */
fleetNoticeShown: boolean;
/**
* Acquire the library-sync mutex for `kind` and mark it syncing. Returns
@ -78,14 +76,12 @@ interface FileSyncState {
updateProgress: (kind: FileSyncBackendKind, label: string, detail?: string | null) => void;
endSync: (kind: FileSyncBackendKind) => void;
setLastError: (kind: FileSyncBackendKind, message: string | null) => void;
setFleetNoticeShown: () => void;
}
export const useFileSyncStore = create<FileSyncState>((set, get) => ({
byKind: {},
activeKind: null,
lastErrorByKind: {},
fleetNoticeShown: false,
beginSync: (kind, initialLabel) => {
// Global mutex: only one backend's library sync at a time, since they all
@ -148,8 +144,6 @@ export const useFileSyncStore = create<FileSyncState>((set, get) => ({
set((s) => ({
lastErrorByKind: { ...s.lastErrorByKind, [kind]: message },
})),
setFleetNoticeShown: () => set({ fleetNoticeShown: true }),
}));
/** Per-backend progress, idle when the backend has never started a run. */