fix: stop re-rendering every message row when the shared tags are re-read unchanged

This commit is contained in:
Linus Rath
2026-10-29 04:33:54 +01:00
parent 2b5257bf6b
commit ba67205052
2 changed files with 44 additions and 3 deletions
@@ -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; }));
+18 -3
View File
@@ -78,6 +78,14 @@ export function currentTagsLogin(): string {
}
const sharedInflight = new Map<string, Promise<void>>();
// 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<string, number>();
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<KeywordsState>((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<KeywordsState>((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));