@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.
- package/package.json +1 -1
- package/src/auth/auth-interceptor.test.ts +41 -38
- package/src/components/compose/ComposeForm.tsx +51 -2
- package/src/components/compose/attachment-only-source.render.test.ts +233 -0
- package/src/components/mail/RoleAppointmentPromptProvider.tsx +7 -3
- package/src/components/ui/folder-role-refusal.test.ts +56 -26
- package/src/components/ui/folder-role-refusal.ts +6 -18
- package/src/components/ui/placement-refusal.test.ts +14 -6
- package/src/components/ui/placement-refusal.ts +5 -6
- package/src/hooks/refusal-escalation.render.test.ts +187 -0
- package/src/hooks/stale-account-sync-visibility.test.ts +166 -0
- package/src/hooks/use-system-update.test.ts +104 -0
- package/src/hooks/use-system-update.ts +6 -0
- package/src/hooks/useDeleteMessages.ts +5 -0
- package/src/hooks/useEmptyTrash.ts +4 -0
- package/src/hooks/useMessageBodyContent.ts +7 -0
- package/src/hooks/useStaleAccountSync.ts +16 -9
- package/src/lib/api.ts +42 -5
- package/src/lib/client.test.ts +68 -0
- package/src/lib/client.ts +2 -8
- package/src/routes/settings/folders.tsx +4 -0
- package/src/test-support/http.ts +3 -2
|
@@ -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
|
|
279
|
-
// A hot window running out while the tab is away costs
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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(),
|
package/src/test-support/http.ts
CHANGED
|
@@ -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,
|
|
75
|
-
*
|
|
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 }), {
|