@remit/web-client 0.0.12 → 0.0.14

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.
Files changed (45) hide show
  1. package/package.json +1 -1
  2. package/src/auth/better-auth-config.ts +4 -1
  3. package/src/components/compose/ComposeForm.tsx +3 -0
  4. package/src/components/layout/ComposeFab.tsx +19 -34
  5. package/src/components/layout/MailTopBar.tsx +64 -0
  6. package/src/components/mail/BriefPane.tsx +1 -12
  7. package/src/components/mail/FlaggedPane.tsx +1 -12
  8. package/src/components/mail/IntelligencePane.tsx +10 -8
  9. package/src/components/mail/MailListHeader.tsx +9 -5
  10. package/src/components/mail/MailboxPane.tsx +0 -10
  11. package/src/components/mail/MessageActionMenu.tsx +18 -25
  12. package/src/components/mail/MessageDetail.tsx +1 -0
  13. package/src/components/mail/MessageToolbar.render.test.ts +39 -0
  14. package/src/components/mail/MessageToolbar.tsx +8 -48
  15. package/src/components/ui/ErrorBanner.tsx +6 -3
  16. package/src/components/ui/ErrorBannerProvider.render.test.ts +85 -0
  17. package/src/components/ui/ErrorBannerProvider.tsx +18 -0
  18. package/src/components/ui/error-banners.ts +9 -0
  19. package/src/hooks/useComposeTarget.ts +92 -0
  20. package/src/hooks/useDeleteMessages.ts +14 -21
  21. package/src/hooks/useIntelligenceData.ts +14 -3
  22. package/src/hooks/useLayoutTier.test.ts +26 -0
  23. package/src/hooks/useMarkAsRead.ts +14 -23
  24. package/src/hooks/useMessageBodyContent.ts +2 -1
  25. package/src/hooks/useMoveMessages.ts +14 -21
  26. package/src/hooks/useStaleAccountSync.test.ts +22 -1
  27. package/src/hooks/useToggleStar.test.ts +31 -0
  28. package/src/hooks/useToggleStar.ts +27 -31
  29. package/src/hooks/useUpdateAddressFlags.ts +3 -1
  30. package/src/lib/api.ts +3 -1
  31. package/src/lib/client.ts +3 -0
  32. package/src/lib/compose-routes.test.ts +44 -0
  33. package/src/lib/compose-routes.ts +25 -0
  34. package/src/lib/error-classifier.test.ts +156 -8
  35. package/src/lib/error-classifier.ts +49 -9
  36. package/src/lib/network-error.ts +58 -0
  37. package/src/lib/query-error-handler.test.ts +6 -2
  38. package/src/lib/query-escalation.integration.test.ts +3 -2
  39. package/src/lib/route-search-query.test.ts +48 -0
  40. package/src/lib/sender-address.test.ts +60 -0
  41. package/src/lib/sender-address.ts +37 -0
  42. package/src/lib/thread-cache.test.ts +65 -0
  43. package/src/lib/thread-cache.ts +76 -0
  44. package/src/routes/mail/outbox.tsx +5 -0
  45. package/src/routes/mail.tsx +15 -1
@@ -0,0 +1,85 @@
1
+ import assert from "node:assert/strict";
2
+ import { afterEach, describe, it } from "node:test";
3
+ import { createElement } from "react";
4
+ import { renderToString } from "react-dom/server";
5
+ import { ApiError } from "@/lib/api";
6
+ import { __resetFatalError, getCurrentFatalError } from "@/lib/fatal-error";
7
+ import { ErrorBannerProvider, useErrorBanners } from "./ErrorBannerProvider";
8
+ import type { PushErrorInput } from "./error-banners";
9
+
10
+ /**
11
+ * Push one error through the real provider and report where it went. The
12
+ * routing decision happens inside `pushError`, so a single render is enough:
13
+ * either a fatal was recorded, or the error stayed a soft banner.
14
+ */
15
+ const push = (input: PushErrorInput): { escalated: boolean } => {
16
+ const Pusher = () => {
17
+ useErrorBanners().pushError(input);
18
+ return null;
19
+ };
20
+ renderToString(
21
+ createElement(ErrorBannerProvider, null, createElement(Pusher)) as never,
22
+ );
23
+ return { escalated: getCurrentFatalError() !== null };
24
+ };
25
+
26
+ afterEach(() => {
27
+ __resetFatalError();
28
+ });
29
+
30
+ describe("pushError — a banner is a soft surface only", () => {
31
+ it("keeps a soft failure with no error attached in a banner", () => {
32
+ assert.equal(push({ title: "Couldn't save draft" }).escalated, false);
33
+ });
34
+
35
+ it("treats an absent error as absent, not as a bug", () => {
36
+ // A caller forwarding an optional error it does not have passes
37
+ // `undefined`. That is "nothing went wrong that we can classify", not a
38
+ // client-side exception, and must not take over the screen.
39
+ assert.equal(
40
+ push({ title: "Couldn't save draft", error: undefined }).escalated,
41
+ false,
42
+ );
43
+ assert.equal(
44
+ push({ title: "Couldn't save draft", error: null }).escalated,
45
+ false,
46
+ );
47
+ });
48
+
49
+ it("keeps a 4xx the call site owns in a banner", () => {
50
+ assert.equal(
51
+ push({
52
+ title: "Couldn't move this message",
53
+ error: new ApiError("Not found", 404),
54
+ }).escalated,
55
+ false,
56
+ );
57
+ });
58
+
59
+ it("escalates a 5xx to the fatal page instead of a toast", () => {
60
+ assert.equal(
61
+ push({
62
+ title: "Couldn't move 339 messages",
63
+ error: new ApiError("Internal error", 500),
64
+ }).escalated,
65
+ true,
66
+ );
67
+ assert.equal(getCurrentFatalError()?.message, "Internal error");
68
+ });
69
+
70
+ it("escalates an exception thrown by our own code (issue #55)", () => {
71
+ const bug = new TypeError(
72
+ 'can\'t access property "map", N.pages is undefined',
73
+ );
74
+ assert.equal(
75
+ push({ title: "Couldn't move 339 messages", error: bug }).escalated,
76
+ true,
77
+ );
78
+ assert.equal(getCurrentFatalError()?.message, bug.message);
79
+ assert.equal(
80
+ getCurrentFatalError()?.recoverable,
81
+ false,
82
+ "a bug is not something the user can retry away",
83
+ );
84
+ });
85
+ });
@@ -6,6 +6,8 @@ import {
6
6
  useMemo,
7
7
  useState,
8
8
  } from "react";
9
+ import { isAlwaysFatal } from "@/lib/error-classifier";
10
+ import { reportFatalError } from "@/lib/fatal-error";
9
11
  import { ErrorBannerStack } from "./ErrorBannerStack.js";
10
12
  import {
11
13
  appendBanner,
@@ -39,7 +41,23 @@ const generateId = (): string => {
39
41
  export const ErrorBannerProvider = ({ children }: { children: ReactNode }) => {
40
42
  const [errors, setErrors] = useState<ErrorBannerEntry[]>([]);
41
43
 
44
+ /**
45
+ * Show a soft, dismissible banner — and only that. A fatal error is not a
46
+ * notification: when the caller hands us a 5xx or an exception from our own
47
+ * code, it goes to the fatal seam instead, so the user gets the full-screen
48
+ * page with a way forward and a bug report rather than a toast they can only
49
+ * dismiss (issue #55). Those two classes are fatal with no opt-out; anything
50
+ * a call site can legitimately own — a 404 empty state, a 4xx the user can
51
+ * act on — still banners here.
52
+ *
53
+ * The guard is on the error being present, not on the key being present:
54
+ * `error` is optional, so a caller forwarding one it does not have passes
55
+ * `undefined`, and "no error at all" must not be classified as a bug.
56
+ */
42
57
  const pushError = useCallback((input: PushErrorInput): string => {
58
+ if (input.error != null && isAlwaysFatal(input.error)) {
59
+ return reportFatalError(input.error).correlationId;
60
+ }
43
61
  const entry = buildEntry(input, generateId(), Date.now());
44
62
  setErrors((current) => appendBanner(current, entry));
45
63
  return entry.id;
@@ -12,6 +12,14 @@ export interface PushErrorInput {
12
12
  severity?: ErrorBannerSeverity;
13
13
  title: string;
14
14
  detail?: string;
15
+ /**
16
+ * The error being reported, when there is one. Pass it: a banner is a soft
17
+ * surface, and `pushError` uses this to refuse errors that are not soft —
18
+ * a 5xx or a client-side exception is escalated to the full-screen fatal
19
+ * page (which offers Retry, a way out, and a bug report) instead of being
20
+ * reduced to a dismissible toast.
21
+ */
22
+ error?: unknown;
15
23
  }
16
24
 
17
25
  const MAX_BANNERS = 5;
@@ -30,6 +38,7 @@ export const buildMutationErrorBanner = (
30
38
  ): PushErrorInput => ({
31
39
  title,
32
40
  detail: formatErrorDetail(error) ?? fallback,
41
+ error,
33
42
  });
34
43
 
35
44
  export const formatErrorDetail = (error: unknown): string | undefined => {
@@ -0,0 +1,92 @@
1
+ /**
2
+ * Where a global compose has to land.
3
+ *
4
+ * The compose surface (`FullCompose`) is mounted by the mailbox route only, so
5
+ * compose started from anywhere else — the daily brief, flagged, outbox — has
6
+ * to carry the user to a mailbox first. The target is the first account's
7
+ * inbox, falling back to its first mailbox.
8
+ *
9
+ * Both compose entry points use this: the top bar's button on desktop and the
10
+ * mobile `ComposeFab`. One resolver, so the two surfaces cannot disagree about
11
+ * which routes host the compose surface.
12
+ */
13
+ import { mailboxOperationsListMailboxesOptions } from "@remit/api-http-client/@tanstack/react-query.gen.ts";
14
+ import type { RemitImapAccountResponse } from "@remit/api-http-client/types.gen.ts";
15
+ import { useQueries } from "@tanstack/react-query";
16
+ import { useLocation, useNavigate } from "@tanstack/react-router";
17
+ import { startTransition, useCallback } from "react";
18
+ import { useCompose } from "@/components/compose/ComposeProvider";
19
+ import { hostsComposeSurface } from "@/lib/compose-routes";
20
+ import { buildMailboxRoleMap } from "@/lib/folder-roles";
21
+
22
+ /**
23
+ * The mailbox id a global compose should navigate to, or undefined if none is
24
+ * known yet.
25
+ *
26
+ * Accounts resolve in order and an account whose mailbox query is still in
27
+ * flight blocks rather than being skipped: skipping hands back a later
28
+ * account's inbox and then silently swaps the target once the earlier query
29
+ * settles.
30
+ */
31
+ export function useComposeTargetMailboxId(
32
+ accounts: RemitImapAccountResponse[],
33
+ ): string | undefined {
34
+ const mailboxQueries = useQueries({
35
+ queries: accounts.map((account) => ({
36
+ ...mailboxOperationsListMailboxesOptions({
37
+ path: { accountId: account.accountId },
38
+ }),
39
+ staleTime: Infinity,
40
+ })),
41
+ });
42
+
43
+ for (const [index, account] of accounts.entries()) {
44
+ const query = mailboxQueries[index];
45
+ if (!query || query.isPending) return undefined;
46
+ const mailboxes = query.data?.items ?? [];
47
+ if (mailboxes.length === 0) continue;
48
+ const roleMap = buildMailboxRoleMap(account.folderAppointments);
49
+ const inbox = mailboxes.find(
50
+ (mailbox) => roleMap.get(mailbox.mailboxId) === "inbox",
51
+ );
52
+ return (inbox ?? mailboxes[0])?.mailboxId;
53
+ }
54
+ return undefined;
55
+ }
56
+
57
+ /**
58
+ * A compose action that works from every view: opens compose in place when the
59
+ * current route already hosts the surface, otherwise navigates to the target
60
+ * mailbox — compose state lives in `ComposeProvider` (mounted at the root), so
61
+ * it survives the transition and the destination mounts straight into it.
62
+ *
63
+ * With no target resolved yet — a cold load whose mailbox queries have not
64
+ * settled, or accounts with no mailboxes — the action does nothing. Opening
65
+ * compose state first would leave it open with nothing rendering it, and it
66
+ * would then pop up unprompted on the next navigation.
67
+ */
68
+ export function useGlobalCompose(
69
+ accounts: RemitImapAccountResponse[],
70
+ ): () => void {
71
+ const { openCompose } = useCompose();
72
+ const navigate = useNavigate();
73
+ const location = useLocation();
74
+ const targetMailboxId = useComposeTargetMailboxId(accounts);
75
+
76
+ return useCallback(() => {
77
+ if (hostsComposeSurface(location.pathname)) {
78
+ startTransition(() => {
79
+ openCompose({ mode: "new" });
80
+ });
81
+ return;
82
+ }
83
+ if (!targetMailboxId) return;
84
+ startTransition(() => {
85
+ openCompose({ mode: "new" });
86
+ });
87
+ navigate({
88
+ to: "/mail/$mailboxId",
89
+ params: { mailboxId: targetMailboxId },
90
+ });
91
+ }, [openCompose, navigate, location.pathname, targetMailboxId]);
92
+ }
@@ -10,6 +10,7 @@ import { useMutation, useQueryClient } from "@tanstack/react-query";
10
10
  import { useCallback } from "react";
11
11
  import { useErrorBanners } from "@/components/ui/ErrorBannerProvider";
12
12
  import { formatErrorDetail } from "@/components/ui/error-banners";
13
+ import { patchThreadListCache, type ThreadListCache } from "@/lib/thread-cache";
13
14
 
14
15
  interface UseDeleteMessagesOptions {
15
16
  mailboxId: string;
@@ -28,15 +29,12 @@ interface ThreadMessagesData {
28
29
  [key: string]: unknown;
29
30
  }
30
31
 
31
- interface ThreadsListPage {
32
- items: RemitImapThreadMessageResponse[];
33
- [key: string]: unknown;
34
- }
35
-
36
- interface ThreadsListData {
37
- pages: ThreadsListPage[];
38
- pageParams: Array<string | undefined>;
39
- }
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;
40
38
 
41
39
  interface SnapshotEntry<T> {
42
40
  queryKey: readonly unknown[];
@@ -147,22 +145,16 @@ export const useDeleteMessages = ({
147
145
  );
148
146
  }
149
147
 
150
- const patchListData = (old: ThreadsListData | undefined) => {
151
- if (!old) return old;
152
- return {
153
- ...old,
154
- pages: old.pages.map((page) => ({
155
- ...page,
156
- items: removeMessagesFromItems(page.items, messageIds),
157
- })),
158
- };
159
- };
148
+ const patchListData = (old: unknown) =>
149
+ patchThreadListCache(old, (items) =>
150
+ removeMessagesFromItems(items, messageIds),
151
+ );
160
152
 
161
- queryClient.setQueriesData<ThreadsListData>(
153
+ queryClient.setQueriesData(
162
154
  { queryKey: threadsListPrefix },
163
155
  patchListData,
164
156
  );
165
- queryClient.setQueriesData<ThreadsListData>(
157
+ queryClient.setQueriesData(
166
158
  { queryKey: threadsSearchPrefix },
167
159
  patchListData,
168
160
  );
@@ -193,6 +185,7 @@ export const useDeleteMessages = ({
193
185
  ? `Couldn't delete ${count} messages`
194
186
  : "Couldn't delete this message",
195
187
  detail: formatErrorDetail(err),
188
+ error: err,
196
189
  });
197
190
  },
198
191
  onSettled: (_data, _err, _vars, context) => {
@@ -18,6 +18,10 @@ import { useQuery } from "@tanstack/react-query";
18
18
  import { useMemo } from "react";
19
19
  import { isServerError } from "@/lib/error-classifier";
20
20
  import { formatDate, formatEmailDate } from "@/lib/format";
21
+ import {
22
+ pickSenderAddress,
23
+ senderAddressSearchQuery,
24
+ } from "@/lib/sender-address";
21
25
 
22
26
  /**
23
27
  * Format a creation timestamp as a human-readable "first seen" label.
@@ -180,7 +184,11 @@ export interface UseIntelligenceDataResult {
180
184
  * that misrepresents the sender's real state. Escalation covers it.
181
185
  */
182
186
  addressErrorIsFatal: boolean;
183
- /** Address id for the sender — needed for PATCH /addresses/{id} mutations. */
187
+ /**
188
+ * Address id for the sender — needed for PATCH /addresses/{id} mutations.
189
+ * `undefined` until the lookup resolves the row for exactly this address;
190
+ * quick actions that PATCH it must stay unwired until then.
191
+ */
184
192
  addressId: string | undefined;
185
193
  /**
186
194
  * Raw address response for the sender — forwarded to the quick-action
@@ -209,13 +217,16 @@ export function useIntelligenceData(
209
217
  // --- Address lookup: flags + first-seen timestamp ---
210
218
  const { data: addressSearchResult, error: addressError } = useQuery({
211
219
  ...addressOperationsSearchAddressesOptions({
212
- query: { q: senderEmail ?? "", limit: 1 },
220
+ query: senderAddressSearchQuery(senderEmail ?? undefined),
213
221
  }),
214
222
  enabled: Boolean(senderEmail),
215
223
  staleTime: 30_000,
216
224
  });
217
225
 
218
- const address = addressSearchResult?.items?.[0];
226
+ const address = pickSenderAddress(
227
+ addressSearchResult?.items,
228
+ senderEmail ?? undefined,
229
+ );
219
230
 
220
231
  // --- Semantic search: similar messages ---
221
232
  const semanticQuery = [subject, senderEmail].filter(Boolean).join(" ");
@@ -45,3 +45,29 @@ describe("isSinglePaneTier — compose surface must mount below desktop", () =>
45
45
  assert.equal(isSinglePaneTier(resolveLayoutTier(1024)), false);
46
46
  });
47
47
  });
48
+
49
+ describe("exactly one search field is mounted at every width", () => {
50
+ // Two call sites decide this from the same tier: `mail.tsx` mounts the top
51
+ // bar's field when the layout is not single-pane, and `MailListHeader`
52
+ // passes `showSearch` to keep the header's field otherwise. Both read
53
+ // `isSinglePaneTier`, so they are each other's complement by construction —
54
+ // if either is rewritten to its own expression this fails. Two mounted
55
+ // fields compete for "/" and for focus (#59); zero leaves no way to search.
56
+ const mountsTopBarField = (width: number): boolean =>
57
+ !isSinglePaneTier(resolveLayoutTier(width));
58
+ const mountsHeaderField = (width: number): boolean =>
59
+ isSinglePaneTier(resolveLayoutTier(width));
60
+
61
+ for (const width of [0, 390, 767, 768, 1023, 1024, 1440]) {
62
+ it(`mounts one field at ${width}px`, () => {
63
+ const fields =
64
+ Number(mountsTopBarField(width)) + Number(mountsHeaderField(width));
65
+ assert.equal(fields, 1, `${width}px mounts ${fields} search fields`);
66
+ });
67
+ }
68
+
69
+ it("puts the field in the top bar only from desktop up", () => {
70
+ assert.equal(mountsTopBarField(1023), false);
71
+ assert.equal(mountsTopBarField(1024), true);
72
+ });
73
+ });
@@ -10,6 +10,7 @@ import { useMutation, useQueryClient } from "@tanstack/react-query";
10
10
  import { useCallback, useEffect, useMemo, useRef } from "react";
11
11
  import { useErrorBanners } from "@/components/ui/ErrorBannerProvider";
12
12
  import { formatErrorDetail } from "@/components/ui/error-banners";
13
+ import { patchThreadListCache, type ThreadListCache } from "@/lib/thread-cache";
13
14
 
14
15
  interface UseMarkAsReadOptions {
15
16
  messages: RemitImapThreadMessageResponse[];
@@ -24,15 +25,12 @@ interface ThreadMessagesData {
24
25
  [key: string]: unknown;
25
26
  }
26
27
 
27
- interface ThreadsListPage {
28
- items: RemitImapThreadMessageResponse[];
29
- [key: string]: unknown;
30
- }
31
-
32
- interface ThreadsListData {
33
- pages: ThreadsListPage[];
34
- pageParams: Array<string | undefined>;
35
- }
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;
36
34
 
37
35
  interface SnapshotEntry<T> {
38
36
  queryKey: readonly unknown[];
@@ -178,25 +176,16 @@ export const useMarkAsRead = ({
178
176
  },
179
177
  );
180
178
 
181
- const patchListData = (old: ThreadsListData | undefined) => {
182
- if (!old) return old;
183
- return {
184
- ...old,
185
- pages: old.pages.map((page) => ({
186
- ...page,
187
- items: setReadOnItems(page.items, messageIds, isRead),
188
- })),
189
- };
190
- };
179
+ const patchListData = (old: unknown) =>
180
+ patchThreadListCache(old, (items) =>
181
+ setReadOnItems(items, messageIds, isRead),
182
+ );
191
183
 
192
184
  for (const queryKey of [
193
185
  ...threadsListPrefixes,
194
186
  ...threadsSearchPrefixes,
195
187
  ]) {
196
- queryClient.setQueriesData<ThreadsListData>(
197
- { queryKey },
198
- patchListData,
199
- );
188
+ queryClient.setQueriesData({ queryKey }, patchListData);
200
189
  }
201
190
 
202
191
  return {
@@ -231,6 +220,7 @@ export const useMarkAsRead = ({
231
220
  pushError({
232
221
  title: isRead ? "Couldn't mark as read" : "Couldn't mark as unread",
233
222
  detail: formatErrorDetail(error),
223
+ error,
234
224
  });
235
225
  },
236
226
  onSettled: (_data, _err, _vars, context) => {
@@ -321,6 +311,7 @@ export const useToggleReadFor = (options: {
321
311
  pushError({
322
312
  title: isRead ? "Couldn't mark as read" : "Couldn't mark as unread",
323
313
  detail: formatErrorDetail(error),
314
+ error,
324
315
  });
325
316
  },
326
317
  onSettled: () => {
@@ -7,6 +7,7 @@ import {
7
7
  pickRenderablePart,
8
8
  type RenderableBodyPart,
9
9
  } from "@/lib/message-body-source";
10
+ import { taggedFetch } from "@/lib/network-error";
10
11
  import { useTelemetry } from "@/lib/telemetry-context";
11
12
  import { messageKeys } from "./queries/keys";
12
13
 
@@ -186,7 +187,7 @@ export const fetchBodyContent = async (
186
187
  const headers: Record<string, string> = {};
187
188
  const token = await getToken();
188
189
  if (token) headers.Authorization = `Bearer ${token}`;
189
- const response = await fetch(url, { headers });
190
+ const response = await taggedFetch(url, { headers });
190
191
  // 202 = the body is not synced yet. The content route has re-armed the sync
191
192
  // cue; retry after `Retry-After` seconds rather than rendering the placeholder
192
193
  // body (a 202 is `ok`, so this must be caught before the success path).
@@ -10,6 +10,7 @@ import { useMutation, useQueryClient } from "@tanstack/react-query";
10
10
  import { useCallback } from "react";
11
11
  import { useErrorBanners } from "@/components/ui/ErrorBannerProvider";
12
12
  import { formatErrorDetail } from "@/components/ui/error-banners";
13
+ import { patchThreadListCache, type ThreadListCache } from "@/lib/thread-cache";
13
14
 
14
15
  interface UseMoveMessagesOptions {
15
16
  mailboxId: string;
@@ -28,15 +29,12 @@ interface ThreadMessagesData {
28
29
  [key: string]: unknown;
29
30
  }
30
31
 
31
- interface ThreadsListPage {
32
- items: RemitImapThreadMessageResponse[];
33
- [key: string]: unknown;
34
- }
35
-
36
- interface ThreadsListData {
37
- pages: ThreadsListPage[];
38
- pageParams: Array<string | undefined>;
39
- }
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;
40
38
 
41
39
  interface SnapshotEntry<T> {
42
40
  queryKey: readonly unknown[];
@@ -142,22 +140,16 @@ export const useMoveMessages = ({
142
140
  );
143
141
  }
144
142
 
145
- const patchListData = (old: ThreadsListData | undefined) => {
146
- if (!old) return old;
147
- return {
148
- ...old,
149
- pages: old.pages.map((page) => ({
150
- ...page,
151
- items: removeMovedMessagesFromItems(page.items, messageIds),
152
- })),
153
- };
154
- };
143
+ const patchListData = (old: unknown) =>
144
+ patchThreadListCache(old, (items) =>
145
+ removeMovedMessagesFromItems(items, messageIds),
146
+ );
155
147
 
156
- queryClient.setQueriesData<ThreadsListData>(
148
+ queryClient.setQueriesData(
157
149
  { queryKey: threadsListPrefix },
158
150
  patchListData,
159
151
  );
160
- queryClient.setQueriesData<ThreadsListData>(
152
+ queryClient.setQueriesData(
161
153
  { queryKey: threadsSearchPrefix },
162
154
  patchListData,
163
155
  );
@@ -188,6 +180,7 @@ export const useMoveMessages = ({
188
180
  ? `Couldn't move ${count} messages`
189
181
  : "Couldn't move this message",
190
182
  detail: formatErrorDetail(err),
183
+ error: err,
191
184
  });
192
185
  },
193
186
  onSettled: (_data, _err, _vars, context) => {
@@ -2,6 +2,7 @@ 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 { NetworkError } from "../lib/network-error.js";
5
6
  import {
6
7
  __peekStaleAccountSyncGuard,
7
8
  __resetStaleAccountSyncGuard,
@@ -227,11 +228,31 @@ describe("handleBackgroundSyncFailure", () => {
227
228
  escalated = true;
228
229
  });
229
230
 
230
- handleBackgroundSyncFailure("a-1", new TypeError("Failed to fetch"));
231
+ // The probe goes through the generated client, so a real transport
232
+ // failure arrives tagged by the fetch boundary.
233
+ handleBackgroundSyncFailure(
234
+ "a-1",
235
+ new NetworkError(new TypeError("Failed to fetch")),
236
+ );
231
237
 
232
238
  assert.equal(escalated, false);
233
239
  });
234
240
 
241
+ test("an exception from our own code on the probe DOES escalate", () => {
242
+ silenceWarn();
243
+ let escalated = false;
244
+ subscribeFatalError(() => {
245
+ escalated = true;
246
+ });
247
+
248
+ // Untagged and statusless: nothing said this came off the wire, so it is
249
+ // our bug, and a silent background probe is exactly where one would
250
+ // otherwise go unnoticed.
251
+ handleBackgroundSyncFailure("a-1", new TypeError("x is not a function"));
252
+
253
+ assert.equal(escalated, true);
254
+ });
255
+
235
256
  test("drops the per-account guard so a later remount can retry", () => {
236
257
  silenceWarn();
237
258
  __peekStaleAccountSyncGuard(); // touch to ensure import is used
@@ -1,6 +1,7 @@
1
1
  import assert from "node:assert";
2
2
  import { describe, 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 {
5
6
  resolveMailboxForMessage,
6
7
  toggleStarsInItems,
@@ -93,3 +94,33 @@ describe("resolveMailboxForMessage", () => {
93
94
  );
94
95
  });
95
96
  });
97
+
98
+ describe("starring across the unified-threads cache shapes", () => {
99
+ // The Flagged view runs an infinite query on `listAllThreads({ starred:
100
+ // true })`, which shares its query-key prefix with the single-shot readers.
101
+ // A `setQueriesData` on that prefix therefore sees both shapes, and the
102
+ // optimistic patch has to survive whichever it is handed — patching the
103
+ // infinite entry as a plain `{ items }` threw and failed the star before it
104
+ // was sent.
105
+ const patch = (old: unknown) =>
106
+ patchThreadListCache(old, (items) => toggleStarsInItems(items, "m1", true));
107
+
108
+ test("patches the single-shot readers", () => {
109
+ const patched = patch({
110
+ items: [make("m1", false), make("m2", false)],
111
+ }) as {
112
+ items: RemitImapThreadMessageResponse[];
113
+ };
114
+ assert.equal(patched.items[0].hasStars, true);
115
+ assert.equal(patched.items[1].hasStars, false);
116
+ });
117
+
118
+ test("patches the Flagged view's infinite query instead of throwing", () => {
119
+ const patched = patch({
120
+ pages: [{ items: [make("m1", false)] }, { items: [make("m2", false)] }],
121
+ pageParams: [undefined, "next"],
122
+ }) as { pages: Array<{ items: RemitImapThreadMessageResponse[] }> };
123
+ assert.equal(patched.pages[0].items[0].hasStars, true);
124
+ assert.equal(patched.pages[1].items[0].hasStars, false);
125
+ });
126
+ });