@remit/web-client 0.0.239 → 0.0.241

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.
@@ -22,6 +22,7 @@ import {
22
22
  type FolderRoleRefusal,
23
23
  isFolderRoleRefusal,
24
24
  } from "@/components/ui/folder-role-refusal";
25
+ import { softErrorStatuses } from "@/lib/error-classifier";
25
26
  import {
26
27
  invalidateThreadListQueries,
27
28
  threadListCacheKeys,
@@ -109,6 +110,9 @@ export const useEmptyTrash = ({
109
110
 
110
111
  const { mutateAsync, isPending } = useMutation({
111
112
  ...trashOperationsEmptyTrashMutation(),
113
+ // The refusal is rendered in the pane, so it must not also take the whole
114
+ // screen. Only 409 — every other status is still the fatal page's (#1059).
115
+ meta: softErrorStatuses(409),
112
116
  onMutate: (): EmptyTrashContext => {
113
117
  inFlight.current = scope;
114
118
  return { scope, listMailboxId: listMailboxIdNow() };
@@ -351,6 +351,12 @@ export const useMessageBodyContent = ({
351
351
 
352
352
  const isLoading = query.isLoading && !!picked;
353
353
 
354
+ // `picked` is null for two different reasons, and a caller that treats them
355
+ // alike is wrong about one of them: the message has not arrived yet, or it
356
+ // arrived carrying nothing renderable. Only the second is a fact about the
357
+ // message, and only it may be stated to the reader (#1030).
358
+ const hasNoRenderablePart = bodyParts !== undefined && picked === null;
359
+
354
360
  useEffect(() => {
355
361
  if (isLoading) {
356
362
  loadStartRef.current = performance.now();
@@ -374,6 +380,7 @@ export const useMessageBodyContent = ({
374
380
  picked,
375
381
  data,
376
382
  isLoading,
383
+ hasNoRenderablePart,
377
384
  isError: query.isError,
378
385
  error: query.error,
379
386
  refetch: query.refetch,
@@ -3,6 +3,10 @@
3
3
  * and `useMailboxNameIndex` run — cached forever, react-query dedupes the
4
4
  * identical key across call sites) and reduces it to the mailboxId → folder map
5
5
  * search results resolve their provenance against. See `lib/result-folder.ts`.
6
+ *
7
+ * The read state travels with the index: a folder label cut on a delimiter that
8
+ * has not arrived is wrong rather than late, so a surface that names folders can
9
+ * wait for this, and say so when it fails.
6
10
  */
7
11
  import { mailboxOperationsListMailboxesOptions } from "@remit/api-http-client/@tanstack/react-query.gen.ts";
8
12
  import type {
@@ -18,37 +22,63 @@ import {
18
22
 
19
23
  type MailboxItems = RemitImapMailboxResponse[];
20
24
 
25
+ interface MailboxListsState {
26
+ items: MailboxItems[];
27
+ isPending: boolean;
28
+ error: Error | null;
29
+ }
30
+
31
+ export interface ResultFolderIndexState {
32
+ index: ResultFolderIndex;
33
+ isPending: boolean;
34
+ error: Error | null;
35
+ }
36
+
21
37
  /**
22
38
  * Hoisted, not inline: `useQueries` skips re-running `combine` when it is given
23
39
  * the same function, so a stable reference is what makes this a memo rather than
24
40
  * a per-render reduce. Without it the index — and every search-result memo keyed
25
41
  * on it — rebuilds on every render.
26
42
  */
27
- const combineMailboxItems = (
28
- results: { data?: { items: MailboxItems } }[],
29
- ): MailboxItems[] => results.map((result) => result.data?.items ?? []);
43
+ const combineMailboxLists = (
44
+ results: {
45
+ data?: { items: MailboxItems };
46
+ isPending: boolean;
47
+ error: Error | null;
48
+ }[],
49
+ ): MailboxListsState => ({
50
+ items: results.map((result) => result.data?.items ?? []),
51
+ isPending: results.some((result) => result.isPending),
52
+ error: results.find((result) => result.error)?.error ?? null,
53
+ });
30
54
 
31
55
  export function useResultFolderIndex(
32
56
  accounts: RemitImapAccountResponse[],
33
- ): ResultFolderIndex {
34
- const mailboxItems = useQueries({
57
+ ): ResultFolderIndexState {
58
+ const mailboxLists = useQueries({
35
59
  queries: accounts.map((account) => ({
36
60
  ...mailboxOperationsListMailboxesOptions({
37
61
  path: { accountId: account.accountId },
38
62
  }),
39
63
  staleTime: Infinity,
40
64
  })),
41
- combine: combineMailboxItems,
65
+ combine: combineMailboxLists,
42
66
  });
43
67
 
44
- return useMemo(
68
+ const index = useMemo(
45
69
  () =>
46
70
  buildResultFolderIndex(
47
71
  accounts.map((account, i) => ({
48
72
  folderAppointments: account.folderAppointments,
49
- mailboxes: mailboxItems[i] ?? [],
73
+ mailboxes: mailboxLists.items[i] ?? [],
50
74
  })),
51
75
  ),
52
- [accounts, mailboxItems],
76
+ [accounts, mailboxLists],
53
77
  );
78
+
79
+ return {
80
+ index,
81
+ isPending: mailboxLists.isPending,
82
+ error: mailboxLists.error,
83
+ };
54
84
  }
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 };
@@ -8,9 +8,11 @@ import { CANONICAL_TO_NAV_ROLE } from "./folder-roles";
8
8
  * canonical PascalCase the API uses everywhere. Both differences are resolved
9
9
  * here rather than by widening the kit's entry type.
10
10
  *
11
- * The record carries no hierarchy delimiter either, so the caller resolves the
12
- * account's own from its mailbox list and hands it in — the row shows a folder
13
- * name, and a path cannot be cut into one without it.
11
+ * The record carries no hierarchy delimiter either, so the caller joins the
12
+ * mailbox id against the account's own mailbox list and hands the delimiter in
13
+ * — the row shows a folder name, and a path cannot be cut into one without it.
14
+ * An empty delimiter means no account lists that mailbox, and the row keeps its
15
+ * whole path rather than being cut on a guess.
14
16
  */
15
17
  export function toQuarantineEntry(
16
18
  wire: RemitImapQuarantineResponse,
@@ -173,7 +173,7 @@ function MailLayout() {
173
173
  [accounts],
174
174
  );
175
175
  const mailboxNameIndex = useMailboxNameIndex(accounts);
176
- const resultFolderIndex = useResultFolderIndex(accounts);
176
+ const resultFolderIndex = useResultFolderIndex(accounts).index;
177
177
  const accountNameIndex = useMemo(
178
178
  () => buildAccountNameIndex(accounts),
179
179
  [accounts],
@@ -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 }), {
@@ -1,41 +0,0 @@
1
- /**
2
- * The hierarchy separator each account's own server reports, for surfaces that
3
- * hold a provider path but not the mailbox row it came from — quarantine, so
4
- * far. Fans out the same per-account mailbox-list query as
5
- * `useResultFolderIndex` (cached forever, react-query dedupes the identical key
6
- * across call sites) and keeps only the delimiter.
7
- */
8
- import { mailboxOperationsListMailboxesOptions } from "@remit/api-http-client/@tanstack/react-query.gen.ts";
9
- import type { RemitImapMailboxResponse } from "@remit/api-http-client/types.gen.ts";
10
- import { useQueries } from "@tanstack/react-query";
11
- import { useMemo } from "react";
12
- import { folderDelimiter } from "@/lib/move-options";
13
-
14
- /**
15
- * Hoisted so `useQueries` treats this as a memo rather than a per-render
16
- * reduce — see `useResultFolderIndex`.
17
- */
18
- const combineDelimiters = (
19
- results: { data?: { items: RemitImapMailboxResponse[] } }[],
20
- ): string[] =>
21
- results.map((result) => folderDelimiter(result.data?.items ?? []));
22
-
23
- export function useAccountDelimiters(
24
- accountIds: readonly string[],
25
- ): ReadonlyMap<string, string> {
26
- const delimiters = useQueries({
27
- queries: accountIds.map((accountId) => ({
28
- ...mailboxOperationsListMailboxesOptions({ path: { accountId } }),
29
- staleTime: Infinity,
30
- })),
31
- combine: combineDelimiters,
32
- });
33
-
34
- return useMemo(
35
- () =>
36
- new Map(
37
- accountIds.map((accountId, i) => [accountId, delimiters[i] ?? "/"]),
38
- ),
39
- [accountIds, delimiters],
40
- );
41
- }