From 157ea398b89271208b5e4b3a68d2e76ff0bbd73c Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Sun, 27 Sep 2026 11:21:54 +0200 Subject: [PATCH] fix: update the sync settings' collection count after the chooser listed new ones #34 The account row in the sync settings counts the synced address books or calendars from its own copy of the list, loaded when the row appears. The chooser loads a fresh list when it opens, so a calendar created meanwhile showed there but not in the count ("4 of 5 sync" stayed after a sixth appeared, and turning it on or off changed nothing) until the settings page was opened again. Every fresh list now reaches every view of that account's collections, so the count follows the chooser. --- .../settings/device-sync/use-sync-collections.ts | 9 +++++++++ .../__tests__/app/collections.test.ts | 14 ++++++++++++++ src/device-sync/app/collections.ts | 16 ++++++++++++++++ 3 files changed, 39 insertions(+) diff --git a/src/components/settings/device-sync/use-sync-collections.ts b/src/components/settings/device-sync/use-sync-collections.ts index 3f5baf6b..9da21584 100644 --- a/src/components/settings/device-sync/use-sync-collections.ts +++ b/src/components/settings/device-sync/use-sync-collections.ts @@ -7,6 +7,7 @@ import { cachedSyncCollections, CollectionsError, listSyncCollections, + onSyncCollectionsLoaded, type CollectionsErrorKind, type SyncCollection, } from '../../../device-sync/app/collections'; @@ -56,6 +57,14 @@ export function useSyncCollections(registryId: string, authority: Authority, act return () => { cancelled = true; }; }, [registryId, authority, active, attempt, feeds]); + // The chooser loads its own, fresher list: the row's counts follow it. + React.useEffect( + () => onSyncCollectionsLoaded((id, auth, collections) => { + if (id === registryId && auth === authority) setState({ kind: 'loaded', collections }); + }), + [registryId, authority], + ); + const reload = React.useCallback(() => setAttempt((n) => n + 1), []); return { state, reload }; } diff --git a/src/device-sync/__tests__/app/collections.test.ts b/src/device-sync/__tests__/app/collections.test.ts index 023268f9..ce8457bb 100644 --- a/src/device-sync/__tests__/app/collections.test.ts +++ b/src/device-sync/__tests__/app/collections.test.ts @@ -3,6 +3,7 @@ import { CollectionsError, forgetSyncCollections, listSyncCollections, + onSyncCollectionsLoaded, otherAccountsSyncing, type CollectionsClient, } from '../../app/collections'; @@ -78,6 +79,19 @@ describe('listSyncCollections', () => { expect(calls[0][1].properties).toEqual(expect.arrayContaining(['id', 'name', 'myRights'])); }); + it('tells every view of the account when a fresh list arrived', async () => { + const { client } = fakeClient({ + 'AddressBook/get c': () => ({ list: [{ id: 'b1', name: 'Personal' }] }), + 'AddressBook/get team': () => ({ list: [] }), + }); + const seen: string[][] = []; + const stop = onSyncCollectionsLoaded((id, authority, list) => seen.push([id, authority, ...list.map((c) => c.key)])); + await listSyncCollections('alice@x', CONTACTS_AUTHORITY, { client }); + stop(); + await listSyncCollections('alice@x', CONTACTS_AUTHORITY, { client, force: true }); + expect(seen).toEqual([['alice@x', CONTACTS_AUTHORITY, 'c/b1']]); + }); + it('remembers the personal JMAP account for the push routes', async () => { const { client } = fakeClient({ 'AddressBook/get': () => ({ list: [] }) }); await listSyncCollections('alice@x', CONTACTS_AUTHORITY, { client }); diff --git a/src/device-sync/app/collections.ts b/src/device-sync/app/collections.ts index 923faf4b..51aac07b 100644 --- a/src/device-sync/app/collections.ts +++ b/src/device-sync/app/collections.ts @@ -92,8 +92,23 @@ const CALENDAR_PROPERTIES = ['id', 'name', 'color', 'isDefault', 'sortOrder', 'i const cache = new Map(); const inFlight = new Map>(); +type LoadedListener = (registryId: string, authority: Authority, collections: SyncCollection[]) => void; +const loadedListeners = new Set(); + const cacheKey = (registryId: string, authority: Authority) => `${registryId}\n${authority}`; +/** + * Calls `listener` with every list loaded from the server, so each view of an + * account's collections (the settings row's counts, the chooser) shows the + * freshest one. Returns the unsubscribe. + */ +export function onSyncCollectionsLoaded(listener: LoadedListener): () => void { + loadedListeners.add(listener); + return () => { + loadedListeners.delete(listener); + }; +} + /** The last list loaded for an account, if it is still fresh. */ export function cachedSyncCollections(registryId: string, authority: Authority): SyncCollection[] | null { const hit = cache.get(cacheKey(registryId, authority)); @@ -122,6 +137,7 @@ export function listSyncCollections( } const run = load(registryId, authority, options).then((collections) => { cache.set(key, { at: Date.now(), collections }); + for (const listener of [...loadedListeners]) listener(registryId, authority, collections); return collections; }); if (!options.client) {