diff --git a/src/stores/__tests__/keywords-store-shared.test.ts b/src/stores/__tests__/keywords-store-shared.test.ts index 74e521f4..005577ee 100644 --- a/src/stores/__tests__/keywords-store-shared.test.ts +++ b/src/stores/__tests__/keywords-store-shared.test.ts @@ -44,6 +44,32 @@ describe('loadSharedTags', () => { expect(useKeywordsStore.getState().sharedTags.team.tags).toHaveLength(1); }); + // Every list row reads its tag colours from this object: a new one on each + // re-read re-rendered the whole list. + it('keeps the same object when a re-read finds the same tags, and counts it as fresh', async () => { + vi.useFakeTimers(); + try { + metadata.mockResolvedValue(TEAM_TAGS); + await useKeywordsStore.getState().loadSharedTags('team'); + const before = useKeywordsStore.getState().sharedTags; + vi.advanceTimersByTime(3 * 60_000); + await useKeywordsStore.getState().loadSharedTags('team'); + expect(metadata).toHaveBeenCalledTimes(2); + expect(useKeywordsStore.getState().sharedTags).toBe(before); + // The unchanged answer still counts as a fresh read. + vi.advanceTimersByTime(60_000); + await useKeywordsStore.getState().loadSharedTags('team'); + expect(metadata).toHaveBeenCalledTimes(2); + + metadata.mockResolvedValue({ tags: { tags: { urgent: { label: 'Now', color: 'red' } } } }); + await useKeywordsStore.getState().loadSharedTags('team', { force: true }); + expect(useKeywordsStore.getState().sharedTags).not.toBe(before); + expect(useKeywordsStore.getState().sharedTags.team.tags[0].label).toBe('Now'); + } finally { + vi.useRealTimers(); + } + }); + it('drops an answer that arrives after the user switched to another login', async () => { let resolve!: (v: unknown) => void; metadata.mockReturnValue(new Promise((r) => { resolve = r; })); diff --git a/src/stores/keywords-store.ts b/src/stores/keywords-store.ts index 7626d8a1..edb7595a 100644 --- a/src/stores/keywords-store.ts +++ b/src/stores/keywords-store.ts @@ -78,6 +78,14 @@ export function currentTagsLogin(): string { } const sharedInflight = new Map>(); +// When each login's account last answered: an unchanged answer keeps the +// entry (and its `loadedAt`), but still counts as fresh. +const sharedReadAt = new Map(); + +function sameTags(a: KeywordDef[], b: KeywordDef[]): boolean { + return a.length === b.length + && a.every((t, i) => t.id === b[i].id && t.label === b[i].label && t.color === b[i].color); +} function persist(keywords: KeywordDef[]): void { void AsyncStorage.setItem(STORAGE_KEY, JSON.stringify(keywords)).catch((err) => { @@ -94,10 +102,11 @@ export const useKeywordsStore = create((set, get) => ({ const login = currentTagsLogin(); if (!login || !accountId || accountId === jmapClient.connectedAccountId) return Promise.resolve(); const current = get().sharedTags[accountId]; - if (!options?.force && current?.login === login && Date.now() - current.loadedAt < SHARED_TAGS_STALE_MS) { + const key = `${login}|${accountId}`; + const readAt = Math.max(current?.loadedAt ?? 0, sharedReadAt.get(key) ?? 0); + if (!options?.force && current?.login === login && Date.now() - readAt < SHARED_TAGS_STALE_MS) { return Promise.resolve(); } - const key = `${login}|${accountId}`; const running = sharedInflight.get(key); if (running) return running; const run = (async () => { @@ -106,10 +115,16 @@ export const useKeywordsStore = create((set, get) => ({ if (currentTagsLogin() !== login) return; // A failed read (or none to make) keeps the last good tags. if (!entries) return; + const tags = parseSharedTags(entries[SHARED_TAGS_KEY]); + sharedReadAt.set(key, Date.now()); + // Unchanged tags keep the entry, and with it every row's tag colours: + // a new object re-rendered each row of the list on every re-read. + const prev = get().sharedTags[accountId]; + if (prev?.login === login && sameTags(prev.tags, tags)) return; set((s) => ({ sharedTags: { ...s.sharedTags, - [accountId]: { login, tags: parseSharedTags(entries[SHARED_TAGS_KEY]), loadedAt: Date.now() }, + [accountId]: { login, tags, loadedAt: Date.now() }, }, })); })().finally(() => sharedInflight.delete(key));