@remit/web-client 0.0.240 → 0.0.242

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.
@@ -275,21 +275,28 @@ export const useStaleAccountSync = (
275
275
  return;
276
276
  }
277
277
  // Hidden: don't reschedule. `handleVisibilityChange` resumes the loop
278
- // (with an immediate catch-up poll) once the tab is visible again.
279
- // A hot window running out while the tab is away costs nothing —
280
- // there is nobody watching a list to keep current.
278
+ // (with an immediate catch-up poll if one is due) once the tab is
279
+ // visible again. A hot window running out while the tab is away costs
280
+ // nothing — there is nobody watching a list to keep current.
281
281
  };
282
282
 
283
+ // A tick that fired while the tab was hidden left no timer behind, so
284
+ // coming back is the only thing that can re-arm the loop — it does so
285
+ // unconditionally. Due-ness decides whether this tab polls *now*, never
286
+ // whether a next tick exists: a tab that went to sleep on the hot cadence
287
+ // and came back after the window lapsed is not due against a longer
288
+ // ambient interval, and used to stop polling for good (#1031).
283
289
  const handleVisibilityChange = () => {
284
290
  if (document.visibilityState !== "visible") return;
285
- if (
286
- !hasPollIntervalElapsed(Date.now(), lastPollAt, currentPollIntervalMs())
287
- ) {
291
+ if (timeoutId !== undefined) clearTimeout(timeoutId);
292
+ const now = Date.now();
293
+ const intervalMs = currentPollIntervalMs(now);
294
+ if (hasPollIntervalElapsed(now, lastPollAt, intervalMs)) {
295
+ poll();
296
+ timeoutId = setTimeout(tick, intervalMs);
288
297
  return;
289
298
  }
290
- if (timeoutId !== undefined) clearTimeout(timeoutId);
291
- poll();
292
- timeoutId = setTimeout(tick, currentPollIntervalMs());
299
+ timeoutId = setTimeout(tick, lastPollAt + intervalMs - now);
293
300
  };
294
301
 
295
302
  // A press opens the hot window, but this loop may already be asleep on a
package/src/lib/api.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import type { ApiError as ApiErrorWireBody } from "@remit/api-http-client/types.gen.ts";
1
2
  import { taggedFetch } from "./network-error";
2
3
 
3
4
  export class ApiError extends Error {
@@ -11,6 +12,46 @@ export class ApiError extends Error {
11
12
  }
12
13
  }
13
14
 
15
+ /**
16
+ * The wire body's coded half, re-checked at runtime. `ApiErrorWireBody` is the
17
+ * generated flat shape the API sends; `ApiError` is the class every HTTP
18
+ * failure arrives in, carrying that body at `.body`. Reading `code` off the
19
+ * class is the bug #1004 is about, so nothing outside this module reaches for
20
+ * either shape directly.
21
+ */
22
+ export interface CodedApiErrorBody {
23
+ code: ApiErrorWireBody["code"];
24
+ details: Record<string, unknown> | undefined;
25
+ }
26
+
27
+ const isRecord = (value: unknown): value is Record<string, unknown> =>
28
+ typeof value === "object" && value !== null && !Array.isArray(value);
29
+
30
+ export const codedApiErrorBody = (
31
+ error: unknown,
32
+ ): CodedApiErrorBody | undefined => {
33
+ const body = error instanceof ApiError ? error.body : error;
34
+ if (!isRecord(body)) return undefined;
35
+ const { code, details } = body;
36
+ if (typeof code !== "string") return undefined;
37
+ return { code, details: isRecord(details) ? details : undefined };
38
+ };
39
+
40
+ const failureMessage = (body: unknown, status: number): string => {
41
+ if (isRecord(body) && body.message !== undefined && body.message !== null) {
42
+ const message = String(body.message);
43
+ if (message) return message;
44
+ }
45
+ return `Request failed with status ${status}`;
46
+ };
47
+
48
+ /**
49
+ * The one place an HTTP failure becomes an `ApiError`, so a test can hand a
50
+ * consumer the exact value the error interceptor produces.
51
+ */
52
+ export const wrapHttpFailure = (body: unknown, status: number): ApiError =>
53
+ new ApiError(failureMessage(body, status), status, body);
54
+
14
55
  interface RequestOptions extends Omit<RequestInit, "body"> {
15
56
  params?: Record<string, string | number | boolean | undefined>;
16
57
  body?: unknown;
@@ -52,11 +93,7 @@ const request = async <T>(
52
93
 
53
94
  if (!response.ok) {
54
95
  const errorBody = await response.json().catch(() => undefined);
55
- throw new ApiError(
56
- errorBody?.message || `Request failed with status ${response.status}`,
57
- response.status,
58
- errorBody,
59
- );
96
+ throw wrapHttpFailure(errorBody, response.status);
60
97
  }
61
98
 
62
99
  if (response.status === 204) {
@@ -0,0 +1,68 @@
1
+ import assert from "node:assert/strict";
2
+ import { afterEach, describe, it } from "node:test";
3
+ import { trashOperationsEmptyTrash } from "@remit/api-http-client/sdk.gen.ts";
4
+ import { type HttpMock, mockFetch } from "../test-support/http";
5
+ import { ApiError } from "./api";
6
+ import "./client";
7
+
8
+ let http: HttpMock | undefined;
9
+
10
+ afterEach(() => {
11
+ http?.restore();
12
+ http = undefined;
13
+ });
14
+
15
+ const answer = (status: number, body: unknown): void => {
16
+ http = mockFetch(
17
+ () =>
18
+ new Response(JSON.stringify(body), {
19
+ status,
20
+ headers: { "content-type": "application/json" },
21
+ }),
22
+ );
23
+ };
24
+
25
+ const empty = (): Promise<unknown> =>
26
+ trashOperationsEmptyTrash({
27
+ path: { accountId: "acc-1" },
28
+ throwOnError: true,
29
+ });
30
+
31
+ describe("the error interceptor", () => {
32
+ it("hands the app an ApiError carrying the status and the wire body", async () => {
33
+ answer(409, {
34
+ code: "folder_role_unresolved",
35
+ message: "Nobody has confirmed which folder is this account's Trash",
36
+ details: { reason: "unconfirmed", role: "Trash", accountId: "acc-1" },
37
+ });
38
+
39
+ const error = await empty().then(
40
+ () => undefined,
41
+ (reason: unknown) => reason,
42
+ );
43
+
44
+ assert.ok(error instanceof ApiError);
45
+ assert.equal(error.status, 409);
46
+ assert.equal(
47
+ error.message,
48
+ "Nobody has confirmed which folder is this account's Trash",
49
+ );
50
+ assert.deepEqual(error.body, {
51
+ code: "folder_role_unresolved",
52
+ message: "Nobody has confirmed which folder is this account's Trash",
53
+ details: { reason: "unconfirmed", role: "Trash", accountId: "acc-1" },
54
+ });
55
+ });
56
+
57
+ it("falls back to the status when the body states no message", async () => {
58
+ answer(503, {});
59
+
60
+ const error = await empty().then(
61
+ () => undefined,
62
+ (reason: unknown) => reason,
63
+ );
64
+
65
+ assert.ok(error instanceof ApiError);
66
+ assert.equal(error.message, "Request failed with status 503");
67
+ });
68
+ });
package/src/lib/client.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import { client } from "@remit/api-http-client/client.gen.ts";
2
2
  import { getRuntimeConfig } from "../runtime-config";
3
- import { ApiError } from "./api";
3
+ import { ApiError, wrapHttpFailure } from "./api";
4
4
  import { taggedFetch } from "./network-error";
5
5
 
6
6
  // In production, the config.js apiUrl points at the deployed API Gateway.
@@ -27,13 +27,7 @@ client.interceptors.error.use((error, response) => {
27
27
  if (error instanceof ApiError) return error;
28
28
  if (!response) return error;
29
29
 
30
- const body = error;
31
- const message =
32
- body && typeof body === "object" && "message" in body
33
- ? String((body as { message: unknown }).message)
34
- : `Request failed with status ${response.status}`;
35
-
36
- return new ApiError(message, response.status, body);
30
+ return wrapHttpFailure(error, response.status);
37
31
  });
38
32
 
39
33
  export { client };
@@ -31,6 +31,7 @@ import { isMailboxNotSettledRefusal } from "@/components/ui/folder-role-refusal"
31
31
  import { useCreateMailbox } from "@/hooks/useCreateMailbox";
32
32
  import { useFolderLabelTranslator } from "@/hooks/useFolderLabelTranslator";
33
33
  import { guardFolderDeletion } from "@/lib/delete-folder";
34
+ import { softErrorStatuses } from "@/lib/error-classifier";
34
35
  import {
35
36
  buildMailboxRoleMap,
36
37
  CANONICAL_TO_NAV_ROLE,
@@ -57,6 +58,9 @@ function AccountFolders({ account }: { account: RemitImapAccountResponse }) {
57
58
 
58
59
  const appointMutation = useMutation({
59
60
  ...folderRoleOperationsAppointFolderRoleMutation(),
61
+ // The pane states a still-settling mailbox inline; see the same meta on
62
+ // the prompt's own write.
63
+ meta: softErrorStatuses(409),
60
64
  onSuccess: () => {
61
65
  queryClient.invalidateQueries({
62
66
  queryKey: configOperationsGetConfigQueryKey(),
@@ -71,8 +71,9 @@ export const mockFetch = (responder: Responder = () => ({})): HttpMock => {
71
71
 
72
72
  /**
73
73
  * A failed request. The status travels in the body as well as on the response:
74
- * the generated client throws the parsed body, and the app's error classifier
75
- * reads the status off it.
74
+ * the generated client throws the parsed body, which the error interceptor
75
+ * re-wraps as an `ApiError` carrying the response status. Anything read off the
76
+ * body — a `code`, its `details` — sits at `.body`, never at the top level.
76
77
  */
77
78
  export const httpError = (status: number, message = "boom"): Response =>
78
79
  new Response(JSON.stringify({ status, message }), {