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) {