diff --git a/scripts/sync-auth.test.mjs b/scripts/sync-auth.test.mjs index bca2342..cd1a3a2 100644 --- a/scripts/sync-auth.test.mjs +++ b/scripts/sync-auth.test.mjs @@ -12,6 +12,38 @@ async function seed() { await store.queueMutation(f.session.accountId, { type: 'uncheckItem', payload: { itemId: 'I1' } }, f.rows.items); return f; } +test('idle polling and acknowledged work never announce syncing', async () => { + const f = await replayFixture(modules), manager = new SyncManager(), statuses = []; + manager.subscribe(s => statuses.push(s.status)); + await manager.sync(f.client, f.session); + await manager.sync(f.client, f.session); + assert.deepEqual(statuses, ['synced', 'synced']); + + await store.queueMutation(f.session.accountId, { type: 'checkItem', payload: { itemId: 'I1', checkedAt: 1 } }, f.rows.items); + statuses.length = 0; + await manager.sync(f.client, f.session); + assert.deepEqual(statuses, ['syncing', 'synced']); + statuses.length = 0; + await manager.sync(f.client, f.session); + assert.deepEqual(statuses, ['synced']); +}); + +for (const patch of [ + { state: 'conflict' }, + { state: 'failed', retryCount: 5 }, + { state: 'failed', nextAttemptAt: Date.now() + 60_000 }, +]) { + test(`polling ineligible work stays out of syncing: ${JSON.stringify(patch)}`, async () => { + const f = await seed(), manager = new SyncManager(), statuses = []; + for (const operation of await store.getQueuedMutations(f.session.accountId)) { + await store.saveOperation(f.session.accountId, { ...operation, ...patch }); + } + manager.subscribe(s => statuses.push(s.status)); + await manager.sync({ mutation: () => { assert.fail('Ineligible edits must not be sent'); } }, f.session); + assert.deepEqual(statuses, ['error']); + assert.equal((await store.getQueuedMutations(f.session.accountId)).length, 2); + }); +} for (const code of ['UNAUTHORIZED', 'INVALID_TOKEN', 'EXPIRED_TOKEN']) { test(`${code} across RPC preserves queue/retry budget and resumes with the same account`, async () => { const f = await seed(); const manager = new SyncManager(), statuses = []; diff --git a/src/components/offline/OfflineIndicator.tsx b/src/components/offline/OfflineIndicator.tsx index 5ed45e9..4e6ca46 100644 --- a/src/components/offline/OfflineIndicator.tsx +++ b/src/components/offline/OfflineIndicator.tsx @@ -28,7 +28,7 @@ export function OfflineIndicator() { }, [isOnline]); // Show syncing state after reconnection - if (isOnline && syncStatus.status === 'syncing') { + if (isOnline && pendingCount > 0 && syncStatus.status === 'syncing') { return (
diff --git a/src/lib/sync.ts b/src/lib/sync.ts index 2738fb5..e4032b4 100644 --- a/src/lib/sync.ts +++ b/src/lib/sync.ts @@ -28,7 +28,7 @@ export class SyncManager { } this.running.add(accountId); const notify = (status: SyncStatus) => { if (isCurrent()) this.listeners.forEach(fn => fn({ ...status, accountId })); }; - notify({ status: 'syncing' }); + let announcedSyncing = false; try { const attempted = new Set(); while (isCurrent()) { @@ -43,6 +43,12 @@ export class SyncManager { if (!isCurrent()) return; if (!current) continue; attempted.add(m.operationId); + // Background polling also runs with no eligible work. Only surface + // syncing once an edit is actually ready to send. + if (!announcedSyncing) { + notify({ status: 'syncing' }); + announcedSyncing = true; + } try { const ack = await this.executeMutation(convex, current, token, acknowledged); if (ack.operationId !== m.operationId) throw new Error('Missing operation acknowledgment');