From 5949e590cd3582e9368ae6c73c1fd238d7868064 Mon Sep 17 00:00:00 2001 From: Brian Date: Tue, 6 Oct 2026 21:34:15 -0700 Subject: [PATCH] fix: stop syncing banner flashing on idle background polls The offline observer triggers a sync every 5s. SyncManager announced 'syncing' at the start of every run, even with nothing eligible to send, so OfflineIndicator briefly rendered its banner (violet via the amber remap) over the nav bar. Only announce 'syncing' once an operation is actually being sent, and require pending changes before showing the banner. Co-Authored-By: Claude Opus 5.5 (1M context) --- scripts/sync-auth.test.mjs | 32 +++++++++++++++++++++ src/components/offline/OfflineIndicator.tsx | 2 +- src/lib/sync.ts | 8 +++++- 3 files changed, 40 insertions(+), 2 deletions(-) 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');