@remit/web-client 0.0.173 → 0.0.175
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/components/mail/BriefPane.tsx +7 -6
- package/src/components/mail/FlaggedPane.tsx +7 -6
- package/src/components/mail/MailboxPane.tsx +36 -53
- package/src/components/mail/intelligence-drawer.stories.tsx +59 -43
- package/src/hooks/intelligence-drawer.render.test.ts +99 -0
- package/src/hooks/signed-out-poll-is-not-fatal.render.test.ts +126 -0
- package/src/hooks/useIntelligenceDrawer.ts +54 -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
|
@@ -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
|
};
|