@remit/web-client 0.0.173 → 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 +1 -1
- package/src/components/compose/ComposeForm.tsx +23 -6
- package/src/components/compose/forward-autosave-is-not-fatal.render.test.ts +357 -0
- package/src/hooks/signed-out-poll-is-not-fatal.render.test.ts +126 -0
- package/src/hooks/useMarkAsRead.ts +5 -0
- package/src/hooks/useRefreshControl.ts +6 -1
- package/src/hooks/useSaveDraft.ts +78 -16
- package/src/hooks/useStaleAccountSync.test.ts +15 -0
- package/src/hooks/useStaleAccountSync.ts +5 -1
- package/src/lib/error-classifier.test.ts +46 -0
- package/src/lib/error-classifier.ts +55 -1
- package/src/lib/query-error-handler.ts +9 -2
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@remit/web-client",
|
|
3
|
-
"version": "0.0.
|
|
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 {
|
|
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
|
-
|
|
559
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
+
});
|
|
@@ -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 }))
|
|
39
|
+
if (shouldEscalate(error, { softError: true }, "user")) {
|
|
40
|
+
reportFatalError(error);
|
|
41
|
+
}
|
|
37
42
|
};
|
|
38
43
|
|
|
39
44
|
const maxLastSynced = (
|
|
@@ -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 [
|
|
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
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
const
|
|
74
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
152
|
+
setSaveState(SAVED);
|
|
110
153
|
queryClient.invalidateQueries({
|
|
111
154
|
queryKey: outboxOperationsListOutboxMessagesOptions().queryKey,
|
|
112
155
|
});
|
|
113
156
|
return result;
|
|
114
157
|
},
|
|
115
|
-
[
|
|
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
|
-
|
|
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
|
-
|
|
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 {
|
|
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
|
}
|
|
@@ -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.
|
|
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
|
};
|