@remit/web-client 0.0.239 → 0.0.240

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@remit/web-client",
3
- "version": "0.0.239",
3
+ "version": "0.0.240",
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": {
@@ -0,0 +1,112 @@
1
+ /**
2
+ * A quarantine row names the folder its message sits in, which means cutting a
3
+ * provider path into a leaf — and that cut is only correct on the account's own
4
+ * hierarchy separator. The separator comes from a second read (the account's
5
+ * mailbox list), so the pane must never paint a row before that read lands:
6
+ * `INBOX.Projects.Q3` cut on a slash is one segment, and the row would show the
7
+ * whole path as if it were a folder name. See #900.
8
+ */
9
+
10
+ import assert from "node:assert/strict";
11
+ import { afterEach, describe, it } from "node:test";
12
+ import { createElement } from "react";
13
+ import { createDomHarness, type DomHarness } from "../../test-support/dom";
14
+ import { type HttpMock, httpError, mockFetch } from "../../test-support/http";
15
+ import { QuarantinePanel } from "./QuarantinePanel";
16
+
17
+ const ACCOUNT_ID = "acct-1";
18
+ const MAILBOX_ID = "mbx-q3";
19
+ const FULL_PATH = "INBOX.Projects.Q3";
20
+
21
+ const CONFIG = {
22
+ accounts: [{ accountId: ACCOUNT_ID, folderAppointments: [] }],
23
+ };
24
+
25
+ const MAILBOXES = {
26
+ items: [
27
+ {
28
+ mailboxId: MAILBOX_ID,
29
+ fullPath: FULL_PATH,
30
+ hierarchyDelimiter: ".",
31
+ },
32
+ ],
33
+ };
34
+
35
+ const QUARANTINE = {
36
+ entries: [
37
+ {
38
+ quarantineId: "q-1",
39
+ accountId: ACCOUNT_ID,
40
+ mailboxId: MAILBOX_ID,
41
+ uidValidity: 1_712_000_000,
42
+ uid: 40251,
43
+ mailboxPath: FULL_PATH,
44
+ failureStage: "BodyParse",
45
+ failureCode: "UnreadableBody",
46
+ failureMessage: "stream ended before the declared body length",
47
+ quarantinedAt: Date.parse("2026-07-19T06:03:00Z"),
48
+ attempts: 1,
49
+ sizeBytes: 2_140,
50
+ structure: [],
51
+ },
52
+ ],
53
+ };
54
+
55
+ let harness: DomHarness | undefined;
56
+ let http: HttpMock | undefined;
57
+
58
+ afterEach(() => {
59
+ harness?.close();
60
+ harness = undefined;
61
+ http?.restore();
62
+ http = undefined;
63
+ });
64
+
65
+ const mount = async (mailboxes: () => unknown): Promise<DomHarness> => {
66
+ http = mockFetch((call) => {
67
+ if (call.path.endsWith("/config")) return CONFIG;
68
+ if (call.path.endsWith("/mailboxes")) return mailboxes();
69
+ if (call.path.endsWith("/quarantine")) return QUARANTINE;
70
+ return {};
71
+ });
72
+ harness = createDomHarness();
73
+ harness.renderApp(createElement(QuarantinePanel));
74
+ // Two waves: the mailbox-list fan-out is keyed on the accounts `/config`
75
+ // returns, so it cannot even start until that first read has settled.
76
+ await harness.flush();
77
+ await harness.wait(0);
78
+ await harness.flush();
79
+ return harness;
80
+ };
81
+
82
+ describe("QuarantinePanel folder names", () => {
83
+ it("waits for the account's delimiter rather than cutting on a guess", async () => {
84
+ const view = await mount(() => new Promise(() => {}));
85
+
86
+ assert.match(view.text(), /Checking for messages set aside/);
87
+ assert.doesNotMatch(
88
+ view.text(),
89
+ new RegExp(FULL_PATH.replace(/\./g, "\\.")),
90
+ );
91
+ });
92
+
93
+ it("names the folder by its leaf once the delimiter has arrived", async () => {
94
+ const view = await mount(() => MAILBOXES);
95
+
96
+ assert.match(view.text(), /Q3/);
97
+ assert.doesNotMatch(
98
+ view.text(),
99
+ new RegExp(FULL_PATH.replace(/\./g, "\\.")),
100
+ );
101
+ });
102
+
103
+ it("reports a failed mailbox read instead of naming folders on a guess", async () => {
104
+ const view = await mount(() => httpError(500));
105
+
106
+ assert.match(view.html(), /role="alert"/);
107
+ assert.doesNotMatch(
108
+ view.text(),
109
+ new RegExp(FULL_PATH.replace(/\./g, "\\.")),
110
+ );
111
+ });
112
+ });
@@ -6,7 +6,10 @@
6
6
  * visible: the list, the reassurance that there is nothing in it, and the
7
7
  * failure to read it at all. None of them is an empty panel.
8
8
  */
9
- import { meOperationsListQuarantineOptions } from "@remit/api-http-client/@tanstack/react-query.gen.ts";
9
+ import {
10
+ configOperationsGetConfigOptions,
11
+ meOperationsListQuarantineOptions,
12
+ } from "@remit/api-http-client/@tanstack/react-query.gen.ts";
10
13
  import type { RemitImapQuarantineResponse } from "@remit/api-http-client/types.gen.ts";
11
14
  import {
12
15
  Button,
@@ -20,9 +23,10 @@ import { useQuery } from "@tanstack/react-query";
20
23
  import { Bug } from "lucide-react";
21
24
  import { useMemo, useState } from "react";
22
25
  import { formatErrorMessage } from "@/components/ui/ErrorState";
23
- import { useAccountDelimiters } from "@/hooks/useAccountDelimiters";
26
+ import { useResultFolderIndex } from "@/hooks/useResultFolderIndex";
24
27
  import { buildBugReportContext, buildGitHubIssueUrl } from "@/lib/bug-report";
25
28
  import { toQuarantineEntry } from "@/lib/quarantine-entries";
29
+ import type { ResultFolderIndex } from "@/lib/result-folder";
26
30
 
27
31
  /** Stable identity, so an empty list does not rebuild the memos below. */
28
32
  const EMPTY_WIRE_ENTRIES: readonly RemitImapQuarantineResponse[] = [];
@@ -126,30 +130,49 @@ export function QuarantinePanelView({
126
130
  );
127
131
  }
128
132
 
133
+ /**
134
+ * A row names the folder it came from by the leaf of its path, which cannot be
135
+ * cut until that account's own delimiter has arrived. A mailbox the account no
136
+ * longer lists never gets one, and keeps its whole path — `folderLeaf` on an
137
+ * empty delimiter is the path itself.
138
+ */
139
+ function delimiterForMailbox(
140
+ folders: ResultFolderIndex,
141
+ mailboxId: string,
142
+ ): string {
143
+ return folders.get(mailboxId)?.hierarchyDelimiter ?? "";
144
+ }
145
+
146
+ /**
147
+ * The folder names come from the account's mailbox lists, which are a second
148
+ * read — started alongside the quarantine list, never behind it. Until both have
149
+ * landed the pane says it is still reading, because a path cut on a delimiter
150
+ * that has not arrived is a wrong folder name rather than a late one, and a
151
+ * failure on either read is reported rather than papered over with a guess.
152
+ */
129
153
  export function QuarantinePanel() {
130
- const { data, isPending, error } = useQuery(
131
- meOperationsListQuarantineOptions(),
132
- );
154
+ const quarantine = useQuery(meOperationsListQuarantineOptions());
155
+ const config = useQuery(configOperationsGetConfigOptions());
156
+ const accounts = useMemo(() => config.data?.accounts ?? [], [config.data]);
157
+ const folders = useResultFolderIndex(accounts);
133
158
 
134
- const wireEntries = data?.entries ?? EMPTY_WIRE_ENTRIES;
135
- const accountIds = useMemo(
136
- () => [...new Set(wireEntries.map((entry) => entry.accountId))],
137
- [wireEntries],
138
- );
139
- const delimiters = useAccountDelimiters(accountIds);
159
+ const wireEntries = quarantine.data?.entries ?? EMPTY_WIRE_ENTRIES;
140
160
  const entries = useMemo(
141
161
  () =>
142
162
  wireEntries.map((entry) =>
143
- toQuarantineEntry(entry, delimiters.get(entry.accountId) ?? "/"),
163
+ toQuarantineEntry(
164
+ entry,
165
+ delimiterForMailbox(folders.index, entry.mailboxId),
166
+ ),
144
167
  ),
145
- [wireEntries, delimiters],
168
+ [wireEntries, folders.index],
146
169
  );
147
170
 
148
171
  return (
149
172
  <QuarantinePanelView
150
173
  entries={entries}
151
- isPending={isPending}
152
- error={error}
174
+ isPending={quarantine.isPending || config.isPending || folders.isPending}
175
+ error={quarantine.error ?? config.error ?? folders.error}
153
176
  />
154
177
  );
155
178
  }
@@ -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
  }
@@ -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],
@@ -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
- }