@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.
@@ -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. 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
  };