@remit/web-client 0.0.226 → 0.0.228

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@remit/web-client",
3
- "version": "0.0.226",
3
+ "version": "0.0.228",
4
4
  "type": "module",
5
5
  "description": "Remit web client, published as composable primitives — the app shell, auth shells, and runtime config. A distributor imports what it composes and bundles it.",
6
6
  "exports": {
@@ -8,6 +8,7 @@
8
8
  */
9
9
  import type { RemitImapAccountResponse } from "@remit/api-http-client/types.gen.ts";
10
10
  import { RefreshButton, ShellTopBar, shortcutHintForAction } from "@remit/ui";
11
+ import { useQueryClient } from "@tanstack/react-query";
11
12
  import { useMemo } from "react";
12
13
  import { AccountMenu } from "@/auth/AccountMenu";
13
14
  import { useRefreshControl } from "@/hooks/useRefreshControl";
@@ -15,6 +16,10 @@ import { useSearchScope } from "@/hooks/useSearchScope";
15
16
  import { openBugReport } from "@/lib/bug-report";
16
17
  import { useMailContext } from "@/lib/mail-context";
17
18
  import { useMailFreshness } from "@/lib/mail-freshness";
19
+ import {
20
+ invalidateThreadListQueries,
21
+ threadListCacheKeys,
22
+ } from "@/lib/thread-list-cache";
18
23
  import { useGoToSection, useOpenCompose } from "@/routing";
19
24
 
20
25
  interface MailTopBarProps {
@@ -22,8 +27,14 @@ interface MailTopBarProps {
22
27
  }
23
28
 
24
29
  export function MailTopBar({ accounts }: MailTopBarProps) {
25
- const { searchInput, onSearchChange, onSearchClear, onSearchClearQuery } =
26
- useMailContext();
30
+ const {
31
+ searchInput,
32
+ onSearchChange,
33
+ onSearchClear,
34
+ onSearchClearQuery,
35
+ resultFolderIndex,
36
+ } = useMailContext();
37
+ const queryClient = useQueryClient();
27
38
  const goToSection = useGoToSection();
28
39
  const compose = useOpenCompose();
29
40
  const { scope, clearScope } = useSearchScope(accounts);
@@ -43,7 +54,17 @@ export function MailTopBar({ accounts }: MailTopBarProps) {
43
54
  state: refreshState,
44
55
  errorMessage: refreshError,
45
56
  refresh,
46
- } = useRefreshControl(allAccountIds);
57
+ } = useRefreshControl(allAccountIds, {
58
+ // Without this the bar's refresh confirmed against a thread list it
59
+ // never reloaded: the sync landed, the button ticked, and the mail it
60
+ // fetched stayed off screen until something else invalidated the list.
61
+ // Every open account's folders, so whichever list is on screen reloads.
62
+ onSettled: () =>
63
+ invalidateThreadListQueries(
64
+ queryClient,
65
+ threadListCacheKeys(resultFolderIndex.keys()),
66
+ ),
67
+ });
47
68
 
48
69
  return (
49
70
  <ShellTopBar
@@ -95,6 +95,35 @@ export const WithActionLink: Story = {
95
95
  },
96
96
  };
97
97
 
98
+ /**
99
+ * A delete refused because the message is still being moved on the mail server
100
+ * (#845). Transient: the same press works once the move confirms, so the copy
101
+ * says so rather than sending the user off to report a bug.
102
+ */
103
+ export const PlacementInFlight: Story = {
104
+ args: {
105
+ severity: "warning",
106
+ title: "Couldn't delete this message yet",
107
+ detail:
108
+ "It is still being moved on the mail server. Try again in a moment.",
109
+ action: { label: "Report an issue", href: REPORT_URL },
110
+ },
111
+ };
112
+
113
+ /**
114
+ * The same refusal after the move gave up without confirming. Waiting will
115
+ * never clear this one, so the remedy is a resync instead.
116
+ */
117
+ export const PlacementUnverified: Story = {
118
+ args: {
119
+ severity: "warning",
120
+ title: "Couldn't delete 4 messages yet",
121
+ detail:
122
+ "An earlier move never finished, so where this message sits is unknown. Sync the folder, then delete it again.",
123
+ action: { label: "Report an issue", href: REPORT_URL },
124
+ },
125
+ };
126
+
98
127
  /** The action link on the dark theme. */
99
128
  export const WithActionLinkDark: Story = {
100
129
  name: "With Action Link (dark)",
@@ -0,0 +1,72 @@
1
+ import assert from "node:assert/strict";
2
+ import { describe, it } from "node:test";
3
+ import {
4
+ isPlacementRefusal,
5
+ placementRefusalBanner,
6
+ } from "./placement-refusal.js";
7
+
8
+ const refusal = (reason: string) => ({
9
+ message: "Message 4f1 was not acted on: its folder and uid do not name...",
10
+ code: "message_placement_unsettled",
11
+ details: { accountId: "acc-1", messageId: "msg-1", reason },
12
+ });
13
+
14
+ describe("isPlacementRefusal", () => {
15
+ it("reads the reason and the message off the coded body", () => {
16
+ assert.deepEqual(isPlacementRefusal(refusal("in_flight")), {
17
+ reason: "in_flight",
18
+ messageId: "msg-1",
19
+ });
20
+ });
21
+
22
+ it("ignores every other failure", () => {
23
+ assert.equal(isPlacementRefusal(undefined), undefined);
24
+ assert.equal(isPlacementRefusal(new Error("network")), undefined);
25
+ assert.equal(
26
+ isPlacementRefusal({ ...refusal("in_flight"), code: "other" }),
27
+ undefined,
28
+ );
29
+ assert.equal(isPlacementRefusal(refusal("something_else")), undefined);
30
+ });
31
+
32
+ it("does not match on the message text alone", () => {
33
+ assert.equal(
34
+ isPlacementRefusal(new Error("an earlier move has not settled")),
35
+ undefined,
36
+ );
37
+ });
38
+ });
39
+
40
+ describe("placementRefusalBanner", () => {
41
+ it("tells an in-flight move to wait", () => {
42
+ const banner = placementRefusalBanner(
43
+ { reason: "in_flight", messageId: "msg-1" },
44
+ 1,
45
+ );
46
+ assert.equal(banner.severity, "warning");
47
+ assert.match(banner.detail ?? "", /try again in a moment/i);
48
+ });
49
+
50
+ it("tells an abandoned move to resync, not to wait", () => {
51
+ const banner = placementRefusalBanner(
52
+ { reason: "unverified", messageId: "msg-1" },
53
+ 1,
54
+ );
55
+ assert.match(banner.detail ?? "", /sync the folder/i);
56
+ assert.doesNotMatch(banner.detail ?? "", /try again in a moment/i);
57
+ });
58
+
59
+ it("counts the selection in the title", () => {
60
+ assert.match(
61
+ placementRefusalBanner({ reason: "in_flight", messageId: "m" }, 4).title,
62
+ /4 messages/,
63
+ );
64
+ });
65
+
66
+ it("never repeats the server's uuid at the user", () => {
67
+ for (const reason of ["in_flight", "unverified"] as const) {
68
+ const banner = placementRefusalBanner({ reason, messageId: "msg-1" }, 1);
69
+ assert.doesNotMatch(`${banner.title} ${banner.detail}`, /msg-1/);
70
+ }
71
+ });
72
+ });
@@ -0,0 +1,66 @@
1
+ /**
2
+ * The coded 409 a delete is refused with when the message's folder and uid do
3
+ * not name the same message (#845). Read the `code`, never the message: the
4
+ * server's sentence names a uuid and no remedy, and a message-string match
5
+ * would start firing on an unrelated conflict the moment the copy changes.
6
+ */
7
+ import type { ApiError } from "@remit/api-http-client/types.gen.ts";
8
+ import type { PushErrorInput } from "@/components/ui/error-banners";
9
+
10
+ /**
11
+ * `in_flight` clears on its own once the mail server confirms the move;
12
+ * `unverified` does not, because the move gave up without confirming and
13
+ * nothing routine repairs the row.
14
+ */
15
+ export type PlacementRefusalReason = "in_flight" | "unverified";
16
+
17
+ export interface PlacementRefusal {
18
+ reason: PlacementRefusalReason;
19
+ messageId: string;
20
+ }
21
+
22
+ const REASONS: ReadonlySet<string> = new Set<PlacementRefusalReason>([
23
+ "in_flight",
24
+ "unverified",
25
+ ]);
26
+
27
+ const stringAt = (
28
+ details: ApiError["details"],
29
+ key: string,
30
+ ): string | undefined => {
31
+ const value = details?.[key];
32
+ return typeof value === "string" ? value : undefined;
33
+ };
34
+
35
+ export const isPlacementRefusal = (
36
+ error: unknown,
37
+ ): PlacementRefusal | undefined => {
38
+ if (typeof error !== "object" || error === null) return undefined;
39
+ const body = error as Partial<ApiError>;
40
+ if (body.code !== "message_placement_unsettled") return undefined;
41
+ const { details } = body;
42
+ if (typeof details !== "object" || details === null) return undefined;
43
+ const reason = stringAt(details, "reason");
44
+ const messageId = stringAt(details, "messageId");
45
+ if (!reason || !REASONS.has(reason) || !messageId) return undefined;
46
+ return { reason: reason as PlacementRefusalReason, messageId };
47
+ };
48
+
49
+ /**
50
+ * The banner copy. Both reasons say what happened and what to do; neither is a
51
+ * dead end, and neither repeats the server's uuid at the user.
52
+ */
53
+ export const placementRefusalBanner = (
54
+ refusal: PlacementRefusal,
55
+ count: number,
56
+ ): PushErrorInput => ({
57
+ severity: "warning",
58
+ title:
59
+ count > 1
60
+ ? `Couldn't delete ${count} messages yet`
61
+ : "Couldn't delete this message yet",
62
+ detail:
63
+ refusal.reason === "in_flight"
64
+ ? "It is still being moved on the mail server. Try again in a moment."
65
+ : "An earlier move never finished, so where this message sits is unknown. Sync the folder, then delete it again.",
66
+ });
@@ -10,6 +10,10 @@ import { useRoleAppointmentPrompt } from "@/components/mail/RoleAppointmentPromp
10
10
  import { useErrorBanners } from "@/components/ui/ErrorBannerProvider";
11
11
  import { formatErrorDetail } from "@/components/ui/error-banners";
12
12
  import { isFolderRoleRefusal } from "@/components/ui/folder-role-refusal";
13
+ import {
14
+ isPlacementRefusal,
15
+ placementRefusalBanner,
16
+ } from "@/components/ui/placement-refusal";
13
17
  import { resolveMailboxesForMessages } from "@/hooks/useMarkAsRead";
14
18
  import { runChunkedMutation } from "@/lib/bulk-actions";
15
19
  import {
@@ -175,6 +179,11 @@ export const useDeleteMessages = ({
175
179
  return;
176
180
  }
177
181
  const count = vars.body.messageIds?.length ?? 0;
182
+ const placement = isPlacementRefusal(err);
183
+ if (placement) {
184
+ pushError(placementRefusalBanner(placement, count));
185
+ return;
186
+ }
178
187
  pushError({
179
188
  title:
180
189
  count > 1
@@ -1,6 +1,10 @@
1
1
  import assert from "node:assert/strict";
2
2
  import { describe, test } from "node:test";
3
- import { type SyncStatusReading, waitForSettled } from "./useRefreshControl.js";
3
+ import {
4
+ inboxSyncedAt,
5
+ type SyncStatusReading,
6
+ waitForSettled,
7
+ } from "./useRefreshControl.js";
4
8
 
5
9
  const BASELINE = 1_700_000_000_000;
6
10
  const LATER = BASELINE + 5_000;
@@ -127,3 +131,157 @@ describe("waitForSettled", () => {
127
131
  assert.deepEqual(outcome, { accountId: "a-1", message: "signed out" });
128
132
  });
129
133
  });
134
+
135
+ const inbox = (lastSyncedAt: number) => ({
136
+ fullPath: "INBOX",
137
+ lastSyncedAt,
138
+ });
139
+ const junk = (lastSyncedAt: number) => ({
140
+ fullPath: "Junk",
141
+ lastSyncedAt,
142
+ });
143
+
144
+ describe("inboxSyncedAt", () => {
145
+ test("reads the INBOX stamp whatever case the server spells it in", () => {
146
+ assert.equal(
147
+ inboxSyncedAt({
148
+ mailboxes: [junk(LATER), { fullPath: "Inbox", lastSyncedAt: BASELINE }],
149
+ }),
150
+ BASELINE,
151
+ );
152
+ });
153
+
154
+ test("is zero for an account with no INBOX or no stamp yet", () => {
155
+ assert.equal(inboxSyncedAt({ mailboxes: [junk(LATER)] }), 0);
156
+ assert.equal(inboxSyncedAt({ mailboxes: [{ fullPath: "INBOX" }] }), 0);
157
+ assert.equal(inboxSyncedAt({}), 0);
158
+ });
159
+ });
160
+
161
+ describe("waitForSettled inbox handoff", () => {
162
+ // A round is every folder in queue order, but the mail somebody pressed
163
+ // refresh for is in their inbox. Waiting for the whole round meant the new
164
+ // mail sat unshown while Junk and Trash went by.
165
+ test("announces the inbox while the round is still running", async () => {
166
+ const announced: number[] = [];
167
+ const { state, readStatus } = scriptedReader([
168
+ {
169
+ syncPhase: "syncing_inbox",
170
+ lastSyncAt: LATER,
171
+ mailboxes: [inbox(BASELINE)],
172
+ },
173
+ {
174
+ syncPhase: "syncing_others",
175
+ lastSyncAt: LATER,
176
+ mailboxes: [inbox(LATER)],
177
+ },
178
+ {
179
+ syncPhase: "syncing_others",
180
+ lastSyncAt: LATER,
181
+ mailboxes: [inbox(LATER)],
182
+ },
183
+ {
184
+ syncPhase: "complete",
185
+ lastSyncAt: LATER,
186
+ mailboxes: [inbox(LATER)],
187
+ },
188
+ ]);
189
+
190
+ const outcome = await waitForSettled({
191
+ accountId: "a-1",
192
+ readStatus,
193
+ baselineLastSyncAt: BASELINE,
194
+ baselineInboxSyncedAt: BASELINE,
195
+ onInboxSynced: () => announced.push(state.reads),
196
+ deadline: Date.now() + 10_000,
197
+ pollMs: 0,
198
+ });
199
+
200
+ assert.equal(outcome, undefined);
201
+ // Second reading, two readings before the round confirmed.
202
+ assert.deepEqual(announced, [2]);
203
+ assert.equal(state.reads, 4);
204
+ });
205
+
206
+ test("announces the inbox once, not on every poll", async () => {
207
+ let announced = 0;
208
+ const { readStatus } = scriptedReader([
209
+ {
210
+ syncPhase: "syncing_others",
211
+ lastSyncAt: LATER,
212
+ mailboxes: [inbox(LATER)],
213
+ },
214
+ {
215
+ syncPhase: "syncing_others",
216
+ lastSyncAt: LATER,
217
+ mailboxes: [inbox(LATER)],
218
+ },
219
+ {
220
+ syncPhase: "complete",
221
+ lastSyncAt: LATER,
222
+ mailboxes: [inbox(LATER)],
223
+ },
224
+ ]);
225
+
226
+ await waitForSettled({
227
+ accountId: "a-1",
228
+ readStatus,
229
+ baselineLastSyncAt: BASELINE,
230
+ baselineInboxSyncedAt: BASELINE,
231
+ onInboxSynced: () => {
232
+ announced += 1;
233
+ },
234
+ deadline: Date.now() + 10_000,
235
+ pollMs: 0,
236
+ });
237
+
238
+ assert.equal(announced, 1);
239
+ });
240
+
241
+ // The caller already treats a settled round as the confirmed state and
242
+ // reloads there, so announcing on the same reading would double the work.
243
+ test("does not announce when the round settles first", async () => {
244
+ let announced = 0;
245
+
246
+ await waitForSettled({
247
+ accountId: "a-1",
248
+ readStatus: async () => ({
249
+ syncPhase: "complete" as const,
250
+ lastSyncAt: LATER,
251
+ mailboxes: [inbox(LATER)],
252
+ }),
253
+ baselineLastSyncAt: BASELINE,
254
+ baselineInboxSyncedAt: BASELINE,
255
+ onInboxSynced: () => {
256
+ announced += 1;
257
+ },
258
+ deadline: Date.now() + 10_000,
259
+ pollMs: 0,
260
+ });
261
+
262
+ assert.equal(announced, 0);
263
+ });
264
+
265
+ // A folder other than INBOX syncing is not the mail being waited for.
266
+ test("does not announce on another folder's stamp moving", async () => {
267
+ let announced = 0;
268
+
269
+ await waitForSettled({
270
+ accountId: "a-1",
271
+ readStatus: async () => ({
272
+ syncPhase: "syncing_others" as const,
273
+ lastSyncAt: LATER,
274
+ mailboxes: [inbox(BASELINE), junk(LATER)],
275
+ }),
276
+ baselineLastSyncAt: BASELINE,
277
+ baselineInboxSyncedAt: BASELINE,
278
+ onInboxSynced: () => {
279
+ announced += 1;
280
+ },
281
+ deadline: Date.now() - 1,
282
+ pollMs: 0,
283
+ });
284
+
285
+ assert.equal(announced, 0);
286
+ });
287
+ });
@@ -10,6 +10,7 @@ import { useCallback, useEffect, useRef, useState } from "react";
10
10
  import { isSyncingPhase } from "@/hooks/useInitialSyncProgress";
11
11
  import { shouldEscalate } from "@/lib/error-classifier";
12
12
  import { reportFatalError } from "@/lib/fatal-error";
13
+ import { startHotSyncWindow } from "@/lib/hot-sync-window";
13
14
  import { useMailFreshness } from "@/lib/mail-freshness";
14
15
  import { useTelemetry } from "@/lib/telemetry-context";
15
16
 
@@ -68,18 +69,35 @@ export interface AccountOutcome {
68
69
  message?: string;
69
70
  }
70
71
 
71
- /** The two fields of `getSyncStatus` that identify a round, named so the wait
72
- * below can be driven from a scripted sequence of readings. */
72
+ /** The fields of `getSyncStatus` this wait reads, named so it can be driven
73
+ * from a scripted sequence of readings. */
73
74
  export interface SyncStatusReading {
74
75
  syncPhase?: RemitImapSyncPhase;
75
76
  lastSyncAt?: number;
77
+ mailboxes?: readonly { fullPath?: string; lastSyncedAt?: number }[];
76
78
  }
77
79
 
80
+ /**
81
+ * The account's INBOX stamp, or 0 when the account has no INBOX or has never
82
+ * synced one. `getSyncStatus` reports `lastSyncedAt` per mailbox, which is what
83
+ * lets the wait answer "the inbox is current" ahead of "the round is done".
84
+ */
85
+ export const inboxSyncedAt = (reading: SyncStatusReading): number =>
86
+ reading.mailboxes?.find(
87
+ (mailbox) => mailbox.fullPath?.toUpperCase() === "INBOX",
88
+ )?.lastSyncedAt ?? 0;
89
+
78
90
  export interface WaitForSettledOptions {
79
91
  accountId: string;
80
92
  readStatus: () => Promise<SyncStatusReading>;
81
93
  /** `lastSyncAt` as it read immediately before this refresh triggered. */
82
94
  baselineLastSyncAt: number;
95
+ /** The INBOX `lastSyncedAt` from that same pre-trigger reading. */
96
+ baselineInboxSyncedAt?: number;
97
+ /** Called at most once, the moment INBOX's own stamp passes the baseline
98
+ * while the round is still running — never after the round has settled,
99
+ * which the caller already treats as the confirmed state. */
100
+ onInboxSynced?: () => void;
83
101
  deadline: number;
84
102
  pollMs?: number;
85
103
  }
@@ -101,6 +119,12 @@ export interface WaitForSettledOptions {
101
119
  * still shows the previous round — including a previous round's `error` phase,
102
120
  * which is why that phase only speaks once the stamp has moved.
103
121
  *
122
+ * A round is the whole account — every folder, in queue order — but the mail
123
+ * a person pressed refresh for is in their inbox. `onInboxSynced` fires the
124
+ * moment that one mailbox's own stamp passes the baseline, so the list on
125
+ * screen reloads then rather than after Junk and Trash have also gone by. The
126
+ * round-level stamp still decides the button's confirmed state.
127
+ *
104
128
  * Read-only (`getSyncStatus`, no IMAP call, no queue write), so waiting costs
105
129
  * a handful of cheap GETs — never a refetch of the account's own message
106
130
  * list.
@@ -109,9 +133,12 @@ export const waitForSettled = async ({
109
133
  accountId,
110
134
  readStatus,
111
135
  baselineLastSyncAt,
136
+ baselineInboxSyncedAt = 0,
137
+ onInboxSynced,
112
138
  deadline,
113
139
  pollMs = REFRESH_POLL_MS,
114
140
  }: WaitForSettledOptions): Promise<AccountOutcome | undefined> => {
141
+ let inboxAnnounced = false;
115
142
  for (;;) {
116
143
  const outcome = await readStatus()
117
144
  .then((data) => ({ ok: true as const, data }))
@@ -127,6 +154,13 @@ export const waitForSettled = async ({
127
154
  }
128
155
  return undefined;
129
156
  }
157
+ if (
158
+ !inboxAnnounced &&
159
+ inboxSyncedAt(outcome.data) > baselineInboxSyncedAt
160
+ ) {
161
+ inboxAnnounced = true;
162
+ onInboxSynced?.();
163
+ }
130
164
  if (Date.now() >= deadline) {
131
165
  return { accountId, message: "Refresh is taking longer than usual" };
132
166
  }
@@ -149,11 +183,13 @@ const refreshOneAccount = async (
149
183
  telemetry: { recordEvent: (name: string) => void },
150
184
  accountId: string,
151
185
  deadline: number,
186
+ onInboxSynced: (accountId: string) => void,
152
187
  ): Promise<AccountResult> => {
153
188
  const baseline = await fetchStatus(queryClient, accountId)
154
189
  .then((data) => ({
155
190
  ok: true as const,
156
191
  lastSyncAt: data.lastSyncAt ?? 0,
192
+ inboxSyncedAt: inboxSyncedAt(data),
157
193
  }))
158
194
  .catch((error: unknown) => ({ ok: false as const, error }));
159
195
  if (!baseline.ok) {
@@ -186,6 +222,8 @@ const refreshOneAccount = async (
186
222
  accountId,
187
223
  readStatus: () => fetchStatus(queryClient, accountId),
188
224
  baselineLastSyncAt: baseline.lastSyncAt,
225
+ baselineInboxSyncedAt: baseline.inboxSyncedAt,
226
+ onInboxSynced: () => onInboxSynced(accountId),
189
227
  deadline,
190
228
  });
191
229
  if (settled)
@@ -194,11 +232,14 @@ const refreshOneAccount = async (
194
232
  };
195
233
 
196
234
  export interface UseRefreshControlOptions {
197
- /** Called once every enqueued account's sync round has settled, alongside
198
- * the unconditional invalidation of each enqueued account's own
199
- * mailbox-list query — the caller's chance to invalidate whatever
200
- * view-specific query (a mailbox's thread list, the brief's unified list)
201
- * the sync may have changed. */
235
+ /** The caller's chance to invalidate whatever view-specific query (a
236
+ * mailbox's thread list, the brief's unified list) the sync may have
237
+ * changed, alongside the invalidation of the account's own mailbox-list
238
+ * query.
239
+ *
240
+ * Called more than once per press: once per account the moment its INBOX
241
+ * is current, and again once its whole round has settled. It must
242
+ * therefore be idempotent — an invalidation is. */
202
243
  onSettled?: () => void;
203
244
  }
204
245
 
@@ -253,11 +294,33 @@ export const useRefreshControl = (
253
294
  setState("refreshing");
254
295
  setErrorMessage(undefined);
255
296
 
297
+ startHotSyncWindow();
298
+
299
+ // The account's own lists, reloaded. Called once as soon as that
300
+ // account's INBOX is current and again when its round confirms: the
301
+ // first is what puts the new mail on screen without waiting for Junk
302
+ // and Trash, the second is the settled state.
303
+ const showAccount = (accountId: string): void => {
304
+ if (runIdRef.current !== runId) return;
305
+ queryClient.invalidateQueries({
306
+ queryKey: mailboxOperationsListMailboxesQueryKey({
307
+ path: { accountId },
308
+ }),
309
+ });
310
+ optionsRef.current.onSettled?.();
311
+ };
312
+
256
313
  void (async () => {
257
314
  const deadline = Date.now() + REFRESH_TIMEOUT_MS;
258
315
  const results = await Promise.all(
259
316
  ids.map((accountId) =>
260
- refreshOneAccount(queryClient, telemetry, accountId, deadline),
317
+ refreshOneAccount(
318
+ queryClient,
319
+ telemetry,
320
+ accountId,
321
+ deadline,
322
+ showAccount,
323
+ ),
261
324
  ),
262
325
  );
263
326
  if (runIdRef.current !== runId) return;
@@ -265,15 +328,8 @@ export const useRefreshControl = (
265
328
  const enqueued = results
266
329
  .filter((result) => result.enqueued)
267
330
  .map((result) => result.accountId);
268
- for (const accountId of enqueued) {
269
- queryClient.invalidateQueries({
270
- queryKey: mailboxOperationsListMailboxesQueryKey({
271
- path: { accountId },
272
- }),
273
- });
274
- }
331
+ for (const accountId of enqueued) showAccount(accountId);
275
332
  if (enqueued.length > 0) {
276
- optionsRef.current.onSettled?.();
277
333
  acknowledge(enqueued);
278
334
  }
279
335
 
@@ -2,10 +2,17 @@ import assert from "node:assert/strict";
2
2
  import { afterEach, describe, test } from "node:test";
3
3
  import { ApiError } from "../lib/api.js";
4
4
  import { __resetFatalError, subscribeFatalError } from "../lib/fatal-error.js";
5
+ import {
6
+ __resetHotSyncWindow,
7
+ HOT_POLL_INTERVAL_MS,
8
+ HOT_WINDOW_MS,
9
+ startHotSyncWindow,
10
+ } from "../lib/hot-sync-window.js";
5
11
  import { NetworkError } from "../lib/network-error.js";
6
12
  import {
7
13
  __peekStaleAccountSyncGuard,
8
14
  __resetStaleAccountSyncGuard,
15
+ currentPollIntervalMs,
9
16
  handleBackgroundSyncFailure,
10
17
  hasPollIntervalElapsed,
11
18
  MIN_POLL_INTERVAL_MS,
@@ -102,8 +109,9 @@ describe("selectStaleAccountIds", () => {
102
109
  });
103
110
 
104
111
  describe("POLL_INTERVAL_MS", () => {
105
- test("defaults to 5 minutes when mailboxPollIntervalSeconds is unset in this test run", () => {
106
- assert.equal(POLL_INTERVAL_MS, 5 * 60 * 1000);
112
+ // How stale an open tab's mail may be with nobody touching anything.
113
+ test("defaults to 30 seconds when mailboxPollIntervalSeconds is unset in this test run", () => {
114
+ assert.equal(POLL_INTERVAL_MS, 30 * 1000);
107
115
  });
108
116
  });
109
117
 
@@ -111,19 +119,13 @@ describe("resolvePollIntervalMs", () => {
111
119
  // This poll shares POST /sync with the refresh control, and that endpoint's
112
120
  // triggers skip the server's per-mailbox freshness gate
113
121
  // (MAILBOX_FRESHNESS_MS in imap-worker's sync-mailboxes fan-out). A timer is
114
- // not a person: without this floor, configuring a sub-window interval turns
115
- // every tick into a full folder-by-folder re-enumeration for every open
116
- // account, which is exactly the fan-out storm the gate prevents. The floor
117
- // is what stops a config value from reintroducing it.
118
- test("never returns less than the server's freshness window", () => {
122
+ // not a person: every tick is a full folder-by-folder re-enumeration for
123
+ // every open account, so the floor is what bounds that work against a config
124
+ // value.
125
+ test("never returns less than the floor", () => {
119
126
  assert.equal(resolvePollIntervalMs("10"), MIN_POLL_INTERVAL_MS);
120
- assert.equal(resolvePollIntervalMs("59"), MIN_POLL_INTERVAL_MS);
121
- });
122
-
123
- test("matches the freshness window the fan-out gate uses", () => {
124
- // Keep in step with MAILBOX_FRESHNESS_MS in
125
- // packages/imap-worker/src/handlers/sync-mailboxes.ts.
126
- assert.equal(MIN_POLL_INTERVAL_MS, 60_000);
127
+ assert.equal(resolvePollIntervalMs("29"), MIN_POLL_INTERVAL_MS);
128
+ assert.equal(MIN_POLL_INTERVAL_MS, 30_000);
127
129
  });
128
130
 
129
131
  test("honours a configured interval above the floor", () => {
@@ -131,8 +133,45 @@ describe("resolvePollIntervalMs", () => {
131
133
  });
132
134
 
133
135
  test("falls back to the default when unset or unparseable", () => {
134
- assert.equal(resolvePollIntervalMs(undefined), 5 * 60 * 1000);
135
- assert.equal(resolvePollIntervalMs("not-a-number"), 5 * 60 * 1000);
136
+ assert.equal(resolvePollIntervalMs(undefined), 30 * 1000);
137
+ assert.equal(resolvePollIntervalMs("not-a-number"), 30 * 1000);
138
+ });
139
+ });
140
+
141
+ describe("currentPollIntervalMs", () => {
142
+ afterEach(__resetHotSyncWindow);
143
+
144
+ test("is the configured ambient interval with no recent press", () => {
145
+ assert.equal(currentPollIntervalMs(NOW, 900_000), 900_000);
146
+ });
147
+
148
+ // Pressing refresh means "and keep looking": the next few minutes run on the
149
+ // hot cadence even where the deployment has lengthened the ambient one.
150
+ test("drops to the hot cadence for the whole window after a press", () => {
151
+ startHotSyncWindow(NOW);
152
+
153
+ assert.equal(currentPollIntervalMs(NOW, 900_000), HOT_POLL_INTERVAL_MS);
154
+ assert.equal(
155
+ currentPollIntervalMs(NOW + HOT_WINDOW_MS - 1, 900_000),
156
+ HOT_POLL_INTERVAL_MS,
157
+ );
158
+ });
159
+
160
+ test("falls back to ambient once the window expires", () => {
161
+ startHotSyncWindow(NOW);
162
+
163
+ assert.equal(currentPollIntervalMs(NOW + HOT_WINDOW_MS, 900_000), 900_000);
164
+ assert.equal(
165
+ currentPollIntervalMs(NOW + HOT_WINDOW_MS + 60_000, 900_000),
166
+ 900_000,
167
+ );
168
+ });
169
+
170
+ // A deployment already polling faster than the hot cadence keeps its own.
171
+ test("never lengthens an ambient interval shorter than the hot one", () => {
172
+ startHotSyncWindow(NOW);
173
+
174
+ assert.equal(currentPollIntervalMs(NOW, 10_000), 10_000);
136
175
  });
137
176
  });
138
177
 
@@ -6,6 +6,11 @@ import pMap from "p-map";
6
6
  import { useEffect, useRef } from "react";
7
7
  import { shouldEscalate } from "@/lib/error-classifier";
8
8
  import { reportFatalError } from "@/lib/fatal-error";
9
+ import {
10
+ HOT_POLL_INTERVAL_MS,
11
+ isHotSyncWindowActive,
12
+ subscribeHotSyncWindow,
13
+ } from "@/lib/hot-sync-window";
9
14
  import { getRuntimeConfig } from "@/runtime-config";
10
15
 
11
16
  /**
@@ -14,7 +19,7 @@ import { getRuntimeConfig } from "@/runtime-config";
14
19
  */
15
20
  export const STALENESS_THRESHOLD_MS = 15 * 60 * 1000;
16
21
 
17
- const DEFAULT_POLL_INTERVAL_SECONDS = 5 * 60;
22
+ const DEFAULT_POLL_INTERVAL_SECONDS = 30;
18
23
 
19
24
  const parsePositiveIntSeconds = (
20
25
  raw: string | undefined,
@@ -26,19 +31,18 @@ const parsePositiveIntSeconds = (
26
31
  };
27
32
 
28
33
  /**
29
- * Floor for the poll interval, matching `MAILBOX_FRESHNESS_MS` in the
30
- * imap-worker's sync-mailboxes fan-out.
34
+ * Floor for the poll interval.
31
35
  *
32
36
  * This poll uses POST /sync, the same endpoint as a person pressing refresh,
33
- * and that endpoint's triggers skip the server's per-mailbox freshness gate.
34
- * A timer is not a person: polling faster than the gate's own window would
35
- * make every tick a full folder-by-folder re-enumeration for every open
36
- * account — the fan-out storm the gate exists to prevent, reintroduced by a
37
- * config value. `mailboxPollIntervalSeconds` can lengthen the interval, never
38
- * shorten it past this: below the window there is nothing to gain, since no
39
- * mailbox can have become stale in the meantime.
37
+ * and that endpoint's triggers skip the server's per-mailbox freshness gate
38
+ * (`MAILBOX_FRESHNESS_MS`, 60s). A timer is not a person: every tick is a full
39
+ * folder-by-folder re-enumeration for every open account, so the interval is
40
+ * what bounds that work. Thirty seconds is the answer to how stale an open
41
+ * tab's mail may be, and buys it at twice the fan-out the gate's own window
42
+ * would allow. `mailboxPollIntervalSeconds` can lengthen the interval, never
43
+ * shorten it past this.
40
44
  */
41
- export const MIN_POLL_INTERVAL_MS = 60 * 1000;
45
+ export const MIN_POLL_INTERVAL_MS = 30 * 1000;
42
46
 
43
47
  /**
44
48
  * Resolve the configured poll interval, never returning less than
@@ -62,6 +66,21 @@ export const POLL_INTERVAL_MS = resolvePollIntervalMs(
62
66
  getRuntimeConfig().mailboxPollIntervalSeconds,
63
67
  );
64
68
 
69
+ /**
70
+ * The interval this tab is currently due on: the hot one while a refresh press
71
+ * is still recent, the configured ambient one otherwise. A deployment that has
72
+ * lengthened `mailboxPollIntervalSeconds` still gets the hot cadence for the
73
+ * few minutes after a press, which is what makes a press mean "and keep
74
+ * looking".
75
+ */
76
+ export const currentPollIntervalMs = (
77
+ now: number = Date.now(),
78
+ ambientMs: number = POLL_INTERVAL_MS,
79
+ ): number =>
80
+ isHotSyncWindowActive(now)
81
+ ? Math.min(HOT_POLL_INTERVAL_MS, ambientMs)
82
+ : ambientMs;
83
+
65
84
  /**
66
85
  * Bounded concurrency for the per-poll fan-out across open accounts —
67
86
  * mirrors `SCHEDULER_ENQUEUE_CONCURRENCY` in
@@ -186,6 +205,8 @@ const runBackgroundSync = (
186
205
  * first time MailLayout mounts in a session, then keep it fresh with a
187
206
  * recurring online poll for as long as the mail app stays open (#1251) —
188
207
  * the client-side replacement for the server's removed "online" sync tier.
208
+ * The poll runs on the ambient interval, or the hot one for the few minutes
209
+ * after somebody presses refresh, and only while the tab is visible.
189
210
  * Fire-and-forget: does not block render, and silently logs failures (this
190
211
  * is a best-effort background refresh — the user-visible UI for triggering
191
212
  * is the "Refresh mailboxes" button in Settings).
@@ -250,27 +271,48 @@ export const useStaleAccountSync = (
250
271
  const tick = () => {
251
272
  if (document.visibilityState === "visible") {
252
273
  poll();
253
- timeoutId = setTimeout(tick, POLL_INTERVAL_MS);
274
+ timeoutId = setTimeout(tick, currentPollIntervalMs());
254
275
  return;
255
276
  }
256
277
  // Hidden: don't reschedule. `handleVisibilityChange` resumes the loop
257
278
  // (with an immediate catch-up poll) once the tab is visible again.
279
+ // A hot window running out while the tab is away costs nothing —
280
+ // there is nobody watching a list to keep current.
258
281
  };
259
282
 
260
283
  const handleVisibilityChange = () => {
261
284
  if (document.visibilityState !== "visible") return;
262
- if (!hasPollIntervalElapsed(Date.now(), lastPollAt)) return;
285
+ if (
286
+ !hasPollIntervalElapsed(Date.now(), lastPollAt, currentPollIntervalMs())
287
+ ) {
288
+ return;
289
+ }
263
290
  if (timeoutId !== undefined) clearTimeout(timeoutId);
264
291
  poll();
265
- timeoutId = setTimeout(tick, POLL_INTERVAL_MS);
292
+ timeoutId = setTimeout(tick, currentPollIntervalMs());
293
+ };
294
+
295
+ // A press opens the hot window, but this loop may already be asleep on a
296
+ // long ambient interval — reschedule onto the hot one rather than sit
297
+ // out the rest of that wait. The press has just triggered its own sync,
298
+ // so this only moves the *next* tick: never a poll on the press itself,
299
+ // and never a tighter loop than the interval (POST /sync is deliberately
300
+ // never coalesced, #37).
301
+ const handleHotWindow = () => {
302
+ if (document.visibilityState !== "visible") return;
303
+ if (timeoutId !== undefined) clearTimeout(timeoutId);
304
+ lastPollAt = Date.now();
305
+ timeoutId = setTimeout(tick, currentPollIntervalMs());
266
306
  };
267
307
 
268
- timeoutId = setTimeout(tick, POLL_INTERVAL_MS);
308
+ timeoutId = setTimeout(tick, currentPollIntervalMs());
269
309
  document.addEventListener("visibilitychange", handleVisibilityChange);
310
+ const unsubscribeHotWindow = subscribeHotSyncWindow(handleHotWindow);
270
311
 
271
312
  return () => {
272
313
  if (timeoutId !== undefined) clearTimeout(timeoutId);
273
314
  document.removeEventListener("visibilitychange", handleVisibilityChange);
315
+ unsubscribeHotWindow();
274
316
  };
275
317
  }, [stableKey, queryClient]);
276
318
  };
@@ -0,0 +1,64 @@
1
+ import assert from "node:assert/strict";
2
+ import { afterEach, describe, test } from "node:test";
3
+ import {
4
+ __resetHotSyncWindow,
5
+ HOT_POLL_INTERVAL_MS,
6
+ HOT_WINDOW_MS,
7
+ isHotSyncWindowActive,
8
+ startHotSyncWindow,
9
+ subscribeHotSyncWindow,
10
+ } from "./hot-sync-window.js";
11
+
12
+ const NOW = 1_700_000_000_000;
13
+
14
+ describe("hot sync window", () => {
15
+ afterEach(__resetHotSyncWindow);
16
+
17
+ test("is closed until somebody presses refresh", () => {
18
+ assert.equal(isHotSyncWindowActive(NOW), false);
19
+ });
20
+
21
+ test("stays open for a few minutes after a press", () => {
22
+ startHotSyncWindow(NOW);
23
+
24
+ assert.equal(isHotSyncWindowActive(NOW), true);
25
+ assert.equal(isHotSyncWindowActive(NOW + HOT_WINDOW_MS - 1), true);
26
+ });
27
+
28
+ test("expires on its own", () => {
29
+ startHotSyncWindow(NOW);
30
+
31
+ assert.equal(isHotSyncWindowActive(NOW + HOT_WINDOW_MS), false);
32
+ assert.equal(isHotSyncWindowActive(NOW + HOT_WINDOW_MS + 1), false);
33
+ });
34
+
35
+ // Someone still pressing refresh is still waiting on mail.
36
+ test("a second press extends the window from that press", () => {
37
+ startHotSyncWindow(NOW);
38
+ startHotSyncWindow(NOW + 60_000);
39
+
40
+ assert.equal(isHotSyncWindowActive(NOW + HOT_WINDOW_MS), true);
41
+ assert.equal(isHotSyncWindowActive(NOW + HOT_WINDOW_MS + 60_000), false);
42
+ });
43
+
44
+ // The poll loop may already be asleep on a long ambient interval when the
45
+ // press lands; without this it would sit out the rest of that wait before
46
+ // the hot cadence took effect at all.
47
+ test("notifies subscribers on a press, and stops after unsubscribe", () => {
48
+ let presses = 0;
49
+ const unsubscribe = subscribeHotSyncWindow(() => {
50
+ presses += 1;
51
+ });
52
+
53
+ startHotSyncWindow(NOW);
54
+ assert.equal(presses, 1);
55
+
56
+ unsubscribe();
57
+ startHotSyncWindow(NOW);
58
+ assert.equal(presses, 1);
59
+ });
60
+
61
+ test("the hot cadence is thirty seconds", () => {
62
+ assert.equal(HOT_POLL_INTERVAL_MS, 30_000);
63
+ });
64
+ });
@@ -0,0 +1,48 @@
1
+ /**
2
+ * A short window of faster background syncing, opened by a person pressing
3
+ * refresh.
4
+ *
5
+ * Pressing refresh says "I am waiting on mail", and the reply people expect is
6
+ * not one round but the next few minutes: a reply lands, a colleague answers,
7
+ * the transfer confirms. The ambient cadence alone would leave that mail up to
8
+ * a full interval away, so the press widens the tab's own poll for a few
9
+ * minutes and then lets it fall back.
10
+ *
11
+ * Module state rather than context: the press happens in the refresh control
12
+ * and the polling happens in `useStaleAccountSync`, two hooks with no common
13
+ * ancestor that owns this, and the window belongs to the tab either way.
14
+ */
15
+
16
+ /** How long a press keeps the tab polling on the hot cadence. */
17
+ export const HOT_WINDOW_MS = 3 * 60 * 1000;
18
+
19
+ /** The cadence inside the window. Never faster: `POST /sync` skips the
20
+ * server's per-mailbox freshness gate, so every tick is a real folder-by-folder
21
+ * fan-out for every open account. */
22
+ export const HOT_POLL_INTERVAL_MS = 30 * 1000;
23
+
24
+ let hotUntil = 0;
25
+ const listeners = new Set<() => void>();
26
+
27
+ /** Open (or re-open) the window. Called on a refresh press, never on a timer. */
28
+ export const startHotSyncWindow = (now: number = Date.now()): void => {
29
+ hotUntil = now + HOT_WINDOW_MS;
30
+ for (const listener of listeners) listener();
31
+ };
32
+
33
+ export const isHotSyncWindowActive = (now: number = Date.now()): boolean =>
34
+ now < hotUntil;
35
+
36
+ /** Notifies on every press so a poll loop already asleep on the ambient
37
+ * interval can reschedule onto the hot one instead of finishing its long wait
38
+ * first. */
39
+ export const subscribeHotSyncWindow = (listener: () => void): (() => void) => {
40
+ listeners.add(listener);
41
+ return () => {
42
+ listeners.delete(listener);
43
+ };
44
+ };
45
+
46
+ export const __resetHotSyncWindow = (): void => {
47
+ hotUntil = 0;
48
+ };
@@ -3,7 +3,7 @@ import { describe, test } from "node:test";
3
3
  import type { RemitImapMailboxSyncProgress } from "@remit/api-http-client/types.gen.ts";
4
4
  import type { NavMailboxRole } from "@remit/ui";
5
5
  import type { ResultFolderIndex } from "@/lib/result-folder";
6
- import { hasGrown, totalsFrom } from "./mail-freshness.js";
6
+ import { grownMailboxIds, hasGrown, totalsFrom } from "./mail-freshness.js";
7
7
 
8
8
  const mailbox = (
9
9
  mailboxId: string,
@@ -114,3 +114,33 @@ describe("totalsFrom", () => {
114
114
  assert.equal(hasGrown(baseline, afterArrivalAndDelete), true);
115
115
  });
116
116
  });
117
+
118
+ describe("grownMailboxIds", () => {
119
+ // The invalidation half of the same reading: which folders' listings are
120
+ // now behind the server, so an open tab reloads exactly those.
121
+ test("names only the mailboxes whose totals moved up", () => {
122
+ const baseline = new Map([
123
+ ["mb-1", 10],
124
+ ["mb-2", 4],
125
+ ["mb-3", 7],
126
+ ]);
127
+ const current = new Map([
128
+ ["mb-1", 12],
129
+ ["mb-2", 4],
130
+ ["mb-3", 6],
131
+ ]);
132
+
133
+ assert.deepEqual(grownMailboxIds(baseline, current), ["mb-1"]);
134
+ });
135
+
136
+ test("counts a folder absent from the baseline from zero", () => {
137
+ assert.deepEqual(grownMailboxIds(new Map(), new Map([["mb-new", 3]])), [
138
+ "mb-new",
139
+ ]);
140
+ });
141
+
142
+ test("is empty when nothing moved", () => {
143
+ const totals = new Map([["mb-1", 10]]);
144
+ assert.deepEqual(grownMailboxIds(totals, new Map(totals)), []);
145
+ });
146
+ });
@@ -14,6 +14,10 @@ import {
14
14
  } from "react";
15
15
  import { useMailContext } from "@/lib/mail-context";
16
16
  import type { ResultFolderIndex } from "@/lib/result-folder";
17
+ import {
18
+ invalidateThreadListQueries,
19
+ threadListCacheKeys,
20
+ } from "@/lib/thread-list-cache";
17
21
 
18
22
  /**
19
23
  * How often the background poll re-reads each open account's sync status.
@@ -55,22 +59,29 @@ export const totalsFrom = (
55
59
  };
56
60
 
57
61
  /**
58
- * Whether any mailbox's message total grew since the baseline. Pure so the
59
- * "did mail arrive" call is unit-testable without a DOM or a QueryClient. A
60
- * mailbox missing from the baseline (new folder, first-ever reading) counts
61
- * from zero rather than being skipped, so mail landing in a folder created
62
- * since the last look still counts as an arrival.
62
+ * Which mailboxes' totals grew since the baseline. Pure so the "did mail
63
+ * arrive" call is unit-testable without a DOM or a QueryClient. A mailbox
64
+ * missing from the baseline (new folder, first-ever reading) counts from zero
65
+ * rather than being skipped, so mail landing in a folder created since the last
66
+ * look still counts as an arrival.
63
67
  */
64
- export const hasGrown = (
68
+ export const grownMailboxIds = (
65
69
  baseline: MailboxTotals,
66
70
  current: MailboxTotals,
67
- ): boolean => {
71
+ ): string[] => {
72
+ const grown: string[] = [];
68
73
  for (const [mailboxId, total] of current) {
69
- if (total > (baseline.get(mailboxId) ?? 0)) return true;
74
+ if (total > (baseline.get(mailboxId) ?? 0)) grown.push(mailboxId);
70
75
  }
71
- return false;
76
+ return grown;
72
77
  };
73
78
 
79
+ /** Whether any mailbox grew at all — the dot's own question. */
80
+ export const hasGrown = (
81
+ baseline: MailboxTotals,
82
+ current: MailboxTotals,
83
+ ): boolean => grownMailboxIds(baseline, current).length > 0;
84
+
74
85
  interface MailFreshnessContextValue {
75
86
  /** True once any of these accounts has grown since it was last acknowledged. */
76
87
  hasNewMail: (accountIds: readonly string[]) => boolean;
@@ -102,10 +113,13 @@ interface MailFreshnessProviderProps {
102
113
  /**
103
114
  * Watches every open account's sync status once a minute and flags which
104
115
  * accounts have grown since they were last shown to the user (#582 clause
105
- * 4). The flag is sticky until acknowledged — a background tick never
106
- * invalidates or reorders anything on its own, it only lights the dot on
107
- * whichever `RefreshButton` reads this context, leaving the choice to load
108
- * it with the user.
116
+ * 4). The flag is sticky until acknowledged: it lights the dot on whichever
117
+ * `RefreshButton` reads this context and stays lit until a refresh clears it.
118
+ *
119
+ * A tick that sees a folder's total move also invalidates that folder's thread
120
+ * listing. It reorders nothing and asks the mail server for nothing — the
121
+ * message is already on our side, so an open list showing it is a cache read,
122
+ * not IMAP work. The dot is still the account-level signal.
109
123
  */
110
124
  export function MailFreshnessProvider({
111
125
  accountIds,
@@ -115,6 +129,10 @@ export function MailFreshnessProvider({
115
129
  const { resultFolderIndex } = useMailContext();
116
130
  const baselineRef = useRef(new Map<string, MailboxTotals>());
117
131
  const seededRef = useRef(new Set<string>());
132
+ // The previous tick's reading, distinct from the dot's baseline: the dot's
133
+ // baseline is sticky until acknowledged, so comparing against it would
134
+ // re-invalidate on every tick for as long as the dot stays lit.
135
+ const previousTotalsRef = useRef(new Map<string, MailboxTotals>());
118
136
  const [newMail, setNewMail] = useState<ReadonlySet<string>>(new Set());
119
137
 
120
138
  const queries = useQueries({
@@ -136,6 +154,20 @@ export function MailFreshnessProvider({
136
154
  const data = queries[index]?.data;
137
155
  if (!data) return;
138
156
  const current = totalsFrom(data.mailboxes ?? [], resultFolderIndex);
157
+
158
+ // Mail that has already landed on the server costs nothing more to
159
+ // show, so a folder whose total moved since the last tick reloads
160
+ // its listing here. That caps how stale an open tab's list can get
161
+ // at this poll's own interval, without a single extra IMAP round.
162
+ const previous = previousTotalsRef.current.get(accountId);
163
+ previousTotalsRef.current.set(accountId, current);
164
+ if (previous) {
165
+ const grown = grownMailboxIds(previous, current);
166
+ if (grown.length > 0) {
167
+ invalidateThreadListQueries(queryClient, threadListCacheKeys(grown));
168
+ }
169
+ }
170
+
139
171
  if (!seededRef.current.has(accountId)) {
140
172
  seededRef.current.add(accountId);
141
173
  baselineRef.current.set(accountId, current);