@remit/web-client 0.0.32 → 0.0.34

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.32",
3
+ "version": "0.0.34",
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": {
@@ -29,12 +29,78 @@ export const authClient = createAuthClient();
29
29
 
30
30
  const EXPIRY_SKEW_SECONDS = 60;
31
31
 
32
+ // How long to stop reaching for the token endpoint after a mint is throttled.
33
+ // A near-expiry token is still usable for `EXPIRY_SKEW_SECONDS`, so backing off
34
+ // keeps a throttled window from turning every request into another mint attempt
35
+ // against an endpoint that already said no.
36
+ const REFRESH_BACKOFF_SECONDS = 30;
37
+
38
+ // Survives page reloads and in-tab navigations so a fresh page load reuses the
39
+ // session's token instead of minting a new one. Cleared on sign-out. Scoped to
40
+ // the tab, not the origin: the token is short-lived and the httpOnly session
41
+ // cookie remains the real credential.
42
+ const STORAGE_KEY = "remit.better-auth.token";
43
+
32
44
  interface CachedToken {
33
45
  value: string;
34
46
  expiresAt: number;
35
47
  }
36
48
 
49
+ const tokenStore = (): Storage | null => {
50
+ try {
51
+ return globalThis.sessionStorage ?? null;
52
+ } catch {
53
+ return null;
54
+ }
55
+ };
56
+
57
+ const readStore = (): CachedToken | null => {
58
+ const raw = tokenStore()?.getItem(STORAGE_KEY);
59
+ if (!raw) return null;
60
+ try {
61
+ const parsed: unknown = JSON.parse(raw);
62
+ if (
63
+ parsed &&
64
+ typeof parsed === "object" &&
65
+ typeof (parsed as CachedToken).value === "string" &&
66
+ typeof (parsed as CachedToken).expiresAt === "number"
67
+ ) {
68
+ return {
69
+ value: (parsed as CachedToken).value,
70
+ expiresAt: (parsed as CachedToken).expiresAt,
71
+ };
72
+ }
73
+ } catch {
74
+ // A corrupt entry is treated as no token.
75
+ }
76
+ return null;
77
+ };
78
+
79
+ const writeStore = (token: CachedToken | null): void => {
80
+ const store = tokenStore();
81
+ if (!store) return;
82
+ try {
83
+ if (!token) {
84
+ store.removeItem(STORAGE_KEY);
85
+ return;
86
+ }
87
+ store.setItem(STORAGE_KEY, JSON.stringify(token));
88
+ } catch {
89
+ // Storage being unavailable (private mode, quota) is not fatal: the
90
+ // in-memory cache still serves the current page.
91
+ }
92
+ };
93
+
37
94
  let cached: CachedToken | null = null;
95
+ let backoffUntil = 0;
96
+
97
+ // Hydrate the in-memory cache from storage on first read of a page load, so a
98
+ // token minted before a reload is reused rather than re-minted.
99
+ const currentCache = (): CachedToken | null => {
100
+ if (cached) return cached;
101
+ cached = readStore();
102
+ return cached;
103
+ };
38
104
 
39
105
  const nowSeconds = (): number => Math.floor(Date.now() / 1000);
40
106
 
@@ -103,6 +169,8 @@ const mint = (): Promise<string> => {
103
169
  inFlight = requestToken()
104
170
  .then((token) => {
105
171
  cached = { value: token, expiresAt: decodeExp(token) };
172
+ writeStore(cached);
173
+ backoffUntil = 0;
106
174
  return token;
107
175
  })
108
176
  .finally(() => {
@@ -111,23 +179,49 @@ const mint = (): Promise<string> => {
111
179
  return inFlight;
112
180
  };
113
181
 
182
+ const isFresh = (token: CachedToken): boolean =>
183
+ token.expiresAt - EXPIRY_SKEW_SECONDS > nowSeconds();
184
+
185
+ const isUsable = (token: CachedToken): boolean =>
186
+ token.expiresAt > nowSeconds();
187
+
114
188
  /**
115
189
  * Return a valid RS256 JWT for the current session, minting a fresh one only
116
190
  * when the cached token is missing or within the skew window of expiry. The API
117
191
  * interceptor attaches it as a Bearer token; the backend verifies it against the
118
192
  * JWKS.
119
193
  *
120
- * Never resolves to null: a failed mint throws. Returning null would put the
121
- * request on the wire without an Authorization header, and the backend answers
122
- * that with a 401 that names a missing token rather than the mint that failed.
194
+ * A throttled or failed refresh never discards a token that is still usable: the
195
+ * request keeps the session it already holds and the endpoint is left alone
196
+ * until the backoff clears. Only a mint with nothing usable to fall back to
197
+ * throws — returning null would put the request on the wire without an
198
+ * Authorization header, and the backend answers that with a 401 that names a
199
+ * missing token rather than the mint that failed.
123
200
  */
124
201
  export const fetchBetterAuthToken = async (): Promise<string> => {
125
- if (cached && cached.expiresAt - EXPIRY_SKEW_SECONDS > nowSeconds()) {
126
- return cached.value;
202
+ const current = currentCache();
203
+
204
+ if (current && isFresh(current)) {
205
+ return current.value;
206
+ }
207
+
208
+ if (current && isUsable(current) && nowSeconds() < backoffUntil) {
209
+ return current.value;
210
+ }
211
+
212
+ try {
213
+ return await mint();
214
+ } catch (error) {
215
+ if (current && isUsable(current)) {
216
+ backoffUntil = nowSeconds() + REFRESH_BACKOFF_SECONDS;
217
+ return current.value;
218
+ }
219
+ throw error;
127
220
  }
128
- return mint();
129
221
  };
130
222
 
131
223
  export const resetBetterAuthTokenCache = (): void => {
224
+ backoffUntil = 0;
225
+ writeStore(null);
132
226
  cached = null;
133
227
  };
@@ -13,12 +13,21 @@ const base64url = (value: string): string =>
13
13
  .replace(/\//g, "_")
14
14
  .replace(/=+$/, "");
15
15
 
16
- /** A token shaped like the one better-auth mints, valid for an hour. */
17
- const jwt = (label: string): string =>
16
+ /** A token shaped like the one better-auth mints, expiring after `ttl` seconds. */
17
+ const jwtExpiringIn = (label: string, ttl: number): string =>
18
18
  `header.${base64url(
19
- JSON.stringify({ exp: Math.floor(Date.now() / 1000) + 3600, label }),
19
+ JSON.stringify({ exp: Math.floor(Date.now() / 1000) + ttl, label }),
20
20
  )}.signature`;
21
21
 
22
+ /** A token shaped like the one better-auth mints, valid for an hour. */
23
+ const jwt = (label: string): string => jwtExpiringIn(label, 3600);
24
+
25
+ /**
26
+ * A token past its comfortable-refresh window but not yet expired: `getToken`
27
+ * treats it as needing a refresh while still holding it as a usable fallback.
28
+ */
29
+ const nearExpiryJwt = (label: string): string => jwtExpiringIn(label, 30);
30
+
22
31
  const realFetch = globalThis.fetch;
23
32
 
24
33
  interface Stub {
@@ -149,4 +158,64 @@ describe("fetchBetterAuthToken", () => {
149
158
  /could not be completed|Failed to fetch/i,
150
159
  );
151
160
  });
161
+
162
+ test("a throttled refresh keeps the still-valid token instead of discarding it", async () => {
163
+ const seed = stubTokenEndpoint(() =>
164
+ tokenResponse(nearExpiryJwt("still-valid")),
165
+ );
166
+ seed.release();
167
+ const held = await fetchBetterAuthToken();
168
+
169
+ const throttled = stubTokenEndpoint(
170
+ () => new Response("", { status: 429, statusText: "Too Many Requests" }),
171
+ );
172
+ throttled.release();
173
+ const afterThrottle = await fetchBetterAuthToken();
174
+
175
+ assert.equal(throttled.calls, 1);
176
+ assert.equal(afterThrottle, held);
177
+ });
178
+
179
+ test("after a throttled refresh it backs off rather than hammering the endpoint", async () => {
180
+ const seed = stubTokenEndpoint(() =>
181
+ tokenResponse(nearExpiryJwt("still-valid")),
182
+ );
183
+ seed.release();
184
+ const held = await fetchBetterAuthToken();
185
+
186
+ const throttled = stubTokenEndpoint(
187
+ () => new Response("", { status: 429, statusText: "Too Many Requests" }),
188
+ );
189
+ throttled.release();
190
+ await fetchBetterAuthToken();
191
+
192
+ const first = await fetchBetterAuthToken();
193
+ const second = await fetchBetterAuthToken();
194
+
195
+ assert.equal(throttled.calls, 1);
196
+ assert.equal(first, held);
197
+ assert.equal(second, held);
198
+ });
199
+
200
+ test("a throttled refresh with no usable token to fall back on still throws", async () => {
201
+ const seed = stubTokenEndpoint(() =>
202
+ tokenResponse(jwtExpiringIn("already-expired", -10)),
203
+ );
204
+ seed.release();
205
+ await fetchBetterAuthToken();
206
+
207
+ const throttled = stubTokenEndpoint(
208
+ () => new Response("", { status: 429, statusText: "Too Many Requests" }),
209
+ );
210
+ throttled.release();
211
+
212
+ await assert.rejects(
213
+ () => fetchBetterAuthToken(),
214
+ (error: unknown) => {
215
+ assert.ok(error instanceof AuthTokenError);
216
+ assert.equal(error.status, 429);
217
+ return true;
218
+ },
219
+ );
220
+ });
152
221
  });
@@ -2,15 +2,21 @@ import {
2
2
  mailboxOperationsListMailboxesQueryKey,
3
3
  messageBulkOperationsDeleteMessagesMutation,
4
4
  threadDetailOperationsListThreadMessagesQueryKey,
5
- threadOperationsListThreadsQueryKey,
6
- threadOperationsSearchThreadsQueryKey,
7
5
  } from "@remit/api-http-client/@tanstack/react-query.gen.ts";
8
6
  import type { RemitImapThreadMessageResponse } from "@remit/api-http-client/types.gen.ts";
9
7
  import { useMutation, useQueryClient } from "@tanstack/react-query";
10
8
  import { useCallback } from "react";
11
9
  import { useErrorBanners } from "@/components/ui/ErrorBannerProvider";
12
10
  import { formatErrorDetail } from "@/components/ui/error-banners";
13
- import { patchThreadListCache, type ThreadListCache } from "@/lib/thread-cache";
11
+ import {
12
+ cancelThreadListQueries,
13
+ invalidateThreadListQueries,
14
+ patchThreadListQueries,
15
+ restoreThreadListQueries,
16
+ snapshotThreadListQueries,
17
+ type ThreadListSnapshotEntry,
18
+ threadListCacheKeys,
19
+ } from "@/lib/thread-list-cache";
14
20
 
15
21
  interface UseDeleteMessagesOptions {
16
22
  mailboxId: string;
@@ -29,13 +35,6 @@ interface ThreadMessagesData {
29
35
  [key: string]: unknown;
30
36
  }
31
37
 
32
- /**
33
- * Either shape a thread list/search query caches — the infinite mailbox list
34
- * or a single-shot page. `setQueriesData` matches by key prefix, so the
35
- * optimistic updater sees both.
36
- */
37
- type ThreadsListData = ThreadListCache;
38
-
39
38
  interface SnapshotEntry<T> {
40
39
  queryKey: readonly unknown[];
41
40
  data: T;
@@ -43,10 +42,9 @@ interface SnapshotEntry<T> {
43
42
 
44
43
  interface DeleteContext {
45
44
  threadMessagesPrefix: readonly unknown[];
46
- threadsListPrefix: readonly unknown[];
47
- threadsSearchPrefix: readonly unknown[];
45
+ listPrefixes: ReadonlyArray<readonly unknown[]>;
48
46
  previousThreadMessages: SnapshotEntry<ThreadMessagesData>[];
49
- previousThreadsList: SnapshotEntry<ThreadsListData>[];
47
+ previousThreadsList: ThreadListSnapshotEntry[];
50
48
  }
51
49
 
52
50
  /**
@@ -94,17 +92,14 @@ export const useDeleteMessages = ({
94
92
  path: { threadId },
95
93
  })
96
94
  : [];
97
- const threadsListPrefix = threadOperationsListThreadsQueryKey({
98
- path: { mailboxId },
99
- });
100
- const threadsSearchPrefix = threadOperationsSearchThreadsQueryKey({
101
- path: { mailboxId },
102
- });
95
+ // The browsed mailbox's lists plus the unified cross-account listing
96
+ // that backs the daily brief — deleting from the brief has to remove the
97
+ // row there too, not only from the per-mailbox lists (#140, part of #149).
98
+ const listPrefixes = threadListCacheKeys([mailboxId]);
103
99
 
104
100
  await Promise.all([
105
101
  queryClient.cancelQueries({ queryKey: threadMessagesPrefix }),
106
- queryClient.cancelQueries({ queryKey: threadsListPrefix }),
107
- queryClient.cancelQueries({ queryKey: threadsSearchPrefix }),
102
+ cancelThreadListQueries(queryClient, listPrefixes),
108
103
  ]);
109
104
 
110
105
  const previousThreadMessages = threadId
@@ -119,18 +114,10 @@ export const useDeleteMessages = ({
119
114
  .map(([queryKey, data]) => ({ queryKey, data }))
120
115
  : [];
121
116
 
122
- const previousThreadsList = queryClient
123
- .getQueriesData<ThreadsListData>({ queryKey: threadsListPrefix })
124
- .concat(
125
- queryClient.getQueriesData<ThreadsListData>({
126
- queryKey: threadsSearchPrefix,
127
- }),
128
- )
129
- .filter(
130
- (entry): entry is [readonly unknown[], ThreadsListData] =>
131
- entry[1] !== undefined,
132
- )
133
- .map(([queryKey, data]) => ({ queryKey, data }));
117
+ const previousThreadsList = snapshotThreadListQueries(
118
+ queryClient,
119
+ listPrefixes,
120
+ );
134
121
 
135
122
  if (threadId) {
136
123
  queryClient.setQueriesData<ThreadMessagesData>(
@@ -145,26 +132,15 @@ export const useDeleteMessages = ({
145
132
  );
146
133
  }
147
134
 
148
- const patchListData = (old: unknown) =>
149
- patchThreadListCache(old, (items) =>
150
- removeMessagesFromItems(items, messageIds),
151
- );
152
-
153
- queryClient.setQueriesData(
154
- { queryKey: threadsListPrefix },
155
- patchListData,
156
- );
157
- queryClient.setQueriesData(
158
- { queryKey: threadsSearchPrefix },
159
- patchListData,
135
+ patchThreadListQueries(queryClient, listPrefixes, (items) =>
136
+ removeMessagesFromItems(items, messageIds),
160
137
  );
161
138
 
162
139
  onAfterOptimisticRemove?.(Array.from(messageIds));
163
140
 
164
141
  return {
165
142
  threadMessagesPrefix,
166
- threadsListPrefix,
167
- threadsSearchPrefix,
143
+ listPrefixes,
168
144
  previousThreadMessages,
169
145
  previousThreadsList,
170
146
  };
@@ -174,9 +150,7 @@ export const useDeleteMessages = ({
174
150
  for (const entry of context.previousThreadMessages) {
175
151
  queryClient.setQueryData(entry.queryKey, entry.data);
176
152
  }
177
- for (const entry of context.previousThreadsList) {
178
- queryClient.setQueryData(entry.queryKey, entry.data);
179
- }
153
+ restoreThreadListQueries(queryClient, context.previousThreadsList);
180
154
  }
181
155
  const count = vars.body.messageIds?.length ?? 0;
182
156
  pushError({
@@ -195,8 +169,7 @@ export const useDeleteMessages = ({
195
169
  queryKey: context.threadMessagesPrefix,
196
170
  });
197
171
  }
198
- queryClient.invalidateQueries({ queryKey: context.threadsListPrefix });
199
- queryClient.invalidateQueries({ queryKey: context.threadsSearchPrefix });
172
+ invalidateThreadListQueries(queryClient, context.listPrefixes);
200
173
  if (accountId) {
201
174
  queryClient.invalidateQueries({
202
175
  queryKey: mailboxOperationsListMailboxesQueryKey({
@@ -1,9 +1,13 @@
1
1
  import assert from "node:assert";
2
- import { describe, test } from "node:test";
2
+ import { afterEach, beforeEach, describe, mock, test } from "node:test";
3
3
  import type { RemitImapThreadMessageResponse } from "@remit/api-http-client/types.gen.ts";
4
+ import { patchThreadListCache } from "../lib/thread-cache.js";
4
5
  import {
6
+ MARK_READ_DELAY_MS,
5
7
  resolveMailboxesForMessages,
8
+ scheduleMarkRead,
6
9
  selectMessagesToMarkRead,
10
+ setReadOnItems,
7
11
  } from "./useMarkAsRead.js";
8
12
 
9
13
  const make = (
@@ -167,6 +171,92 @@ describe("selectMessagesToMarkRead", () => {
167
171
  });
168
172
  });
169
173
 
174
+ describe("marking read across the unified-threads cache shapes", () => {
175
+ // The daily brief reads from the unified cross-account listing, a different
176
+ // cache than the per-mailbox lists the regular inboxes render. Marking a
177
+ // message read has to patch that cache too, or the brief keeps its unread
178
+ // dot until reload (#140). The unified prefix carries both cache shapes — the
179
+ // brief's single page and the Flagged view's infinite query — so the patch
180
+ // has to survive whichever it is handed.
181
+ const patch = (old: unknown) =>
182
+ patchThreadListCache(old, (items) =>
183
+ setReadOnItems(items, new Set(["m1"]), true),
184
+ );
185
+
186
+ test("patches the brief's single-shot page", () => {
187
+ const patched = patch({
188
+ items: [
189
+ make({ messageId: "m1", threadMessageId: "tm1", isRead: false }),
190
+ make({ messageId: "m2", threadMessageId: "tm2", isRead: false }),
191
+ ],
192
+ }) as { items: RemitImapThreadMessageResponse[] };
193
+ assert.equal(patched.items[0].isRead, true);
194
+ assert.equal(patched.items[1].isRead, false);
195
+ });
196
+
197
+ test("patches the Flagged view's infinite query instead of throwing", () => {
198
+ const patched = patch({
199
+ pages: [
200
+ {
201
+ items: [
202
+ make({ messageId: "m1", threadMessageId: "tm1", isRead: false }),
203
+ ],
204
+ },
205
+ {
206
+ items: [
207
+ make({ messageId: "m2", threadMessageId: "tm2", isRead: false }),
208
+ ],
209
+ },
210
+ ],
211
+ pageParams: [undefined, "next"],
212
+ }) as { pages: Array<{ items: RemitImapThreadMessageResponse[] }> };
213
+ assert.equal(patched.pages[0].items[0].isRead, true);
214
+ assert.equal(patched.pages[1].items[0].isRead, false);
215
+ });
216
+ });
217
+
218
+ describe("scheduleMarkRead", () => {
219
+ beforeEach(() => {
220
+ mock.timers.enable({ apis: ["setTimeout"] });
221
+ });
222
+ afterEach(() => {
223
+ mock.timers.reset();
224
+ });
225
+
226
+ test("marks read once the dwell elapses", () => {
227
+ const marked: string[][] = [];
228
+ scheduleMarkRead(["m1"], MARK_READ_DELAY_MS, (ids) => marked.push(ids));
229
+ mock.timers.tick(MARK_READ_DELAY_MS - 1);
230
+ assert.deepStrictEqual(marked, []);
231
+ mock.timers.tick(1);
232
+ assert.deepStrictEqual(marked, [["m1"]]);
233
+ });
234
+
235
+ test("does not mark when the selection changes before the dwell", () => {
236
+ const marked: string[][] = [];
237
+ const cancel = scheduleMarkRead(["m1"], MARK_READ_DELAY_MS, (ids) =>
238
+ marked.push(ids),
239
+ );
240
+ mock.timers.tick(2000);
241
+ cancel();
242
+ mock.timers.tick(MARK_READ_DELAY_MS);
243
+ assert.deepStrictEqual(marked, []);
244
+ });
245
+
246
+ test("schedules nothing for an already-read (empty) selection", () => {
247
+ const marked: string[][] = [];
248
+ scheduleMarkRead([], MARK_READ_DELAY_MS, (ids) => marked.push(ids));
249
+ mock.timers.tick(MARK_READ_DELAY_MS);
250
+ assert.deepStrictEqual(marked, []);
251
+ });
252
+
253
+ test("marks at once when the delay is not positive", () => {
254
+ const marked: string[][] = [];
255
+ scheduleMarkRead(["m1"], 0, (ids) => marked.push(ids));
256
+ assert.deepStrictEqual(marked, [["m1"]]);
257
+ });
258
+ });
259
+
170
260
  describe("resolveMailboxesForMessages", () => {
171
261
  const messages = [
172
262
  make({
@@ -2,15 +2,21 @@ import {
2
2
  mailboxOperationsListMailboxesQueryKey,
3
3
  messageBulkOperationsUpdateFlagsMutation,
4
4
  threadDetailOperationsListThreadMessagesQueryKey,
5
- threadOperationsListThreadsQueryKey,
6
- threadOperationsSearchThreadsQueryKey,
7
5
  } from "@remit/api-http-client/@tanstack/react-query.gen.ts";
8
6
  import type { RemitImapThreadMessageResponse } from "@remit/api-http-client/types.gen.ts";
9
7
  import { useMutation, useQueryClient } from "@tanstack/react-query";
10
8
  import { useCallback, useEffect, useMemo, useRef } from "react";
11
9
  import { useErrorBanners } from "@/components/ui/ErrorBannerProvider";
12
10
  import { formatErrorDetail } from "@/components/ui/error-banners";
13
- import { patchThreadListCache, type ThreadListCache } from "@/lib/thread-cache";
11
+ import {
12
+ cancelThreadListQueries,
13
+ invalidateThreadListQueries,
14
+ patchThreadListQueries,
15
+ restoreThreadListQueries,
16
+ snapshotThreadListQueries,
17
+ type ThreadListSnapshotEntry,
18
+ threadListCacheKeys,
19
+ } from "@/lib/thread-list-cache";
14
20
 
15
21
  interface UseMarkAsReadOptions {
16
22
  messages: RemitImapThreadMessageResponse[];
@@ -25,13 +31,6 @@ interface ThreadMessagesData {
25
31
  [key: string]: unknown;
26
32
  }
27
33
 
28
- /**
29
- * Either shape a thread list/search query caches — the infinite mailbox list
30
- * or a single-shot page. `setQueriesData` matches by key prefix, so the
31
- * optimistic updater sees both.
32
- */
33
- type ThreadsListData = ThreadListCache;
34
-
35
34
  interface SnapshotEntry<T> {
36
35
  queryKey: readonly unknown[];
37
36
  data: T;
@@ -39,12 +38,38 @@ interface SnapshotEntry<T> {
39
38
 
40
39
  interface MarkAsReadContext {
41
40
  threadMessagesPrefix: readonly unknown[];
42
- threadsListPrefixes: ReadonlyArray<readonly unknown[]>;
43
- threadsSearchPrefixes: ReadonlyArray<readonly unknown[]>;
41
+ listPrefixes: ReadonlyArray<readonly unknown[]>;
44
42
  previousThreadMessages: SnapshotEntry<ThreadMessagesData>[];
45
- previousThreadsList: SnapshotEntry<ThreadsListData>[];
43
+ previousThreadsList: ThreadListSnapshotEntry[];
46
44
  }
47
45
 
46
+ /**
47
+ * Dwell before a message the user is viewing is marked read. A glance closed
48
+ * within this window leaves it unread (#140). Applied by the single shared
49
+ * trigger below, so the daily brief and the mailbox thread views agree.
50
+ */
51
+ export const MARK_READ_DELAY_MS = 3000;
52
+
53
+ /**
54
+ * Fire `markRead` for `messageIds` after `delayMs`. Returns a canceller the
55
+ * caller runs on unmount or when the selection changes before the dwell
56
+ * elapses; an empty list schedules nothing, and a non-positive delay fires at
57
+ * once. Framework-agnostic so the timing can be exercised with fake timers.
58
+ */
59
+ export const scheduleMarkRead = (
60
+ messageIds: string[],
61
+ delayMs: number,
62
+ markRead: (ids: string[]) => void,
63
+ ): (() => void) => {
64
+ if (messageIds.length === 0) return () => {};
65
+ if (delayMs <= 0) {
66
+ markRead(messageIds);
67
+ return () => {};
68
+ }
69
+ const timer = setTimeout(() => markRead(messageIds), delayMs);
70
+ return () => clearTimeout(timer);
71
+ };
72
+
48
73
  /**
49
74
  * The mailboxes whose cached listings a batch of message mutations affects.
50
75
  *
@@ -68,7 +93,7 @@ export const resolveMailboxesForMessages = (
68
93
  return [...mailboxIds];
69
94
  };
70
95
 
71
- const setReadOnItems = (
96
+ export const setReadOnItems = (
72
97
  items: RemitImapThreadMessageResponse[],
73
98
  messageIds: Set<string>,
74
99
  isRead: boolean,
@@ -122,26 +147,13 @@ export const useMarkAsRead = ({
122
147
  threadDetailOperationsListThreadMessagesQueryKey({
123
148
  path: { threadId },
124
149
  });
125
- const affectedMailboxIds = resolveMailboxesForMessages(
126
- messageIds,
127
- messages,
128
- mailboxId,
129
- );
130
- const threadsListPrefixes = affectedMailboxIds.map((id) =>
131
- threadOperationsListThreadsQueryKey({ path: { mailboxId: id } }),
132
- );
133
- const threadsSearchPrefixes = affectedMailboxIds.map((id) =>
134
- threadOperationsSearchThreadsQueryKey({ path: { mailboxId: id } }),
150
+ const listPrefixes = threadListCacheKeys(
151
+ resolveMailboxesForMessages(messageIds, messages, mailboxId),
135
152
  );
136
153
 
137
154
  await Promise.all([
138
155
  queryClient.cancelQueries({ queryKey: threadMessagesPrefix }),
139
- ...threadsListPrefixes.map((queryKey) =>
140
- queryClient.cancelQueries({ queryKey }),
141
- ),
142
- ...threadsSearchPrefixes.map((queryKey) =>
143
- queryClient.cancelQueries({ queryKey }),
144
- ),
156
+ cancelThreadListQueries(queryClient, listPrefixes),
145
157
  ]);
146
158
 
147
159
  const previousThreadMessages = queryClient
@@ -152,18 +164,10 @@ export const useMarkAsRead = ({
152
164
  )
153
165
  .map(([queryKey, data]) => ({ queryKey, data }));
154
166
 
155
- const previousThreadsList = [
156
- ...threadsListPrefixes,
157
- ...threadsSearchPrefixes,
158
- ]
159
- .flatMap((queryKey) =>
160
- queryClient.getQueriesData<ThreadsListData>({ queryKey }),
161
- )
162
- .filter(
163
- (entry): entry is [readonly unknown[], ThreadsListData] =>
164
- entry[1] !== undefined,
165
- )
166
- .map(([queryKey, data]) => ({ queryKey, data }));
167
+ const previousThreadsList = snapshotThreadListQueries(
168
+ queryClient,
169
+ listPrefixes,
170
+ );
167
171
 
168
172
  queryClient.setQueriesData<ThreadMessagesData>(
169
173
  { queryKey: threadMessagesPrefix },
@@ -176,22 +180,13 @@ export const useMarkAsRead = ({
176
180
  },
177
181
  );
178
182
 
179
- const patchListData = (old: unknown) =>
180
- patchThreadListCache(old, (items) =>
181
- setReadOnItems(items, messageIds, isRead),
182
- );
183
-
184
- for (const queryKey of [
185
- ...threadsListPrefixes,
186
- ...threadsSearchPrefixes,
187
- ]) {
188
- queryClient.setQueriesData({ queryKey }, patchListData);
189
- }
183
+ patchThreadListQueries(queryClient, listPrefixes, (items) =>
184
+ setReadOnItems(items, messageIds, isRead),
185
+ );
190
186
 
191
187
  return {
192
188
  threadMessagesPrefix,
193
- threadsListPrefixes,
194
- threadsSearchPrefixes,
189
+ listPrefixes,
195
190
  previousThreadMessages,
196
191
  previousThreadsList,
197
192
  };
@@ -212,9 +207,7 @@ export const useMarkAsRead = ({
212
207
  for (const entry of context.previousThreadMessages) {
213
208
  queryClient.setQueryData(entry.queryKey, entry.data);
214
209
  }
215
- for (const entry of context.previousThreadsList) {
216
- queryClient.setQueryData(entry.queryKey, entry.data);
217
- }
210
+ restoreThreadListQueries(queryClient, context.previousThreadsList);
218
211
  }
219
212
  const isRead = variables.body.isRead ?? true;
220
213
  pushError({
@@ -226,12 +219,7 @@ export const useMarkAsRead = ({
226
219
  onSettled: (_data, _err, _vars, context) => {
227
220
  if (!context) return;
228
221
  queryClient.invalidateQueries({ queryKey: context.threadMessagesPrefix });
229
- for (const queryKey of [
230
- ...context.threadsListPrefixes,
231
- ...context.threadsSearchPrefixes,
232
- ]) {
233
- queryClient.invalidateQueries({ queryKey });
234
- }
222
+ invalidateThreadListQueries(queryClient, context.listPrefixes);
235
223
  // Refresh the sidebar mailbox list so the unread badge picks up the
236
224
  // next backend `unseenCount` (which is owned by IMAP sync).
237
225
  if (accountId) {
@@ -276,13 +264,13 @@ export const useMarkAsRead = ({
276
264
  [messages, expandedIds],
277
265
  );
278
266
 
279
- // Mark as read immediately when a message is expanded match Gmail
280
- // behaviour. The previous implementation used a 10s delay plus an
281
- // "all expanded" gate that meant most reads silently never fired.
282
- useEffect(() => {
283
- if (eligibleIds.length === 0) return;
284
- markMessagesRead(eligibleIds);
285
- }, [eligibleIds, markMessagesRead]);
267
+ // Mark as read after a short dwell on the open message. Changing the
268
+ // selection or leaving the view before the dwell elapses cancels the pending
269
+ // mark, so a glance stays unread (#140).
270
+ useEffect(
271
+ () => scheduleMarkRead(eligibleIds, MARK_READ_DELAY_MS, markMessagesRead),
272
+ [eligibleIds, markMessagesRead],
273
+ );
286
274
 
287
275
  // biome-ignore lint/correctness/useExhaustiveDependencies: intentionally clears state on threadId change; adding threadId to deps causes stale closure issues
288
276
  useEffect(() => {
@@ -315,16 +303,10 @@ export const useToggleReadFor = (options: {
315
303
  });
316
304
  },
317
305
  onSettled: () => {
318
- queryClient.invalidateQueries({
319
- queryKey: threadOperationsListThreadsQueryKey({
320
- path: { mailboxId },
321
- }),
322
- });
323
- queryClient.invalidateQueries({
324
- queryKey: threadOperationsSearchThreadsQueryKey({
325
- path: { mailboxId },
326
- }),
327
- });
306
+ invalidateThreadListQueries(
307
+ queryClient,
308
+ threadListCacheKeys([mailboxId]),
309
+ );
328
310
  if (accountId) {
329
311
  queryClient.invalidateQueries({
330
312
  queryKey: mailboxOperationsListMailboxesQueryKey({
@@ -2,15 +2,21 @@ import {
2
2
  mailboxOperationsListMailboxesQueryKey,
3
3
  messageBulkOperationsMoveMessagesMutation,
4
4
  threadDetailOperationsListThreadMessagesQueryKey,
5
- threadOperationsListThreadsQueryKey,
6
- threadOperationsSearchThreadsQueryKey,
7
5
  } from "@remit/api-http-client/@tanstack/react-query.gen.ts";
8
6
  import type { RemitImapThreadMessageResponse } from "@remit/api-http-client/types.gen.ts";
9
7
  import { useMutation, useQueryClient } from "@tanstack/react-query";
10
8
  import { useCallback } from "react";
11
9
  import { useErrorBanners } from "@/components/ui/ErrorBannerProvider";
12
10
  import { formatErrorDetail } from "@/components/ui/error-banners";
13
- import { patchThreadListCache, type ThreadListCache } from "@/lib/thread-cache";
11
+ import {
12
+ cancelThreadListQueries,
13
+ invalidateThreadListQueries,
14
+ patchThreadListQueries,
15
+ restoreThreadListQueries,
16
+ snapshotThreadListQueries,
17
+ type ThreadListSnapshotEntry,
18
+ threadListCacheKeys,
19
+ } from "@/lib/thread-list-cache";
14
20
 
15
21
  interface UseMoveMessagesOptions {
16
22
  mailboxId: string;
@@ -29,13 +35,6 @@ interface ThreadMessagesData {
29
35
  [key: string]: unknown;
30
36
  }
31
37
 
32
- /**
33
- * Either shape a thread list/search query caches — the infinite mailbox list
34
- * or a single-shot page. `setQueriesData` matches by key prefix, so the
35
- * optimistic updater sees both.
36
- */
37
- type ThreadsListData = ThreadListCache;
38
-
39
38
  interface SnapshotEntry<T> {
40
39
  queryKey: readonly unknown[];
41
40
  data: T;
@@ -43,10 +42,9 @@ interface SnapshotEntry<T> {
43
42
 
44
43
  interface MoveContext {
45
44
  threadMessagesPrefix: readonly unknown[];
46
- threadsListPrefix: readonly unknown[];
47
- threadsSearchPrefix: readonly unknown[];
45
+ listPrefixes: ReadonlyArray<readonly unknown[]>;
48
46
  previousThreadMessages: SnapshotEntry<ThreadMessagesData>[];
49
- previousThreadsList: SnapshotEntry<ThreadsListData>[];
47
+ previousThreadsList: ThreadListSnapshotEntry[];
50
48
  }
51
49
 
52
50
  /**
@@ -82,24 +80,20 @@ export const useMoveMessages = ({
82
80
  path: { threadId },
83
81
  })
84
82
  : [];
85
- const threadsListPrefix = threadOperationsListThreadsQueryKey({
86
- path: { mailboxId },
87
- });
88
- const threadsSearchPrefix = threadOperationsSearchThreadsQueryKey({
89
- path: { mailboxId },
90
- });
83
+ // The source mailbox's lists plus the unified cross-account listing that
84
+ // backs the daily brief — moving from the brief has to remove the row
85
+ // there too, not only from the per-mailbox lists (#140, part of #149).
86
+ const listPrefixes = threadListCacheKeys([mailboxId]);
91
87
 
92
88
  // Only cancel the thread-messages query when we actually have a
93
89
  // threadId — passing an empty queryKey here would match every
94
90
  // query in the cache and cancel unrelated work (Copilot review,
95
- // PR #287). The list/search prefixes are always scoped to the
96
- // current mailbox so they're safe to cancel as-is.
91
+ // PR #287). The list prefixes are always scoped so they're safe.
97
92
  await Promise.all([
98
93
  ...(threadId
99
94
  ? [queryClient.cancelQueries({ queryKey: threadMessagesPrefix })]
100
95
  : []),
101
- queryClient.cancelQueries({ queryKey: threadsListPrefix }),
102
- queryClient.cancelQueries({ queryKey: threadsSearchPrefix }),
96
+ cancelThreadListQueries(queryClient, listPrefixes),
103
97
  ]);
104
98
 
105
99
  const previousThreadMessages = threadId
@@ -114,18 +108,10 @@ export const useMoveMessages = ({
114
108
  .map(([queryKey, data]) => ({ queryKey, data }))
115
109
  : [];
116
110
 
117
- const previousThreadsList = queryClient
118
- .getQueriesData<ThreadsListData>({ queryKey: threadsListPrefix })
119
- .concat(
120
- queryClient.getQueriesData<ThreadsListData>({
121
- queryKey: threadsSearchPrefix,
122
- }),
123
- )
124
- .filter(
125
- (entry): entry is [readonly unknown[], ThreadsListData] =>
126
- entry[1] !== undefined,
127
- )
128
- .map(([queryKey, data]) => ({ queryKey, data }));
111
+ const previousThreadsList = snapshotThreadListQueries(
112
+ queryClient,
113
+ listPrefixes,
114
+ );
129
115
 
130
116
  if (threadId) {
131
117
  queryClient.setQueriesData<ThreadMessagesData>(
@@ -140,26 +126,15 @@ export const useMoveMessages = ({
140
126
  );
141
127
  }
142
128
 
143
- const patchListData = (old: unknown) =>
144
- patchThreadListCache(old, (items) =>
145
- removeMovedMessagesFromItems(items, messageIds),
146
- );
147
-
148
- queryClient.setQueriesData(
149
- { queryKey: threadsListPrefix },
150
- patchListData,
151
- );
152
- queryClient.setQueriesData(
153
- { queryKey: threadsSearchPrefix },
154
- patchListData,
129
+ patchThreadListQueries(queryClient, listPrefixes, (items) =>
130
+ removeMovedMessagesFromItems(items, messageIds),
155
131
  );
156
132
 
157
133
  onAfterOptimisticRemove?.(Array.from(messageIds));
158
134
 
159
135
  return {
160
136
  threadMessagesPrefix,
161
- threadsListPrefix,
162
- threadsSearchPrefix,
137
+ listPrefixes,
163
138
  previousThreadMessages,
164
139
  previousThreadsList,
165
140
  };
@@ -169,9 +144,7 @@ export const useMoveMessages = ({
169
144
  for (const entry of context.previousThreadMessages) {
170
145
  queryClient.setQueryData(entry.queryKey, entry.data);
171
146
  }
172
- for (const entry of context.previousThreadsList) {
173
- queryClient.setQueryData(entry.queryKey, entry.data);
174
- }
147
+ restoreThreadListQueries(queryClient, context.previousThreadsList);
175
148
  }
176
149
  const count = vars.body.messageIds?.length ?? 0;
177
150
  pushError({
@@ -190,8 +163,7 @@ export const useMoveMessages = ({
190
163
  queryKey: context.threadMessagesPrefix,
191
164
  });
192
165
  }
193
- queryClient.invalidateQueries({ queryKey: context.threadsListPrefix });
194
- queryClient.invalidateQueries({ queryKey: context.threadsSearchPrefix });
166
+ invalidateThreadListQueries(queryClient, context.listPrefixes);
195
167
  if (accountId) {
196
168
  queryClient.invalidateQueries({
197
169
  queryKey: mailboxOperationsListMailboxesQueryKey({
@@ -1,15 +1,21 @@
1
1
  import {
2
2
  messageOperationsUpdateMessageFlagsMutation,
3
3
  threadDetailOperationsListThreadMessagesQueryKey,
4
- threadOperationsListThreadsQueryKey,
5
- threadOperationsSearchThreadsQueryKey,
6
- unifiedThreadOperationsListAllThreadsQueryKey,
7
4
  } from "@remit/api-http-client/@tanstack/react-query.gen.ts";
8
5
  import type { RemitImapThreadMessageResponse } from "@remit/api-http-client/types.gen.ts";
9
6
  import { useMutation, useQueryClient } from "@tanstack/react-query";
10
7
  import { useErrorBanners } from "@/components/ui/ErrorBannerProvider";
11
8
  import { formatErrorDetail } from "@/components/ui/error-banners";
12
- import { patchThreadListCache, type ThreadListCache } from "@/lib/thread-cache";
9
+ import { patchThreadListCache } from "@/lib/thread-cache";
10
+ import {
11
+ cancelThreadListQueries,
12
+ invalidateThreadListQueries,
13
+ patchThreadListQueries,
14
+ restoreThreadListQueries,
15
+ snapshotThreadListQueries,
16
+ type ThreadListSnapshotEntry,
17
+ threadListCacheKeys,
18
+ } from "@/lib/thread-list-cache";
13
19
 
14
20
  interface UseToggleStarOptions {
15
21
  threadId: string;
@@ -47,13 +53,6 @@ interface ThreadMessagesData {
47
53
  [key: string]: unknown;
48
54
  }
49
55
 
50
- /**
51
- * Either shape a thread list/search query caches — the infinite mailbox list
52
- * or a single-shot page. `setQueriesData` matches by key prefix, so the
53
- * optimistic updater sees both.
54
- */
55
- type ThreadsListData = ThreadListCache;
56
-
57
56
  interface SnapshotEntry<T> {
58
57
  queryKey: readonly unknown[];
59
58
  data: T;
@@ -61,12 +60,9 @@ interface SnapshotEntry<T> {
61
60
 
62
61
  interface ToggleStarContext {
63
62
  threadMessagesPrefix: readonly unknown[];
64
- threadsListPrefix: readonly unknown[];
65
- threadsSearchPrefix: readonly unknown[];
66
- unifiedThreadsPrefix: readonly unknown[];
63
+ listPrefixes: ReadonlyArray<readonly unknown[]>;
67
64
  previousThreadMessages: SnapshotEntry<ThreadMessagesData>[];
68
- previousThreadsList: SnapshotEntry<ThreadsListData>[];
69
- previousUnifiedThreads: SnapshotEntry<ThreadMessagesData>[];
65
+ previousThreadsList: ThreadListSnapshotEntry[];
70
66
  }
71
67
 
72
68
  export const toggleStarsInItems = (
@@ -100,29 +96,17 @@ export const useToggleStar = ({
100
96
  threadDetailOperationsListThreadMessagesQueryKey({
101
97
  path: { threadId },
102
98
  });
103
- const affectedMailboxId = resolveMailboxForMessage(
104
- messageId,
105
- messages,
106
- mailboxId,
107
- );
108
- const threadsListPrefix = threadOperationsListThreadsQueryKey({
109
- path: { mailboxId: affectedMailboxId },
110
- });
111
- const threadsSearchPrefix = threadOperationsSearchThreadsQueryKey({
112
- path: { mailboxId: affectedMailboxId },
113
- });
114
- // The unified cross-account listing backs the daily brief and the
115
- // Starred mailbox, so a star toggled from an inbox has to land there
116
- // too — without this the starred view keeps serving a stale page for
117
- // its whole staleTime and the message never appears.
118
- const unifiedThreadsPrefix =
119
- unifiedThreadOperationsListAllThreadsQueryKey();
99
+ // The list caches to patch: the message's own mailbox plus the unified
100
+ // cross-account listing that backs the daily brief and the Flagged view.
101
+ // Resolved from the shared source so this hook can never fall behind the
102
+ // others on which listings exist (#149).
103
+ const listPrefixes = threadListCacheKeys([
104
+ resolveMailboxForMessage(messageId, messages, mailboxId),
105
+ ]);
120
106
 
121
107
  await Promise.all([
122
108
  queryClient.cancelQueries({ queryKey: threadMessagesPrefix }),
123
- queryClient.cancelQueries({ queryKey: threadsListPrefix }),
124
- queryClient.cancelQueries({ queryKey: threadsSearchPrefix }),
125
- queryClient.cancelQueries({ queryKey: unifiedThreadsPrefix }),
109
+ cancelThreadListQueries(queryClient, listPrefixes),
126
110
  ]);
127
111
 
128
112
  const previousThreadMessages = queryClient
@@ -133,69 +117,27 @@ export const useToggleStar = ({
133
117
  )
134
118
  .map(([queryKey, data]) => ({ queryKey, data }));
135
119
 
136
- const previousThreadsList = queryClient
137
- .getQueriesData<ThreadsListData>({ queryKey: threadsListPrefix })
138
- .concat(
139
- queryClient.getQueriesData<ThreadsListData>({
140
- queryKey: threadsSearchPrefix,
141
- }),
142
- )
143
- .filter(
144
- (entry): entry is [readonly unknown[], ThreadsListData] =>
145
- entry[1] !== undefined,
146
- )
147
- .map(([queryKey, data]) => ({ queryKey, data }));
148
-
149
- const previousUnifiedThreads = queryClient
150
- .getQueriesData<ThreadMessagesData>({ queryKey: unifiedThreadsPrefix })
151
- .filter(
152
- (entry): entry is [readonly unknown[], ThreadMessagesData] =>
153
- entry[1] !== undefined,
154
- )
155
- .map(([queryKey, data]) => ({ queryKey, data }));
156
-
157
- // The unified-threads prefix carries both shapes too: the Flagged view
158
- // runs an infinite query on `listAllThreads({ starred: true })`, which
159
- // sits under the same prefix as the single-shot readers. Patching it as
160
- // a plain `{ items }` threw on that entry and failed the star before it
161
- // was sent — the same defect as the mailbox list (issues #51, #55), so
162
- // it goes through the same helper.
163
- const patchItemsData = (old: unknown) =>
164
- patchThreadListCache(old, (items) =>
165
- toggleStarsInItems(items, messageId, nextStarred),
166
- );
167
-
168
- queryClient.setQueriesData(
169
- { queryKey: threadMessagesPrefix },
170
- patchItemsData,
171
- );
172
- queryClient.setQueriesData(
173
- { queryKey: unifiedThreadsPrefix },
174
- patchItemsData,
120
+ const previousThreadsList = snapshotThreadListQueries(
121
+ queryClient,
122
+ listPrefixes,
175
123
  );
176
124
 
177
- const patchListData = (old: unknown) =>
125
+ // The thread-detail cache carries the `{ items }` shape too, so it goes
126
+ // through the same shape-tolerant helper as the listings.
127
+ queryClient.setQueriesData({ queryKey: threadMessagesPrefix }, (old) =>
178
128
  patchThreadListCache(old, (items) =>
179
129
  toggleStarsInItems(items, messageId, nextStarred),
180
- );
181
-
182
- queryClient.setQueriesData(
183
- { queryKey: threadsListPrefix },
184
- patchListData,
130
+ ),
185
131
  );
186
- queryClient.setQueriesData(
187
- { queryKey: threadsSearchPrefix },
188
- patchListData,
132
+ patchThreadListQueries(queryClient, listPrefixes, (items) =>
133
+ toggleStarsInItems(items, messageId, nextStarred),
189
134
  );
190
135
 
191
136
  return {
192
137
  threadMessagesPrefix,
193
- threadsListPrefix,
194
- threadsSearchPrefix,
195
- unifiedThreadsPrefix,
138
+ listPrefixes,
196
139
  previousThreadMessages,
197
140
  previousThreadsList,
198
- previousUnifiedThreads,
199
141
  };
200
142
  },
201
143
  onError: (err, vars, context) => {
@@ -203,12 +145,7 @@ export const useToggleStar = ({
203
145
  for (const entry of context.previousThreadMessages) {
204
146
  queryClient.setQueryData(entry.queryKey, entry.data);
205
147
  }
206
- for (const entry of context.previousThreadsList) {
207
- queryClient.setQueryData(entry.queryKey, entry.data);
208
- }
209
- for (const entry of context.previousUnifiedThreads) {
210
- queryClient.setQueryData(entry.queryKey, entry.data);
211
- }
148
+ restoreThreadListQueries(queryClient, context.previousThreadsList);
212
149
  }
213
150
  const nextStarred = vars.body.isStarred ?? false;
214
151
  pushError({
@@ -222,9 +159,7 @@ export const useToggleStar = ({
222
159
  onSettled: (_data, _err, _vars, context) => {
223
160
  if (!context) return;
224
161
  queryClient.invalidateQueries({ queryKey: context.threadMessagesPrefix });
225
- queryClient.invalidateQueries({ queryKey: context.threadsListPrefix });
226
- queryClient.invalidateQueries({ queryKey: context.threadsSearchPrefix });
227
- queryClient.invalidateQueries({ queryKey: context.unifiedThreadsPrefix });
162
+ invalidateThreadListQueries(queryClient, context.listPrefixes);
228
163
  },
229
164
  });
230
165
 
@@ -0,0 +1,44 @@
1
+ import assert from "node:assert";
2
+ import { describe, test } from "node:test";
3
+ import {
4
+ threadOperationsListThreadsQueryKey,
5
+ threadOperationsSearchThreadsQueryKey,
6
+ unifiedThreadOperationsListAllThreadsQueryKey,
7
+ } from "@remit/api-http-client/@tanstack/react-query.gen.ts";
8
+ import { threadListCacheKeys } from "./thread-list-cache.js";
9
+
10
+ describe("threadListCacheKeys", () => {
11
+ test("resolves each mailbox's list and search caches plus the unified one", () => {
12
+ const keys = threadListCacheKeys(["mb1"]);
13
+ assert.deepStrictEqual(keys, [
14
+ threadOperationsListThreadsQueryKey({ path: { mailboxId: "mb1" } }),
15
+ threadOperationsSearchThreadsQueryKey({ path: { mailboxId: "mb1" } }),
16
+ unifiedThreadOperationsListAllThreadsQueryKey(),
17
+ ]);
18
+ });
19
+
20
+ test("always includes the unified cross-account listing the daily brief reads", () => {
21
+ // #140: the brief kept its unread dot because the read mutation never
22
+ // reached this key. Every caller of the shared source now does.
23
+ const keys = threadListCacheKeys(["mb1", "mb2"]);
24
+ assert.ok(
25
+ keys.some(
26
+ (key) =>
27
+ JSON.stringify(key) ===
28
+ JSON.stringify(unifiedThreadOperationsListAllThreadsQueryKey()),
29
+ ),
30
+ );
31
+ });
32
+
33
+ test("emits one list and one search key per distinct mailbox", () => {
34
+ const keys = threadListCacheKeys(["mb1", "mb2"]);
35
+ // two mailboxes -> two list + two search + one unified
36
+ assert.strictEqual(keys.length, 5);
37
+ });
38
+
39
+ test("dedupes repeated mailbox ids", () => {
40
+ const keys = threadListCacheKeys(["mb1", "mb1"]);
41
+ // one mailbox -> one list + one search + one unified
42
+ assert.strictEqual(keys.length, 3);
43
+ });
44
+ });
@@ -0,0 +1,97 @@
1
+ import {
2
+ threadOperationsListThreadsQueryKey,
3
+ threadOperationsSearchThreadsQueryKey,
4
+ unifiedThreadOperationsListAllThreadsQueryKey,
5
+ } from "@remit/api-http-client/@tanstack/react-query.gen.ts";
6
+ import type { RemitImapThreadMessageResponse } from "@remit/api-http-client/types.gen.ts";
7
+ import type { QueryClient } from "@tanstack/react-query";
8
+ import { patchThreadListCache, type ThreadListCache } from "./thread-cache.js";
9
+
10
+ export interface ThreadListSnapshotEntry {
11
+ queryKey: readonly unknown[];
12
+ data: ThreadListCache;
13
+ }
14
+
15
+ /**
16
+ * Every cached thread listing the given mailboxes feed, as query-key prefixes:
17
+ * each mailbox's list and search caches, plus the unified cross-account listing
18
+ * that backs the daily brief and the Flagged view.
19
+ *
20
+ * A thread-list mutation (mark read, star, delete, move) has to reach all of
21
+ * them. When a hook hand-copies a subset, a view reading from a listing it
22
+ * forgot serves a stale page until reload: the daily brief kept its unread dot
23
+ * after a message was read because the read mutation patched the per-mailbox
24
+ * caches but not the unified one (#140). This is the single source every
25
+ * mutation hook resolves its keys from, so the set can never drift between them
26
+ * (part of #149). React Query matches by prefix, so each key also covers its
27
+ * option variants (order, pagination) without enumerating them.
28
+ */
29
+ export const threadListCacheKeys = (
30
+ mailboxIds: Iterable<string>,
31
+ ): ReadonlyArray<readonly unknown[]> => {
32
+ const keys: Array<readonly unknown[]> = [];
33
+ for (const mailboxId of new Set(mailboxIds)) {
34
+ keys.push(threadOperationsListThreadsQueryKey({ path: { mailboxId } }));
35
+ keys.push(threadOperationsSearchThreadsQueryKey({ path: { mailboxId } }));
36
+ }
37
+ keys.push(unifiedThreadOperationsListAllThreadsQueryKey());
38
+ return keys;
39
+ };
40
+
41
+ export const cancelThreadListQueries = (
42
+ queryClient: QueryClient,
43
+ prefixes: ReadonlyArray<readonly unknown[]>,
44
+ ): Promise<unknown> =>
45
+ Promise.all(
46
+ prefixes.map((queryKey) => queryClient.cancelQueries({ queryKey })),
47
+ );
48
+
49
+ export const snapshotThreadListQueries = (
50
+ queryClient: QueryClient,
51
+ prefixes: ReadonlyArray<readonly unknown[]>,
52
+ ): ThreadListSnapshotEntry[] =>
53
+ prefixes
54
+ .flatMap((queryKey) =>
55
+ queryClient.getQueriesData<ThreadListCache>({ queryKey }),
56
+ )
57
+ .filter(
58
+ (entry): entry is [readonly unknown[], ThreadListCache] =>
59
+ entry[1] !== undefined,
60
+ )
61
+ .map(([queryKey, data]) => ({ queryKey, data }));
62
+
63
+ /**
64
+ * Run `patchItems` over every cached listing under `prefixes`, whichever shape
65
+ * each holds — the shape-tolerant `patchThreadListCache` covers both the
66
+ * single-shot page (brief) and the infinite query (Flagged, mailbox list).
67
+ */
68
+ export const patchThreadListQueries = (
69
+ queryClient: QueryClient,
70
+ prefixes: ReadonlyArray<readonly unknown[]>,
71
+ patchItems: (
72
+ items: RemitImapThreadMessageResponse[],
73
+ ) => RemitImapThreadMessageResponse[],
74
+ ): void => {
75
+ const updater = (old: unknown) => patchThreadListCache(old, patchItems);
76
+ for (const queryKey of prefixes) {
77
+ queryClient.setQueriesData({ queryKey }, updater);
78
+ }
79
+ };
80
+
81
+ export const restoreThreadListQueries = (
82
+ queryClient: QueryClient,
83
+ snapshot: ThreadListSnapshotEntry[],
84
+ ): void => {
85
+ for (const entry of snapshot) {
86
+ queryClient.setQueryData(entry.queryKey, entry.data);
87
+ }
88
+ };
89
+
90
+ export const invalidateThreadListQueries = (
91
+ queryClient: QueryClient,
92
+ prefixes: ReadonlyArray<readonly unknown[]>,
93
+ ): void => {
94
+ for (const queryKey of prefixes) {
95
+ queryClient.invalidateQueries({ queryKey });
96
+ }
97
+ };