@remit/web-client 0.0.172 → 0.0.174

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.172",
3
+ "version": "0.0.174",
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": {
@@ -40,6 +40,7 @@ import {
40
40
  import { useMessageBodyContent } from "../../hooks/useMessageBodyContent";
41
41
  import { useSaveDraft } from "../../hooks/useSaveDraft";
42
42
  import { useSignature } from "../../hooks/useSignature.js";
43
+ import { softErrorMeta } from "../../lib/error-classifier";
43
44
  import { accountIsMissingSmtp } from "../settings/account-form-helpers.js";
44
45
  import { useErrorBanners } from "../ui/ErrorBannerProvider.js";
45
46
  import {
@@ -212,6 +213,17 @@ type SendReadiness =
212
213
  | { status: "blocked"; reason: string }
213
214
  | { status: "ready"; accountId: string };
214
215
 
216
+ /**
217
+ * Naming To, not "a recipient". Sending goes through the draft, and a draft is
218
+ * created against `CreateOutboxMessageInput`, whose `@minItems(1)` is on
219
+ * `toAddresses` alone — so a message addressed only in Cc has a recipient and
220
+ * still cannot be sent from here, and being told to add one it can already see
221
+ * leaves it with nothing to do. The server's own send guard counts Cc and Bcc,
222
+ * because a Bcc-only envelope is real mail; it is answering whether the message
223
+ * has anywhere to go, which is not the question this one asks.
224
+ */
225
+ const NO_TO_ADDRESS_MESSAGE = "Add a To address before sending.";
226
+
215
227
  const isFormEmpty = (
216
228
  toAddresses: AddressEntry[],
217
229
  ccAddresses: AddressEntry[],
@@ -536,7 +548,7 @@ export const ComposeForm = ({
536
548
  [onDraftCreated],
537
549
  );
538
550
 
539
- const { saveStatus, saveError, saveDraft, saveImmediately, stopAutoSave } =
551
+ const { saveState, saveError, saveDraft, saveImmediately, stopAutoSave } =
540
552
  useSaveDraft({
541
553
  outboxMessageId,
542
554
  onDraftCreated: adoptCreatedDraft,
@@ -555,12 +567,17 @@ export const ComposeForm = ({
555
567
  });
556
568
  }, [saveError, pushError]);
557
569
 
558
- const sendMutation = useMutation(
559
- outboxDetailOperationsSendOutboxMessageMutation(),
560
- );
570
+ // A refused send is reported below, next to the message it did not send, and
571
+ // the composer stays up so the user can fix the address and press it again.
572
+ // A 5xx still escalates.
573
+ const sendMutation = useMutation({
574
+ ...outboxDetailOperationsSendOutboxMessageMutation(),
575
+ meta: softErrorMeta,
576
+ });
561
577
 
562
578
  const deleteMutation = useMutation({
563
579
  ...outboxDetailOperationsDeleteOutboxMessageMutation(),
580
+ meta: softErrorMeta,
564
581
  onError: (error) => {
565
582
  // Discard closes the dialog optimistically. A soft 4xx (409/404 the
566
583
  // draft is already gone) must not pass silently as success — surface a
@@ -621,7 +638,7 @@ export const ComposeForm = ({
621
638
  return { status: "blocked", reason: SMTP_MISSING_MESSAGE };
622
639
  }
623
640
  if (toAddresses.length === 0) {
624
- return { status: "blocked", reason: "Add at least one recipient." };
641
+ return { status: "blocked", reason: NO_TO_ADDRESS_MESSAGE };
625
642
  }
626
643
  return { status: "ready", accountId: selectedAccountId };
627
644
  }, [
@@ -864,7 +881,7 @@ export const ComposeForm = ({
864
881
  onSend={attemptSend}
865
882
  onBlocked={reportBlocked}
866
883
  onDiscard={handleDiscard}
867
- saveStatus={saveStatus}
884
+ save={saveState}
868
885
  />
869
886
  }
870
887
  >
@@ -0,0 +1,357 @@
1
+ /**
2
+ * Forward opens with a subject and a quote and deliberately no recipient. The
3
+ * autosave gate only skips a form that is blank everywhere, so the seeded
4
+ * subject used to fire a create — and a create carries `@minItems(1)` on
5
+ * `toAddresses`, so the server refused it 400. That refusal then went to the
6
+ * full-screen fatal page, which unmounts the composer and takes the message
7
+ * with it: the one failure mode a composer must never have.
8
+ *
9
+ * Both halves are held here. The invalid request is not made at all while the
10
+ * draft has no recipient to create it with — and the composer says so, because
11
+ * a message being held unsaved in silence is the same loss with the noise taken
12
+ * out. A write that does fail lands in a banner beside the message rather than
13
+ * over it. A 5xx and a 401 keep their own rules.
14
+ */
15
+
16
+ import assert from "node:assert/strict";
17
+ import { afterEach, describe, it } from "node:test";
18
+ import type {
19
+ RemitImapAccountResponse,
20
+ RemitImapDescribeMessageResponse,
21
+ } from "@remit/api-http-client/types.gen.ts";
22
+ import { MutationCache, QueryCache, QueryClient } from "@tanstack/react-query";
23
+ import {
24
+ type AnyRouter,
25
+ createMemoryHistory,
26
+ createRootRoute,
27
+ createRoute,
28
+ createRouter,
29
+ RouterContextProvider,
30
+ } from "@tanstack/react-router";
31
+ import { createElement, Fragment, useState } from "react";
32
+ import { __resetFatalError } from "../../lib/fatal-error";
33
+ import {
34
+ handleMutationCacheError,
35
+ handleQueryCacheError,
36
+ } from "../../lib/query-error-handler";
37
+ import { createDomHarness, type DomHarness } from "../../test-support/dom";
38
+ import { type HttpMock, httpError, mockFetch } from "../../test-support/http";
39
+ import { FatalErrorOverlay } from "../ui/FatalErrorOverlay";
40
+ import { ComposeForm } from "./ComposeForm";
41
+ import { ComposeProvider } from "./ComposeProvider";
42
+
43
+ const ACCOUNT_ID = "acc-1";
44
+ const OUTBOX_MESSAGE_ID = "ob-fwd";
45
+ const AUTOSAVE_DEBOUNCE_MS = 2000;
46
+
47
+ const account = {
48
+ accountId: ACCOUNT_ID,
49
+ email: "me@example.com",
50
+ smtpEnabled: true,
51
+ } as unknown as RemitImapAccountResponse;
52
+
53
+ const sourceMessage = {
54
+ message: { messageId: "msg-1" },
55
+ envelope: {
56
+ subject: "Lunch",
57
+ messageIdValue: "<m1@example.com>",
58
+ from: [{ normalizedEmail: "them@example.com", displayName: "Them" }],
59
+ replyTo: [],
60
+ to: [],
61
+ cc: [],
62
+ },
63
+ references: [],
64
+ bodyParts: [],
65
+ } as unknown as RemitImapDescribeMessageResponse;
66
+
67
+ const outboxEntry = () => ({
68
+ outboxMessageId: OUTBOX_MESSAGE_ID,
69
+ accountId: ACCOUNT_ID,
70
+ fromAddress: account.email,
71
+ toAddresses: ["them@example.com"],
72
+ ccAddresses: [],
73
+ bccAddresses: [],
74
+ references: [],
75
+ subject: "Fwd: Lunch",
76
+ textBody: "here you go",
77
+ status: "draft",
78
+ });
79
+
80
+ let harness: DomHarness | undefined;
81
+ let http: HttpMock | undefined;
82
+
83
+ afterEach(() => {
84
+ harness?.close();
85
+ harness = undefined;
86
+ http?.restore();
87
+ http = undefined;
88
+ __resetFatalError();
89
+ });
90
+
91
+ const creates = () =>
92
+ (http?.calls ?? []).filter(
93
+ (call) => call.method === "POST" && call.path.endsWith("/outbox"),
94
+ );
95
+
96
+ const patches = () =>
97
+ (http?.calls ?? []).filter((call) => call.method === "PATCH");
98
+
99
+ const sends = () =>
100
+ (http?.calls ?? []).filter((call) => call.path.endsWith("/send"));
101
+
102
+ const fatalOverlay = () =>
103
+ harness?.query('[data-testid="fatal-error-overlay"]') ?? null;
104
+
105
+ const bannerAlerts = () =>
106
+ harness?.queryAll('[aria-label="Notifications"] [role="alert"]') ?? [];
107
+
108
+ // The draft the composer is on is its owner's to hand it — here, the test's.
109
+ const Opened = ({ outboxMessageId }: { outboxMessageId?: string }) => {
110
+ const [draftId, setDraftId] = useState(outboxMessageId);
111
+
112
+ return createElement(ComposeForm, {
113
+ mode: "forward",
114
+ account,
115
+ sourceMessage,
116
+ outboxMessageId: draftId,
117
+ onDraftCreated: setDraftId,
118
+ onClose: () => {},
119
+ });
120
+ };
121
+
122
+ (globalThis as { self?: typeof globalThis }).self ??= globalThis;
123
+
124
+ const rootRoute = createRootRoute();
125
+ const mailboxRoute = createRoute({
126
+ getParentRoute: () => rootRoute,
127
+ path: "/mail/$mailboxId",
128
+ validateSearch: (search: Record<string, unknown>) => search,
129
+ });
130
+
131
+ const testRouter = (): AnyRouter =>
132
+ createRouter({
133
+ routeTree: rootRoute.addChildren([mailboxRoute]),
134
+ history: createMemoryHistory({ initialEntries: ["/mail/mbx-1"] }),
135
+ }) as unknown as AnyRouter;
136
+
137
+ interface MountOptions {
138
+ /** The draft compose opens on, when the user is resuming one. */
139
+ outboxMessageId?: string;
140
+ /** The status every PATCH answers with, when it is to fail. */
141
+ patchStatus?: number;
142
+ }
143
+
144
+ /**
145
+ * The stub holds the constraint the API holds: a create with no recipient is
146
+ * refused, in the words the backend's schema validation uses.
147
+ */
148
+ const mount = async (options: MountOptions = {}): Promise<void> => {
149
+ http = mockFetch(async (call) => {
150
+ if (call.path.endsWith("/config")) return { accounts: [account] };
151
+
152
+ if (call.method === "POST" && call.path.endsWith("/outbox")) {
153
+ const to = call.body?.toAddresses;
154
+ if (!Array.isArray(to) || to.length === 0) {
155
+ return httpError(
156
+ 400,
157
+ "body/requestBody/toAddresses must NOT have fewer than 1 items",
158
+ );
159
+ }
160
+ return outboxEntry();
161
+ }
162
+
163
+ if (call.method === "PATCH") {
164
+ if (options.patchStatus) {
165
+ return httpError(options.patchStatus, "the draft moved on");
166
+ }
167
+ return outboxEntry();
168
+ }
169
+
170
+ return outboxEntry();
171
+ });
172
+
173
+ const queryClient = new QueryClient({
174
+ queryCache: new QueryCache({ onError: handleQueryCacheError }),
175
+ mutationCache: new MutationCache({ onError: handleMutationCacheError }),
176
+ defaultOptions: {
177
+ queries: { retry: false },
178
+ mutations: { retry: false },
179
+ },
180
+ });
181
+
182
+ harness = createDomHarness({ queryClient });
183
+ harness.renderApp(
184
+ createElement(
185
+ Fragment,
186
+ null,
187
+ createElement(FatalErrorOverlay),
188
+ createElement(RouterContextProvider, {
189
+ router: testRouter(),
190
+ // biome-ignore lint/correctness/noChildrenProp: RouterContextProvider types `children` as a required prop, which createElement's rest-argument form does not satisfy
191
+ children: createElement(
192
+ ComposeProvider,
193
+ null,
194
+ createElement(Opened, { outboxMessageId: options.outboxMessageId }),
195
+ ),
196
+ }),
197
+ ),
198
+ );
199
+ await harness.flush();
200
+ await harness.wait(50);
201
+ };
202
+
203
+ const subjectField = (): HTMLInputElement => {
204
+ const field = harness?.query<HTMLInputElement>("[data-subject-field]");
205
+ if (!field) throw new Error("the compose subject field is not mounted");
206
+ return field;
207
+ };
208
+
209
+ const recipientField = (): HTMLInputElement => {
210
+ const field = harness?.query<HTMLInputElement>("#address-field-To");
211
+ if (!field) throw new Error("the compose recipient field is not mounted");
212
+ return field;
213
+ };
214
+
215
+ const addRecipient = async (email: string): Promise<void> => {
216
+ harness?.type(recipientField(), email);
217
+ harness?.dispatch(
218
+ recipientField(),
219
+ new (
220
+ harness.window as unknown as { KeyboardEvent: typeof KeyboardEvent }
221
+ ).KeyboardEvent("keydown", { key: "Enter", bubbles: true }),
222
+ );
223
+ await harness?.flush();
224
+ };
225
+
226
+ describe("a forwarded message and the draft it cannot create yet", () => {
227
+ it("attempts no create while the forward has no recipient", async () => {
228
+ await mount();
229
+
230
+ assert.equal(
231
+ subjectField().value,
232
+ "Fwd: Lunch",
233
+ "the forward seeded its subject, so the form is not blank",
234
+ );
235
+
236
+ await harness?.wait(AUTOSAVE_DEBOUNCE_MS + 300);
237
+
238
+ assert.equal(creates().length, 0, "no create was attempted");
239
+ assert.equal(fatalOverlay(), null, "the composer stayed on screen");
240
+ assert.equal(bannerAlerts().length, 0, "nothing was raised as a failure");
241
+ });
242
+
243
+ it("says the draft is not being saved, and what would make it save", async () => {
244
+ await mount();
245
+
246
+ harness?.type(subjectField(), "Fwd: Lunch and the three paragraphs after");
247
+ await harness?.flush();
248
+
249
+ const indicator = harness?.byText("output", "Not saved");
250
+ assert.match(
251
+ indicator?.textContent ?? "",
252
+ /add a To address/i,
253
+ "the composer names the field the create schema actually requires",
254
+ );
255
+
256
+ await addRecipient("them@example.com");
257
+ await harness?.wait(AUTOSAVE_DEBOUNCE_MS + 300);
258
+
259
+ assert.equal(creates().length, 1, "the held content was written");
260
+ assert.equal(
261
+ creates()[0]?.body?.subject,
262
+ "Fwd: Lunch and the three paragraphs after",
263
+ "including everything typed while it was held",
264
+ );
265
+ assert.match(harness?.text() ?? "", /Draft saved/);
266
+ });
267
+
268
+ it("creates the draft, with everything the forward seeded, once a recipient is there", async () => {
269
+ await mount();
270
+ await harness?.wait(AUTOSAVE_DEBOUNCE_MS + 300);
271
+
272
+ await addRecipient("them@example.com");
273
+ await harness?.wait(AUTOSAVE_DEBOUNCE_MS + 300);
274
+
275
+ const created = creates();
276
+ assert.equal(created.length, 1, "the draft was created once");
277
+ assert.deepEqual(created[0]?.body?.toAddresses, ["them@example.com"]);
278
+ assert.equal(created[0]?.body?.subject, "Fwd: Lunch");
279
+ assert.equal(fatalOverlay(), null, "the composer stayed on screen");
280
+ });
281
+
282
+ it("refuses to send a forward that has no recipient, and says why", async () => {
283
+ await mount();
284
+
285
+ const send = harness?.byText("button", "Send");
286
+ if (!send) throw new Error("the send button is not mounted");
287
+ harness?.click(send);
288
+ await harness?.flush();
289
+ await harness?.wait(100);
290
+
291
+ assert.match(harness?.text() ?? "", /Add a To address before sending/);
292
+ assert.equal(creates().length, 0, "nothing was created");
293
+ assert.equal(sends().length, 0, "nothing was sent");
294
+ });
295
+
296
+ it("drops the held sentence the moment the To address arrives", async () => {
297
+ await mount();
298
+
299
+ harness?.type(subjectField(), "Fwd: Lunch and the three paragraphs after");
300
+ await harness?.flush();
301
+ assert.match(harness?.text() ?? "", /Not saved/);
302
+
303
+ await addRecipient("them@example.com");
304
+
305
+ // Before the debounce, not after it: the sentence stopped being true the
306
+ // moment the address landed, and standing for another two seconds tells
307
+ // the user their draft is being dropped when it is on its way.
308
+ assert.doesNotMatch(
309
+ harness?.text() ?? "",
310
+ /Not saved/,
311
+ "a reason that no longer holds is off screen at once",
312
+ );
313
+ });
314
+ });
315
+
316
+ describe("a failed autosave and the message it is holding", () => {
317
+ it("banners a refused write and leaves the composer and its text alone", async () => {
318
+ await mount({ outboxMessageId: OUTBOX_MESSAGE_ID, patchStatus: 409 });
319
+
320
+ harness?.type(subjectField(), "Fwd: Lunch on Thursday");
321
+ await harness?.wait(AUTOSAVE_DEBOUNCE_MS + 300);
322
+
323
+ assert.ok(patches().length > 0, "the write was attempted");
324
+ assert.equal(
325
+ fatalOverlay(),
326
+ null,
327
+ "a refused autosave must not take the composer down",
328
+ );
329
+ assert.match(harness?.text() ?? "", /Couldn't save draft/);
330
+ assert.equal(
331
+ subjectField().value,
332
+ "Fwd: Lunch on Thursday",
333
+ "what was written is still on screen",
334
+ );
335
+ });
336
+
337
+ it("escalates a 401 — a dismissible banner is no way back in", async () => {
338
+ await mount({ outboxMessageId: OUTBOX_MESSAGE_ID, patchStatus: 401 });
339
+
340
+ harness?.type(subjectField(), "Fwd: Lunch on Thursday");
341
+ await harness?.wait(AUTOSAVE_DEBOUNCE_MS + 300);
342
+
343
+ assert.ok(
344
+ fatalOverlay(),
345
+ "a signed-out session must reach the page that signs back in",
346
+ );
347
+ });
348
+
349
+ it("still escalates a 5xx from the same write", async () => {
350
+ await mount({ outboxMessageId: OUTBOX_MESSAGE_ID, patchStatus: 500 });
351
+
352
+ harness?.type(subjectField(), "Fwd: Lunch on Thursday");
353
+ await harness?.wait(AUTOSAVE_DEBOUNCE_MS + 300);
354
+
355
+ assert.ok(fatalOverlay(), "our API answering 'I'm broken' is never soft");
356
+ });
357
+ });
@@ -6,10 +6,10 @@ import { ErrorBanner } from "./ErrorBanner";
6
6
  * message list, so it is opaque and it names its own severity out loud rather
7
7
  * than leaving colour to carry the meaning.
8
8
  *
9
- * A failure the user cannot act on still gets an action link, prefilled, so
10
- * reporting it is one click instead of a form they have to assemble. The
11
- * hrefs below stand in for the real report URL, which the app builds from
12
- * build-time constants Storybook has no `define` for.
9
+ * Every error banner carries a prefilled report link, so reporting a failure
10
+ * the user cannot act on is one click instead of a form they have to
11
+ * assemble. The hrefs below stand in for the real report URL, which the app
12
+ * builds from build-time constants Storybook has no `define` for.
13
13
  */
14
14
  const meta: Meta<typeof ErrorBanner> = {
15
15
  title: "Components/ErrorBanner",
@@ -27,21 +27,38 @@ type Story = StoryObj<typeof ErrorBanner>;
27
27
  const REPORT_URL =
28
28
  "https://github.com/remit-mail/reader/issues/new?title=Spellcheck+stopped";
29
29
 
30
- /** A mutation that failed, with the reason underneath. */
30
+ /** A mutation that failed, with the reason underneath and a way out. */
31
31
  export const Failed: Story = {
32
32
  name: "Error",
33
33
  args: {
34
34
  severity: "error",
35
35
  title: "Couldn't move message",
36
36
  detail: "Connection reset by peer",
37
+ action: { label: "Report an issue", href: REPORT_URL },
37
38
  },
38
39
  };
39
40
 
40
- /** Nothing more to say than the title. */
41
+ /** Nothing more to say than the title — the report link still stands. */
41
42
  export const NoDetail: Story = {
42
43
  args: {
43
44
  severity: "error",
44
45
  title: "Couldn't move message",
46
+ action: { label: "Report an issue", href: REPORT_URL },
47
+ },
48
+ };
49
+
50
+ /**
51
+ * Report spam failing because the account has nowhere to file it. The reason
52
+ * names the folder and the fix, so this one is actionable without the report
53
+ * link — which is offered anyway, because the user should not have to decide.
54
+ */
55
+ export const NoJunkFolder: Story = {
56
+ args: {
57
+ severity: "error",
58
+ title: "Couldn't report this message as spam",
59
+ detail:
60
+ "This account has no Junk folder. Create one named Junk or Spam in your mail provider, then report this message again.",
61
+ action: { label: "Report an issue", href: REPORT_URL },
45
62
  },
46
63
  };
47
64
 
@@ -6,6 +6,7 @@ import {
6
6
  useMemo,
7
7
  useState,
8
8
  } from "react";
9
+ import { buildBugReportContext, buildGitHubIssueUrl } from "@/lib/bug-report";
9
10
  import { isAlwaysFatal } from "@/lib/error-classifier";
10
11
  import { reportFatalError } from "@/lib/fatal-error";
11
12
  import { ErrorBannerStack } from "./ErrorBannerStack.js";
@@ -13,6 +14,7 @@ import {
13
14
  appendBanner,
14
15
  buildEntry,
15
16
  dismissBanner,
17
+ type ErrorBannerAction,
16
18
  type ErrorBannerEntry,
17
19
  type PushErrorInput,
18
20
  } from "./error-banners.js";
@@ -28,6 +30,37 @@ const ErrorBannerContext = createContext<ErrorBannerContextValue | undefined>(
28
30
  undefined,
29
31
  );
30
32
 
33
+ /**
34
+ * A way out of every failure banner. A soft failure is still a failure the
35
+ * user could not prevent and cannot diagnose, and a banner offering only
36
+ * "Dismiss" is a dead end — the fatal screen has carried a prefilled report
37
+ * since issue #55 and the soft surface never did. The report is seeded from
38
+ * the banner it belongs to, so it names the failure instead of asking the
39
+ * user to describe it, and it carries the same breadcrumbs — failing request,
40
+ * navigation, console — the fatal report does.
41
+ *
42
+ * A caller that already has a better way out keeps it, and a warning or an
43
+ * informational banner gets none: those are not failures to report.
44
+ */
45
+ export const bannerWayOut = (
46
+ input: PushErrorInput,
47
+ ): ErrorBannerAction | undefined => {
48
+ if (input.action) return input.action;
49
+ if ((input.severity ?? "error") !== "error") return undefined;
50
+ return {
51
+ label: "Report an issue",
52
+ href: buildGitHubIssueUrl(
53
+ buildBugReportContext({
54
+ title: `Bug: ${input.title}`,
55
+ errorMessage: input.detail
56
+ ? `${input.title} — ${input.detail}`
57
+ : input.title,
58
+ stack: input.error instanceof Error ? input.error.stack : undefined,
59
+ }),
60
+ ),
61
+ };
62
+ };
63
+
31
64
  const generateId = (): string => {
32
65
  if (
33
66
  typeof globalThis.crypto !== "undefined" &&
@@ -58,7 +91,11 @@ export const ErrorBannerProvider = ({ children }: { children: ReactNode }) => {
58
91
  if (input.error != null && isAlwaysFatal(input.error)) {
59
92
  return reportFatalError(input.error).correlationId;
60
93
  }
61
- const entry = buildEntry(input, generateId(), Date.now());
94
+ const entry = buildEntry(
95
+ { ...input, action: bannerWayOut(input) },
96
+ generateId(),
97
+ Date.now(),
98
+ );
62
99
  setErrors((current) => appendBanner(current, entry));
63
100
  return entry.id;
64
101
  }, []);
@@ -0,0 +1,71 @@
1
+ import assert from "node:assert/strict";
2
+ import { describe, it } from "node:test";
3
+ import { ApiError } from "@/lib/api";
4
+ import { bannerWayOut } from "./ErrorBannerProvider";
5
+
6
+ const issueBody = (href: string): string =>
7
+ new URL(href).searchParams.get("body") ?? "";
8
+
9
+ const issueTitle = (href: string): string =>
10
+ new URL(href).searchParams.get("title") ?? "";
11
+
12
+ describe("bannerWayOut — no failure banner is a dead end", () => {
13
+ it("offers a prefilled report on a failure the user cannot act on", () => {
14
+ // The spam report that failed on test.remit.email offered nothing but
15
+ // Dismiss: no reason, no fix, no way to report it.
16
+ const action = bannerWayOut({
17
+ title: "Couldn't report this message as spam",
18
+ detail: "This message could not be processed.",
19
+ error: new ApiError("This message could not be processed.", 422),
20
+ });
21
+
22
+ assert.equal(action?.label, "Report an issue");
23
+ assert.ok(
24
+ action?.href.startsWith(
25
+ "https://github.com/remit-mail/reader/issues/new?",
26
+ ),
27
+ );
28
+ assert.match(
29
+ issueTitle(action.href),
30
+ /Couldn't report this message as spam/,
31
+ );
32
+ assert.match(
33
+ issueBody(action.href),
34
+ /Couldn't report this message as spam/,
35
+ );
36
+ });
37
+
38
+ it("carries the stack of the error that caused the banner", () => {
39
+ const error = new Error("Connection reset by peer");
40
+ const action = bannerWayOut({
41
+ title: "Couldn't move this message",
42
+ error,
43
+ });
44
+
45
+ assert.ok(action !== undefined);
46
+ assert.match(issueBody(action.href), /Stacktrace/);
47
+ });
48
+
49
+ it("keeps an action the call site already chose", () => {
50
+ const action = bannerWayOut({
51
+ title: "Spellcheck stopped",
52
+ action: { label: "Report this", href: "https://example.invalid/report" },
53
+ });
54
+
55
+ assert.deepEqual(action, {
56
+ label: "Report this",
57
+ href: "https://example.invalid/report",
58
+ });
59
+ });
60
+
61
+ it("offers none on a warning or a statement of fact", () => {
62
+ assert.equal(
63
+ bannerWayOut({ severity: "warning", title: "Draft saved locally" }),
64
+ undefined,
65
+ );
66
+ assert.equal(
67
+ bannerWayOut({ severity: "info", title: "Sync finished" }),
68
+ undefined,
69
+ );
70
+ });
71
+ });
@@ -0,0 +1,126 @@
1
+ /**
2
+ * A 401 escalates over `meta.softError` only when someone is waiting on the
3
+ * answer. The self-update poll is the case that made that seam necessary: it is
4
+ * mounted at the app root and answers 401 by design for anyone whose session has
5
+ * lapsed, so escalating it put the full-screen fatal page over every screen in
6
+ * the app at load — including the sign-in the shell was already about to ask
7
+ * for.
8
+ *
9
+ * Held with the real caches wired to `lib/query-error-handler.ts`, because the
10
+ * seam is in what those two handlers pass and a classifier unit test cannot see
11
+ * a caller that never passed anything.
12
+ */
13
+
14
+ import assert from "node:assert/strict";
15
+ import { afterEach, describe, it } from "node:test";
16
+ import {
17
+ MutationCache,
18
+ QueryCache,
19
+ QueryClient,
20
+ useMutation,
21
+ } from "@tanstack/react-query";
22
+ import { createElement, Fragment, useEffect } from "react";
23
+ import { FatalErrorOverlay } from "../components/ui/FatalErrorOverlay";
24
+ import { ApiError } from "../lib/api";
25
+ import { softErrorMeta } from "../lib/error-classifier";
26
+ import { __resetFatalError } from "../lib/fatal-error";
27
+ import {
28
+ handleMutationCacheError,
29
+ handleQueryCacheError,
30
+ } from "../lib/query-error-handler";
31
+ import { createDomHarness, type DomHarness } from "../test-support/dom";
32
+ import { type HttpMock, httpError, mockFetch } from "../test-support/http";
33
+ import { SelfUpdateProvider } from "./use-system-update";
34
+
35
+ let harness: DomHarness | undefined;
36
+ let http: HttpMock | undefined;
37
+
38
+ afterEach(() => {
39
+ harness?.close();
40
+ harness = undefined;
41
+ http?.restore();
42
+ http = undefined;
43
+ __resetFatalError();
44
+ });
45
+
46
+ const escalatingClient = (): QueryClient =>
47
+ new QueryClient({
48
+ queryCache: new QueryCache({ onError: handleQueryCacheError }),
49
+ mutationCache: new MutationCache({ onError: handleMutationCacheError }),
50
+ defaultOptions: {
51
+ queries: { retry: false },
52
+ mutations: { retry: false },
53
+ },
54
+ });
55
+
56
+ const fatalOverlay = () =>
57
+ harness?.query('[data-testid="fatal-error-overlay"]') ?? null;
58
+
59
+ /** A write nobody pressed a button for, refused for want of a session. */
60
+ const SoftWrite = () => {
61
+ const mutation = useMutation({
62
+ mutationFn: async (): Promise<void> => {
63
+ throw new ApiError("signed out", 401);
64
+ },
65
+ meta: softErrorMeta,
66
+ });
67
+ const { mutate } = mutation;
68
+ useEffect(() => {
69
+ mutate();
70
+ }, [mutate]);
71
+ return null;
72
+ };
73
+
74
+ describe("a 401 and who was waiting on it", () => {
75
+ it("leaves the app standing when the root update poll is signed out", async () => {
76
+ http = mockFetch((call) => {
77
+ if (call.path.endsWith("/system/update")) {
78
+ return httpError(401, "session expired");
79
+ }
80
+ return {};
81
+ });
82
+
83
+ harness = createDomHarness({ queryClient: escalatingClient() });
84
+ harness.renderApp(
85
+ createElement(
86
+ Fragment,
87
+ null,
88
+ createElement(FatalErrorOverlay),
89
+ createElement(SelfUpdateProvider, null),
90
+ ),
91
+ );
92
+ await harness.flush();
93
+ await harness.wait(50);
94
+ await harness.flush();
95
+
96
+ assert.ok(
97
+ http.to("/system/update").length > 0,
98
+ "the poll was made, so the 401 really was classified",
99
+ );
100
+ assert.equal(
101
+ fatalOverlay(),
102
+ null,
103
+ "a background poll's 401 must not take the whole app down",
104
+ );
105
+ });
106
+
107
+ it("still escalates a soft write's 401 — no banner signs anyone back in", async () => {
108
+ harness = createDomHarness({ queryClient: escalatingClient() });
109
+ harness.renderApp(
110
+ createElement(
111
+ Fragment,
112
+ null,
113
+ createElement(FatalErrorOverlay),
114
+ createElement(SoftWrite),
115
+ ),
116
+ );
117
+ await harness.flush();
118
+ await harness.wait(50);
119
+ await harness.flush();
120
+
121
+ assert.ok(
122
+ fatalOverlay(),
123
+ "a signed-out session must reach the page that signs back in",
124
+ );
125
+ });
126
+ });
@@ -9,6 +9,7 @@ import { useCallback, useEffect, useMemo, useRef } from "react";
9
9
  import { useErrorBanners } from "@/components/ui/ErrorBannerProvider";
10
10
  import { formatErrorDetail } from "@/components/ui/error-banners";
11
11
  import { runChunkedMutation } from "@/lib/bulk-actions";
12
+ import { softErrorMeta } from "@/lib/error-classifier";
12
13
  import {
13
14
  cancelThreadListQueries,
14
15
  invalidateThreadListQueries,
@@ -138,8 +139,12 @@ export const useMarkAsRead = ({
138
139
  const markedAsReadRef = useRef<Set<string>>(new Set());
139
140
  const pendingRef = useRef<Set<string>>(new Set());
140
141
 
142
+ // Nobody asked for this write and nobody is waiting on it — it fires after a
143
+ // dwell on an open message. The rollback and the banner below are the whole
144
+ // of its failure, so a refusal must not take the screen down with it.
141
145
  const { mutate: markAsRead } = useMutation({
142
146
  ...messageBulkOperationsUpdateFlagsMutation(),
147
+ meta: softErrorMeta,
143
148
  onMutate: async (variables): Promise<MarkAsReadContext> => {
144
149
  const messageIds = new Set(variables.body.messageIds ?? []);
145
150
  const isRead = variables.body.isRead ?? true;
@@ -32,8 +32,13 @@ const messageFor = (error: unknown): string =>
32
32
  ? error.message
33
33
  : "Something went wrong";
34
34
 
35
+ // The user pulled to refresh and is watching the spinner, so this is theirs to
36
+ // be answered: a 401 here escalates over the soft meta rather than resolving
37
+ // into a spinner that stops for no stated reason.
35
38
  const escalateIfFatal = (error: unknown): void => {
36
- if (shouldEscalate(error, { softError: true })) reportFatalError(error);
39
+ if (shouldEscalate(error, { softError: true }, "user")) {
40
+ reportFatalError(error);
41
+ }
37
42
  };
38
43
 
39
44
  const maxLastSynced = (
@@ -51,7 +51,7 @@ interface UseReportSpamOptions {
51
51
  * despite a non-zero `failureCount`.
52
52
  */
53
53
  export const GENERIC_SPAM_ACTION_FAILURE =
54
- "This message could not be processed. Please try again.";
54
+ "This message could not be processed. Please try again. If it keeps failing, report it.";
55
55
 
56
56
  /**
57
57
  * The server's designed failure text names the message by embedding its raw
@@ -3,10 +3,10 @@ import {
3
3
  outboxOperationsCreateOutboxMessageMutation,
4
4
  outboxOperationsListOutboxMessagesOptions,
5
5
  } from "@remit/api-http-client/@tanstack/react-query.gen.ts";
6
+ import type { ComposeSaveState } from "@remit/ui";
6
7
  import { useMutation, useQueryClient } from "@tanstack/react-query";
7
8
  import { useCallback, useRef, useState } from "react";
8
-
9
- export type SaveStatus = "idle" | "saving" | "saved" | "error";
9
+ import { softErrorMeta } from "../lib/error-classifier";
10
10
 
11
11
  export type ImmediateSave =
12
12
  | { outcome: "saved"; outboxMessageId: string }
@@ -29,6 +29,44 @@ interface UseSaveDraftOptions {
29
29
  onDraftCreated: (id: string) => void;
30
30
  }
31
31
 
32
+ /**
33
+ * A draft with no To address yet has nothing the create endpoint will accept —
34
+ * `CreateOutboxMessageInput.toAddresses` carries `@minItems(1)`, so the request
35
+ * comes back 400. Cc and Bcc do not stand in for it; the constraint names
36
+ * `toAddresses` and nothing else. Forward opens in exactly that state, with a
37
+ * subject and a quote and no address, and it is a normal place to be while
38
+ * writing rather than a failure to report. The update endpoint has no such
39
+ * constraint, so only a draft that does not exist yet is held back.
40
+ *
41
+ * The send guard in `outbox-queue.ts` counts Cc and Bcc, and is right to: a
42
+ * Bcc-only envelope is real mail. It answers a different question — whether
43
+ * this message has anywhere to go — from this one, which is only whether the
44
+ * create schema will take it.
45
+ */
46
+ const nothingToCreateYet = (
47
+ targetId: string | undefined,
48
+ data: DraftData,
49
+ ): boolean => targetId === undefined && data.toAddresses.length === 0;
50
+
51
+ /**
52
+ * Held back, and naming what is actually missing. "A recipient" was a lie to
53
+ * anyone who had filled in Cc: they had one, and were being told to add what
54
+ * they could see on screen.
55
+ *
56
+ * Module scope, not a literal built in the render: this is set from inside the
57
+ * autosave effect, and a fresh object each time would be a new state on every
58
+ * render with the effect re-running on each of them.
59
+ */
60
+ const NOT_SAVED_WITHOUT_A_TO_ADDRESS: ComposeSaveState = {
61
+ status: "unsaved",
62
+ reason: "Not saved — add a To address to keep this draft.",
63
+ };
64
+
65
+ const IDLE: ComposeSaveState = { status: "idle" };
66
+ const SAVING: ComposeSaveState = { status: "saving" };
67
+ const SAVED: ComposeSaveState = { status: "saved" };
68
+ const SAVE_FAILED: ComposeSaveState = { status: "error" };
69
+
32
70
  const settled = (promise: Promise<unknown>): Promise<void> =>
33
71
  promise.then(
34
72
  () => undefined,
@@ -39,7 +77,7 @@ export const useSaveDraft = ({
39
77
  outboxMessageId,
40
78
  onDraftCreated,
41
79
  }: UseSaveDraftOptions) => {
42
- const [saveStatus, setSaveStatus] = useState<SaveStatus>("idle");
80
+ const [saveState, setSaveState] = useState<ComposeSaveState>(IDLE);
43
81
  const [saveError, setSaveError] = useState<unknown>(null);
44
82
  const timerRef = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
45
83
  const closedIdsRef = useRef<Set<string>>(new Set());
@@ -67,16 +105,21 @@ export const useSaveDraft = ({
67
105
  if (leavingADocument && timerRef.current) clearTimeout(timerRef.current);
68
106
  }
69
107
 
70
- const createMutation = useMutation(
71
- outboxOperationsCreateOutboxMessageMutation(),
72
- );
73
- const updateMutation = useMutation(
74
- outboxDetailOperationsUpdateOutboxMessageMutation(),
75
- );
108
+ // A write that fails belongs in the composer's banner beside the message it
109
+ // could not save, never on the full-screen page that unmounts the composer
110
+ // and the message with it. A 5xx still escalates.
111
+ const createMutation = useMutation({
112
+ ...outboxOperationsCreateOutboxMessageMutation(),
113
+ meta: softErrorMeta,
114
+ });
115
+ const updateMutation = useMutation({
116
+ ...outboxDetailOperationsUpdateOutboxMessageMutation(),
117
+ meta: softErrorMeta,
118
+ });
76
119
 
77
120
  const executeSave = useCallback(
78
121
  async (data: DraftData) => {
79
- setSaveStatus("saving");
122
+ setSaveState(SAVING);
80
123
  setSaveError(null);
81
124
 
82
125
  const targetId = targetIdRef.current;
@@ -94,7 +137,7 @@ export const useSaveDraft = ({
94
137
  references: data.references,
95
138
  },
96
139
  });
97
- setSaveStatus("saved");
140
+ setSaveState(SAVED);
98
141
  return result;
99
142
  }
100
143
 
@@ -106,13 +149,18 @@ export const useSaveDraft = ({
106
149
  });
107
150
  targetIdRef.current = result.outboxMessageId;
108
151
  onDraftCreated(result.outboxMessageId);
109
- setSaveStatus("saved");
152
+ setSaveState(SAVED);
110
153
  queryClient.invalidateQueries({
111
154
  queryKey: outboxOperationsListOutboxMessagesOptions().queryKey,
112
155
  });
113
156
  return result;
114
157
  },
115
- [createMutation, updateMutation, onDraftCreated, queryClient],
158
+ [
159
+ createMutation.mutateAsync,
160
+ updateMutation.mutateAsync,
161
+ onDraftCreated,
162
+ queryClient,
163
+ ],
116
164
  );
117
165
 
118
166
  // One entry takes one write at a time. Overlapping writes settle in whatever
@@ -131,6 +179,20 @@ export const useSaveDraft = ({
131
179
  const saveDraft = useCallback(
132
180
  (data: DraftData) => {
133
181
  if (timerRef.current) clearTimeout(timerRef.current);
182
+ // Said now rather than two seconds from now: the composer is holding
183
+ // text nothing is going to persist, and the moment it starts holding it
184
+ // is the moment the user has to be able to see that.
185
+ if (nothingToCreateYet(targetIdRef.current, data)) {
186
+ setSaveState(NOT_SAVED_WITHOUT_A_TO_ADDRESS);
187
+ return;
188
+ }
189
+ // The sentence goes the moment its reason does, rather than standing
190
+ // for the two seconds until the write it is no longer true about
191
+ // lands. Only that sentence is cleared: a "Draft saved" from the
192
+ // previous write is still the truth about this document.
193
+ setSaveState((current) =>
194
+ current.status === "unsaved" ? IDLE : current,
195
+ );
134
196
  timerRef.current = setTimeout(() => {
135
197
  const targetId = targetIdRef.current;
136
198
  if (targetId && closedIdsRef.current.has(targetId)) return;
@@ -139,7 +201,7 @@ export const useSaveDraft = ({
139
201
  // through the global MutationCache.onError sink.
140
202
  enqueueSave(data).catch((error: unknown) => {
141
203
  setSaveError(error);
142
- setSaveStatus("error");
204
+ setSaveState(SAVE_FAILED);
143
205
  });
144
206
  }, 2000);
145
207
  },
@@ -161,7 +223,7 @@ export const useSaveDraft = ({
161
223
  }),
162
224
  )
163
225
  .catch((error: unknown): ImmediateSave => {
164
- setSaveStatus("error");
226
+ setSaveState(SAVE_FAILED);
165
227
  return { outcome: "failed", error };
166
228
  });
167
229
  },
@@ -178,5 +240,5 @@ export const useSaveDraft = ({
178
240
  if (closedOutboxMessageId) closedIdsRef.current.add(closedOutboxMessageId);
179
241
  }, []);
180
242
 
181
- return { saveStatus, saveError, saveDraft, saveImmediately, stopAutoSave };
243
+ return { saveState, saveError, saveDraft, saveImmediately, stopAutoSave };
182
244
  };
@@ -221,6 +221,21 @@ describe("handleBackgroundSyncFailure", () => {
221
221
  assert.equal(escalated, false);
222
222
  });
223
223
 
224
+ test("a 401 on the background probe does NOT escalate", () => {
225
+ silenceWarn();
226
+ let escalated = false;
227
+ subscribeFatalError(() => {
228
+ escalated = true;
229
+ });
230
+
231
+ // The probe fires on mount and nobody is waiting on it. Escalating its
232
+ // 401 would put the full-screen page up on load for a lapsed session,
233
+ // ahead of the sign-in the shell is already about to ask for.
234
+ handleBackgroundSyncFailure("a-1", new ApiError("signed out", 401));
235
+
236
+ assert.equal(escalated, false);
237
+ });
238
+
224
239
  test("a network blip on the background probe does NOT escalate", () => {
225
240
  silenceWarn();
226
241
  let escalated = false;
@@ -140,13 +140,17 @@ export const __peekStaleAccountSyncGuard = (): ReadonlySet<string> =>
140
140
  * per-account guard so a later remount can retry, then log and move on. But a
141
141
  * 5xx is OUR API broken, and per the contract (#1059) that always escalates to
142
142
  * the full-screen overlay — even from a background trigger.
143
+ *
144
+ * Nobody is waiting on this one. It fires on mount, so escalating its 401 would
145
+ * put the full-screen page up on load for anyone whose session had lapsed,
146
+ * ahead of the sign-in the shell is already about to ask for.
143
147
  */
144
148
  export const handleBackgroundSyncFailure = (
145
149
  accountId: string,
146
150
  error: unknown,
147
151
  ): void => {
148
152
  triggeredAccountIds.delete(accountId);
149
- if (shouldEscalate(error, { softError: true })) {
153
+ if (shouldEscalate(error, { softError: true }, "nobody")) {
150
154
  reportFatalError(error);
151
155
  return;
152
156
  }
@@ -2,10 +2,18 @@
2
2
  * Build-time constants injected by vite.config.ts via `define`.
3
3
  * __APP_SHA__ is the full git SHA (or "dev" in local builds without git).
4
4
  * __APP_BUILD_TIME__ is an ISO timestamp.
5
+ *
6
+ * Read through `typeof` because not every host that renders this app's
7
+ * components applies the `define` — Storybook's test runner mounts them
8
+ * without it — and a bare reference is a ReferenceError at module scope that
9
+ * takes the importing story down with it. An unknown build is the same
10
+ * "dev" the git-less local build already resolves to.
5
11
  */
6
12
 
7
- export const APP_SHA: string = __APP_SHA__;
8
- export const APP_BUILD_TIME: string = __APP_BUILD_TIME__;
13
+ export const APP_SHA: string =
14
+ typeof __APP_SHA__ === "undefined" ? "dev" : __APP_SHA__;
15
+ export const APP_BUILD_TIME: string =
16
+ typeof __APP_BUILD_TIME__ === "undefined" ? "unknown" : __APP_BUILD_TIME__;
9
17
 
10
18
  /** First 7 characters of the SHA, matching git's default short form. */
11
19
  export const APP_SHORT_SHA: string = APP_SHA.slice(0, 7);
@@ -278,4 +278,50 @@ describe("shouldEscalate (the fail-fast decision table — #1059)", () => {
278
278
  assert.equal(shouldEscalate(bug), true);
279
279
  assert.equal(shouldEscalate(bug, { softError: true }), true);
280
280
  });
281
+
282
+ it("escalates a 401 on a write EVEN when the call site marked it soft (rule 4 wins)", () => {
283
+ assert.equal(
284
+ shouldEscalate(
285
+ new ApiError("signed out", 401),
286
+ { softError: true },
287
+ "user",
288
+ ),
289
+ true,
290
+ "a dismissible banner leaves the user signed out with no way back in",
291
+ );
292
+ });
293
+
294
+ it("leaves a soft 401 soft when nobody is waiting on the answer", () => {
295
+ assert.equal(
296
+ shouldEscalate(
297
+ new ApiError("signed out", 401),
298
+ { softError: true },
299
+ "nobody",
300
+ ),
301
+ false,
302
+ "a background poll's 401 must not put the fatal page over the whole app",
303
+ );
304
+ });
305
+
306
+ it("defaults to nobody waiting, so an unstated call site keeps a soft 401 soft", () => {
307
+ assert.equal(
308
+ shouldEscalate(new ApiError("signed out", 401), { softError: true }),
309
+ false,
310
+ );
311
+ });
312
+
313
+ it("escalates a 401 on a read that never opted out", () => {
314
+ assert.equal(
315
+ shouldEscalate(new ApiError("signed out", 401), undefined, "nobody"),
316
+ true,
317
+ "a read with no softError escalates on the default, waiting or not",
318
+ );
319
+ });
320
+
321
+ it("leaves a soft 403 soft — the call site can state that refusal in place", () => {
322
+ assert.equal(
323
+ shouldEscalate(new ApiError("not yours", 403), { softError: true }),
324
+ false,
325
+ );
326
+ });
281
327
  });
@@ -101,6 +101,37 @@ export const isClientBug = (error: unknown): boolean =>
101
101
  export const isAlwaysFatal = (error: unknown): boolean =>
102
102
  isServerError(error) || isClientBug(error);
103
103
 
104
+ /**
105
+ * The session is gone. Not 403: a handler answers 403 for a resource belonging
106
+ * to another account config, which is a refusal a call site can state where it
107
+ * stands. A 401 is the user signed out from under whatever they were doing.
108
+ */
109
+ export const isUnauthenticated = (error: unknown): boolean =>
110
+ getErrorStatus(error) === 401;
111
+
112
+ /**
113
+ * Who is waiting on this request's answer. One decision turns on it, and only
114
+ * one: whether a 401 overrides the call site's own `meta.softError`.
115
+ *
116
+ * "user" — the user did something and the app owes them the outcome. Every
117
+ * mutation is this, the debounced autosave included: it carries text that is on
118
+ * screen, and if it is refused for want of a session then so is the send behind
119
+ * it. No banner a call site can render signs anyone back in, so this is the one
120
+ * 4xx a call site may not keep to itself.
121
+ *
122
+ * "nobody" — a poll, a prefetch, a best-effort background trigger, an inline
123
+ * sub-resource with an error surface of its own. A 401 there is not news the
124
+ * app may take the whole screen for, and `meta.softError` decides as usual.
125
+ *
126
+ * Reads are "nobody" as a class, which is not the same as saying no read
127
+ * matters: a read the screen is actually waiting on has no `meta.softError` on
128
+ * it, so rule 1 escalates it anyway. The only reads this spares are the ones
129
+ * that already declared they own their failures — the update poll mounted at
130
+ * the app root, the message body with its own inline banner. Escalating those
131
+ * put the full-screen page over every screen in the app.
132
+ */
133
+ export type Awaiting = "user" | "nobody";
134
+
104
135
  const isSoftErrorMeta = (meta: Record<string, unknown> | undefined): boolean =>
105
136
  meta?.softError === true;
106
137
 
@@ -115,7 +146,12 @@ const isSoftErrorMeta = (meta: Record<string, unknown> | undefined): boolean =>
115
146
  * answered "I'm broken"; that is never benign.
116
147
  * 3. A client-side exception ALWAYS escalates, on the same terms. It is our
117
148
  * bug; there is nothing for the user to retry and nothing to dismiss.
118
- * 4. The ONLY soft (do-NOT-escalate) exemptions:
149
+ * 4. A 401 on a request the user is waiting on ALWAYS escalates, on the same
150
+ * terms. Dismissing it leaves them signed out with no way back in, and a
151
+ * send that keeps failing becomes a loop with no exit — `BetterAuthShell`
152
+ * re-gates only when `useSession()` revalidates, which a banner never makes
153
+ * happen. See `Awaiting` for what nobody waiting on it means.
154
+ * 5. The ONLY soft (do-NOT-escalate) exemptions:
119
155
  * a. aborts / cancellations — never a failure;
120
156
  * b. network/offline errors — environmental, recovered by React Query's
121
157
  * reconnect/retry;
@@ -126,11 +162,29 @@ const isSoftErrorMeta = (meta: Record<string, unknown> | undefined): boolean =>
126
162
  export const shouldEscalate = (
127
163
  error: unknown,
128
164
  meta?: Record<string, unknown>,
165
+ awaiting: Awaiting = "nobody",
129
166
  ): boolean => {
130
167
  if (isServerError(error)) return true;
131
168
  if (isAbortError(error)) return false;
132
169
  if (isNetworkError(error)) return false;
170
+ if (awaiting === "user" && isUnauthenticated(error)) return true;
133
171
  if (isAlwaysFatal(error)) return true;
134
172
  if (isSoftErrorMeta(meta)) return false;
135
173
  return true;
136
174
  };
175
+
176
+ /**
177
+ * The meta a call site sets to keep its own non-5xx failures off the
178
+ * full-screen fatal page, because it renders them itself — a banner, a retry,
179
+ * an empty state. Rules 2, 3 and 4 above still win: a 5xx, a client-side
180
+ * exception, and a 401 on something the user is waiting on, escalate regardless.
181
+ *
182
+ * Two classes of call site must always carry it. One is a request the user
183
+ * never asked for and is not waiting on — a debounced autosave, a
184
+ * dwell-triggered mark-as-read: a refusal there is not news worth stopping the
185
+ * app for. The other is any surface holding text the user has not finished
186
+ * writing. The fatal page unmounts the app, so escalating from a composer
187
+ * throws the message away and leaves nothing to retry, which is a worse outcome
188
+ * than the failure it reports.
189
+ */
190
+ export const softErrorMeta: { softError: true } = { softError: true };
@@ -10,6 +10,13 @@ import { reportFatalError } from "./fatal-error";
10
10
  * statusless network blips, and non-5xx errors a call site opted out of via
11
11
  * `meta.softError` stay soft.
12
12
  *
13
+ * The two caches differ on one thing, the `Awaiting` argument. A mutation is
14
+ * something the user did and is owed the outcome of, so a 401 there escalates
15
+ * over `meta.softError` — no banner signs anyone back in. A query is "nobody" as
16
+ * a class: the reads the screen is actually waiting on carry no `meta.softError`
17
+ * and escalate on the default anyway, so the only ones this spares are those
18
+ * that already declared they own their failures.
19
+ *
13
20
  * This is the v5 equivalent of `defaultOptions.queries.onError` /
14
21
  * `.mutations.onError` — v5 moved the global error hook onto the caches.
15
22
  */
@@ -17,7 +24,7 @@ export const handleQueryCacheError = (
17
24
  error: Error,
18
25
  query: Query<unknown, unknown, unknown>,
19
26
  ): void => {
20
- if (shouldEscalate(error, query.meta)) {
27
+ if (shouldEscalate(error, query.meta, "nobody")) {
21
28
  reportFatalError(error);
22
29
  }
23
30
  };
@@ -28,7 +35,7 @@ export const handleMutationCacheError = (
28
35
  _onMutateResult: unknown,
29
36
  mutation: Mutation<unknown, unknown, unknown>,
30
37
  ): void => {
31
- if (shouldEscalate(error, mutation.meta)) {
38
+ if (shouldEscalate(error, mutation.meta, "user")) {
32
39
  reportFatalError(error);
33
40
  }
34
41
  };