@remit/web-client 0.0.13 → 0.0.14
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/better-auth-config.ts +4 -1
- package/src/components/compose/ComposeForm.tsx +3 -0
- package/src/components/mail/IntelligencePane.tsx +10 -8
- package/src/components/mail/MessageActionMenu.tsx +18 -25
- package/src/components/mail/MessageDetail.tsx +1 -0
- package/src/components/ui/ErrorBanner.tsx +6 -3
- package/src/components/ui/ErrorBannerProvider.render.test.ts +85 -0
- package/src/components/ui/ErrorBannerProvider.tsx +18 -0
- package/src/components/ui/error-banners.ts +9 -0
- package/src/hooks/useDeleteMessages.ts +14 -21
- package/src/hooks/useIntelligenceData.ts +14 -3
- package/src/hooks/useMarkAsRead.ts +14 -23
- package/src/hooks/useMessageBodyContent.ts +2 -1
- package/src/hooks/useMoveMessages.ts +14 -21
- package/src/hooks/useStaleAccountSync.test.ts +22 -1
- package/src/hooks/useToggleStar.test.ts +31 -0
- package/src/hooks/useToggleStar.ts +27 -31
- package/src/hooks/useUpdateAddressFlags.ts +3 -1
- package/src/lib/api.ts +3 -1
- package/src/lib/client.ts +3 -0
- package/src/lib/error-classifier.test.ts +156 -8
- package/src/lib/error-classifier.ts +49 -9
- package/src/lib/network-error.ts +58 -0
- package/src/lib/query-error-handler.test.ts +6 -2
- package/src/lib/query-escalation.integration.test.ts +3 -2
- package/src/lib/sender-address.test.ts +60 -0
- package/src/lib/sender-address.ts +37 -0
- package/src/lib/thread-cache.test.ts +65 -0
- package/src/lib/thread-cache.ts +76 -0
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import assert from "node:assert";
|
|
2
2
|
import { describe, test } from "node:test";
|
|
3
3
|
import type { RemitImapThreadMessageResponse } from "@remit/api-http-client/types.gen.ts";
|
|
4
|
+
import { patchThreadListCache } from "../lib/thread-cache.js";
|
|
4
5
|
import {
|
|
5
6
|
resolveMailboxForMessage,
|
|
6
7
|
toggleStarsInItems,
|
|
@@ -93,3 +94,33 @@ describe("resolveMailboxForMessage", () => {
|
|
|
93
94
|
);
|
|
94
95
|
});
|
|
95
96
|
});
|
|
97
|
+
|
|
98
|
+
describe("starring across the unified-threads cache shapes", () => {
|
|
99
|
+
// The Flagged view runs an infinite query on `listAllThreads({ starred:
|
|
100
|
+
// true })`, which shares its query-key prefix with the single-shot readers.
|
|
101
|
+
// A `setQueriesData` on that prefix therefore sees both shapes, and the
|
|
102
|
+
// optimistic patch has to survive whichever it is handed — patching the
|
|
103
|
+
// infinite entry as a plain `{ items }` threw and failed the star before it
|
|
104
|
+
// was sent.
|
|
105
|
+
const patch = (old: unknown) =>
|
|
106
|
+
patchThreadListCache(old, (items) => toggleStarsInItems(items, "m1", true));
|
|
107
|
+
|
|
108
|
+
test("patches the single-shot readers", () => {
|
|
109
|
+
const patched = patch({
|
|
110
|
+
items: [make("m1", false), make("m2", false)],
|
|
111
|
+
}) as {
|
|
112
|
+
items: RemitImapThreadMessageResponse[];
|
|
113
|
+
};
|
|
114
|
+
assert.equal(patched.items[0].hasStars, true);
|
|
115
|
+
assert.equal(patched.items[1].hasStars, false);
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
test("patches the Flagged view's infinite query instead of throwing", () => {
|
|
119
|
+
const patched = patch({
|
|
120
|
+
pages: [{ items: [make("m1", false)] }, { items: [make("m2", false)] }],
|
|
121
|
+
pageParams: [undefined, "next"],
|
|
122
|
+
}) as { pages: Array<{ items: RemitImapThreadMessageResponse[] }> };
|
|
123
|
+
assert.equal(patched.pages[0].items[0].hasStars, true);
|
|
124
|
+
assert.equal(patched.pages[1].items[0].hasStars, false);
|
|
125
|
+
});
|
|
126
|
+
});
|
|
@@ -9,6 +9,7 @@ import type { RemitImapThreadMessageResponse } from "@remit/api-http-client/type
|
|
|
9
9
|
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
|
10
10
|
import { useErrorBanners } from "@/components/ui/ErrorBannerProvider";
|
|
11
11
|
import { formatErrorDetail } from "@/components/ui/error-banners";
|
|
12
|
+
import { patchThreadListCache, type ThreadListCache } from "@/lib/thread-cache";
|
|
12
13
|
|
|
13
14
|
interface UseToggleStarOptions {
|
|
14
15
|
threadId: string;
|
|
@@ -46,15 +47,12 @@ interface ThreadMessagesData {
|
|
|
46
47
|
[key: string]: unknown;
|
|
47
48
|
}
|
|
48
49
|
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
pages: ThreadsListPage[];
|
|
56
|
-
pageParams: Array<string | undefined>;
|
|
57
|
-
}
|
|
50
|
+
/**
|
|
51
|
+
* Either shape a thread list/search query caches — the infinite mailbox list
|
|
52
|
+
* or a single-shot page. `setQueriesData` matches by key prefix, so the
|
|
53
|
+
* optimistic updater sees both.
|
|
54
|
+
*/
|
|
55
|
+
type ThreadsListData = ThreadListCache;
|
|
58
56
|
|
|
59
57
|
interface SnapshotEntry<T> {
|
|
60
58
|
queryKey: readonly unknown[];
|
|
@@ -156,39 +154,36 @@ export const useToggleStar = ({
|
|
|
156
154
|
)
|
|
157
155
|
.map(([queryKey, data]) => ({ queryKey, data }));
|
|
158
156
|
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
157
|
+
// The unified-threads prefix carries both shapes too: the Flagged view
|
|
158
|
+
// runs an infinite query on `listAllThreads({ starred: true })`, which
|
|
159
|
+
// sits under the same prefix as the single-shot readers. Patching it as
|
|
160
|
+
// a plain `{ items }` threw on that entry and failed the star before it
|
|
161
|
+
// was sent — the same defect as the mailbox list (issues #51, #55), so
|
|
162
|
+
// it goes through the same helper.
|
|
163
|
+
const patchItemsData = (old: unknown) =>
|
|
164
|
+
patchThreadListCache(old, (items) =>
|
|
165
|
+
toggleStarsInItems(items, messageId, nextStarred),
|
|
166
|
+
);
|
|
167
|
+
|
|
168
|
+
queryClient.setQueriesData(
|
|
168
169
|
{ queryKey: threadMessagesPrefix },
|
|
169
170
|
patchItemsData,
|
|
170
171
|
);
|
|
171
|
-
queryClient.setQueriesData
|
|
172
|
+
queryClient.setQueriesData(
|
|
172
173
|
{ queryKey: unifiedThreadsPrefix },
|
|
173
174
|
patchItemsData,
|
|
174
175
|
);
|
|
175
176
|
|
|
176
|
-
const patchListData = (old:
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
pages: old.pages.map((page) => ({
|
|
181
|
-
...page,
|
|
182
|
-
items: toggleStarsInItems(page.items, messageId, nextStarred),
|
|
183
|
-
})),
|
|
184
|
-
};
|
|
185
|
-
};
|
|
177
|
+
const patchListData = (old: unknown) =>
|
|
178
|
+
patchThreadListCache(old, (items) =>
|
|
179
|
+
toggleStarsInItems(items, messageId, nextStarred),
|
|
180
|
+
);
|
|
186
181
|
|
|
187
|
-
queryClient.setQueriesData
|
|
182
|
+
queryClient.setQueriesData(
|
|
188
183
|
{ queryKey: threadsListPrefix },
|
|
189
184
|
patchListData,
|
|
190
185
|
);
|
|
191
|
-
queryClient.setQueriesData
|
|
186
|
+
queryClient.setQueriesData(
|
|
192
187
|
{ queryKey: threadsSearchPrefix },
|
|
193
188
|
patchListData,
|
|
194
189
|
);
|
|
@@ -221,6 +216,7 @@ export const useToggleStar = ({
|
|
|
221
216
|
? "Couldn't star message"
|
|
222
217
|
: "Couldn't unstar message",
|
|
223
218
|
detail: formatErrorDetail(err),
|
|
219
|
+
error: err,
|
|
224
220
|
});
|
|
225
221
|
},
|
|
226
222
|
onSettled: (_data, _err, _vars, context) => {
|
|
@@ -12,6 +12,7 @@ import { useCallback } from "react";
|
|
|
12
12
|
import { useErrorBanners } from "@/components/ui/ErrorBannerProvider";
|
|
13
13
|
import { formatErrorDetail } from "@/components/ui/error-banners";
|
|
14
14
|
import { reportFatalError } from "@/lib/fatal-error";
|
|
15
|
+
import { senderAddressSearchQuery } from "@/lib/sender-address";
|
|
15
16
|
|
|
16
17
|
interface UseUpdateAddressFlagsOptions {
|
|
17
18
|
addressId: string | undefined;
|
|
@@ -61,7 +62,7 @@ export function useUpdateAddressFlags({
|
|
|
61
62
|
const { pushError } = useErrorBanners();
|
|
62
63
|
|
|
63
64
|
const addressCacheKey = addressOperationsSearchAddressesQueryKey({
|
|
64
|
-
query:
|
|
65
|
+
query: senderAddressSearchQuery(senderEmail),
|
|
65
66
|
});
|
|
66
67
|
|
|
67
68
|
const { mutate, isPending } = useMutation({
|
|
@@ -99,6 +100,7 @@ export function useUpdateAddressFlags({
|
|
|
99
100
|
pushError({
|
|
100
101
|
title: "Couldn't update sender preference",
|
|
101
102
|
detail: formatErrorDetail(err),
|
|
103
|
+
error: err,
|
|
102
104
|
});
|
|
103
105
|
},
|
|
104
106
|
onSettled: () => {
|
package/src/lib/api.ts
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { taggedFetch } from "./network-error";
|
|
2
|
+
|
|
1
3
|
export class ApiError extends Error {
|
|
2
4
|
constructor(
|
|
3
5
|
message: string,
|
|
@@ -38,7 +40,7 @@ const request = async <T>(
|
|
|
38
40
|
|
|
39
41
|
const url = buildUrl(path, params);
|
|
40
42
|
|
|
41
|
-
const response = await
|
|
43
|
+
const response = await taggedFetch(url, {
|
|
42
44
|
method,
|
|
43
45
|
headers: {
|
|
44
46
|
"Content-Type": "application/json",
|
package/src/lib/client.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { client } from "@remit/api-http-client/client.gen.ts";
|
|
2
2
|
import { getRuntimeConfig } from "../runtime-config";
|
|
3
3
|
import { ApiError } from "./api";
|
|
4
|
+
import { taggedFetch } from "./network-error";
|
|
4
5
|
|
|
5
6
|
// In production, the config.js apiUrl points at the deployed API Gateway.
|
|
6
7
|
// In local dev, the Vite proxy forwards /api -> localhost:4321 (see vite.config.ts).
|
|
@@ -8,6 +9,8 @@ const baseUrl = getRuntimeConfig().apiUrl;
|
|
|
8
9
|
|
|
9
10
|
client.setConfig({
|
|
10
11
|
baseUrl,
|
|
12
|
+
// Transport failures are tagged where they happen; see `network-error.ts`.
|
|
13
|
+
fetch: taggedFetch,
|
|
11
14
|
});
|
|
12
15
|
|
|
13
16
|
/**
|
|
@@ -5,10 +5,18 @@ import {
|
|
|
5
5
|
getErrorStatus,
|
|
6
6
|
hasHttpStatus,
|
|
7
7
|
isAbortError,
|
|
8
|
+
isClientBug,
|
|
8
9
|
isNetworkError,
|
|
9
10
|
isServerError,
|
|
10
11
|
shouldEscalate,
|
|
11
12
|
} from "./error-classifier";
|
|
13
|
+
import { NetworkError, taggedFetch } from "./network-error";
|
|
14
|
+
|
|
15
|
+
const timeoutError = (): Error => {
|
|
16
|
+
const error = new Error("The operation timed out.");
|
|
17
|
+
error.name = "TimeoutError";
|
|
18
|
+
return error;
|
|
19
|
+
};
|
|
12
20
|
|
|
13
21
|
const abortError = (): Error => {
|
|
14
22
|
const error = new Error("aborted");
|
|
@@ -75,9 +83,31 @@ describe("isAbortError", () => {
|
|
|
75
83
|
});
|
|
76
84
|
|
|
77
85
|
describe("isNetworkError", () => {
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
86
|
+
// The boundary tags the failure, so the browser's wording never matters.
|
|
87
|
+
// These are the real rejections `fetch` produces, across engines — every one
|
|
88
|
+
// of them has to stay soft, whatever it happens to say.
|
|
89
|
+
const transportFailures = [
|
|
90
|
+
new TypeError("Failed to fetch"), // Chrome, Edge
|
|
91
|
+
new TypeError("NetworkError when attempting to fetch resource."), // Firefox
|
|
92
|
+
new TypeError("Load failed"), // WebKit
|
|
93
|
+
new TypeError("The network connection was lost."), // WebKit, wifi dropped
|
|
94
|
+
new TypeError("The request timed out."), // WebKit
|
|
95
|
+
new TypeError("fetch failed"), // undici
|
|
96
|
+
new Error("something no engine has said yet"),
|
|
97
|
+
];
|
|
98
|
+
|
|
99
|
+
it("is true for anything the fetch boundary tagged, whatever it says", () => {
|
|
100
|
+
for (const failure of transportFailures) {
|
|
101
|
+
assert.equal(
|
|
102
|
+
isNetworkError(new NetworkError(failure)),
|
|
103
|
+
true,
|
|
104
|
+
`"${failure.message}" must stay soft`,
|
|
105
|
+
);
|
|
106
|
+
}
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it("is true for a timeout — a timeout is a transport failure", () => {
|
|
110
|
+
assert.equal(isNetworkError(new NetworkError(timeoutError())), true);
|
|
81
111
|
});
|
|
82
112
|
|
|
83
113
|
it("is false for an aborted request (its own category)", () => {
|
|
@@ -88,6 +118,108 @@ describe("isNetworkError", () => {
|
|
|
88
118
|
assert.equal(isNetworkError(new ApiError("boom", 500)), false);
|
|
89
119
|
assert.equal(isNetworkError(new ApiError("not found", 404)), false);
|
|
90
120
|
});
|
|
121
|
+
|
|
122
|
+
it("is false for an exception thrown by our own code", () => {
|
|
123
|
+
assert.equal(
|
|
124
|
+
isNetworkError(
|
|
125
|
+
new TypeError('can\'t access property "map", x is undefined'),
|
|
126
|
+
),
|
|
127
|
+
false,
|
|
128
|
+
);
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
it("does not mistake an untagged error for transport just because it reads like one", () => {
|
|
132
|
+
// A bug whose message happens to contain a fetch-failure phrase is still a
|
|
133
|
+
// bug. Only the boundary gets to say otherwise.
|
|
134
|
+
assert.equal(isNetworkError(new TypeError("Failed to fetch")), false);
|
|
135
|
+
});
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
describe("taggedFetch — where the decision is actually made", () => {
|
|
139
|
+
const withFetch = async (
|
|
140
|
+
impl: typeof fetch,
|
|
141
|
+
run: () => Promise<void>,
|
|
142
|
+
): Promise<void> => {
|
|
143
|
+
const original = globalThis.fetch;
|
|
144
|
+
globalThis.fetch = impl;
|
|
145
|
+
try {
|
|
146
|
+
await run();
|
|
147
|
+
} finally {
|
|
148
|
+
globalThis.fetch = original;
|
|
149
|
+
}
|
|
150
|
+
};
|
|
151
|
+
|
|
152
|
+
it("tags every transport rejection, regardless of its message", async () => {
|
|
153
|
+
for (const message of [
|
|
154
|
+
"Failed to fetch",
|
|
155
|
+
"The network connection was lost.",
|
|
156
|
+
"fetch failed",
|
|
157
|
+
"anything at all",
|
|
158
|
+
]) {
|
|
159
|
+
await withFetch(
|
|
160
|
+
() => Promise.reject(new TypeError(message)),
|
|
161
|
+
async () => {
|
|
162
|
+
const error = await taggedFetch("/x").catch((e: unknown) => e);
|
|
163
|
+
assert.ok(error instanceof NetworkError);
|
|
164
|
+
assert.equal(isNetworkError(error), true);
|
|
165
|
+
assert.equal(shouldEscalate(error), false);
|
|
166
|
+
},
|
|
167
|
+
);
|
|
168
|
+
}
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
it("tags a timeout, because a timeout never reached a server", async () => {
|
|
172
|
+
await withFetch(
|
|
173
|
+
() => Promise.reject(timeoutError()),
|
|
174
|
+
async () => {
|
|
175
|
+
const error = await taggedFetch("/x").catch((e: unknown) => e);
|
|
176
|
+
assert.ok(error instanceof NetworkError);
|
|
177
|
+
assert.equal(shouldEscalate(error), false);
|
|
178
|
+
},
|
|
179
|
+
);
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
it("leaves a deliberate abort untagged — it is not a failure", async () => {
|
|
183
|
+
await withFetch(
|
|
184
|
+
() => Promise.reject(abortError()),
|
|
185
|
+
async () => {
|
|
186
|
+
const error = await taggedFetch("/x").catch((e: unknown) => e);
|
|
187
|
+
assert.ok(!(error instanceof NetworkError));
|
|
188
|
+
assert.equal(isAbortError(error), true);
|
|
189
|
+
},
|
|
190
|
+
);
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
it("passes a response through untouched, including an error status", async () => {
|
|
194
|
+
const response = new Response("nope", { status: 500 });
|
|
195
|
+
await withFetch(
|
|
196
|
+
() => Promise.resolve(response),
|
|
197
|
+
async () => {
|
|
198
|
+
assert.equal(await taggedFetch("/x"), response);
|
|
199
|
+
},
|
|
200
|
+
);
|
|
201
|
+
});
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
describe("isClientBug", () => {
|
|
205
|
+
it("is true for an exception raised inside our own code", () => {
|
|
206
|
+
assert.equal(
|
|
207
|
+
isClientBug(
|
|
208
|
+
new TypeError('can\'t access property "map", x is undefined'),
|
|
209
|
+
),
|
|
210
|
+
true,
|
|
211
|
+
);
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
it("is false for a request that reached, or failed to reach, a server", () => {
|
|
215
|
+
assert.equal(isClientBug(new ApiError("boom", 500)), false);
|
|
216
|
+
assert.equal(isClientBug(new ApiError("not found", 404)), false);
|
|
217
|
+
assert.equal(
|
|
218
|
+
isClientBug(new NetworkError(new TypeError("Failed to fetch"))),
|
|
219
|
+
false,
|
|
220
|
+
);
|
|
221
|
+
assert.equal(isClientBug(abortError()), false);
|
|
222
|
+
});
|
|
91
223
|
});
|
|
92
224
|
|
|
93
225
|
describe("shouldEscalate (the fail-fast decision table — #1059)", () => {
|
|
@@ -119,15 +251,31 @@ describe("shouldEscalate (the fail-fast decision table — #1059)", () => {
|
|
|
119
251
|
assert.equal(shouldEscalate(abortError()), false);
|
|
120
252
|
});
|
|
121
253
|
|
|
122
|
-
it("does NOT escalate a
|
|
123
|
-
assert.equal(
|
|
124
|
-
|
|
254
|
+
it("does NOT escalate a network/offline blip", () => {
|
|
255
|
+
assert.equal(
|
|
256
|
+
shouldEscalate(new NetworkError(new TypeError("Failed to fetch"))),
|
|
257
|
+
false,
|
|
258
|
+
);
|
|
259
|
+
assert.equal(
|
|
260
|
+
shouldEscalate(
|
|
261
|
+
new NetworkError(new TypeError("The network connection was lost.")),
|
|
262
|
+
),
|
|
263
|
+
false,
|
|
264
|
+
);
|
|
125
265
|
});
|
|
126
266
|
|
|
127
|
-
it("a
|
|
267
|
+
it("a network error is soft regardless of meta", () => {
|
|
128
268
|
assert.equal(
|
|
129
|
-
shouldEscalate(new TypeError("Failed to fetch"), {
|
|
269
|
+
shouldEscalate(new NetworkError(new TypeError("Failed to fetch")), {
|
|
270
|
+
softError: false,
|
|
271
|
+
}),
|
|
130
272
|
false,
|
|
131
273
|
);
|
|
132
274
|
});
|
|
275
|
+
|
|
276
|
+
it("escalates an exception thrown by our own code, softError or not", () => {
|
|
277
|
+
const bug = new TypeError('can\'t access property "map", x is undefined');
|
|
278
|
+
assert.equal(shouldEscalate(bug), true);
|
|
279
|
+
assert.equal(shouldEscalate(bug, { softError: true }), true);
|
|
280
|
+
});
|
|
133
281
|
});
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { ApiError } from "./api";
|
|
2
|
+
import { NetworkError } from "./network-error";
|
|
2
3
|
|
|
3
4
|
/**
|
|
4
5
|
* Extract an HTTP status code from a thrown error, regardless of which client
|
|
@@ -55,14 +56,50 @@ export const isAbortError = (error: unknown): boolean => {
|
|
|
55
56
|
return false;
|
|
56
57
|
};
|
|
57
58
|
|
|
59
|
+
const isOffline = (): boolean =>
|
|
60
|
+
typeof navigator !== "undefined" && navigator.onLine === false;
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* A transport failure from a wifi drop, tab wake, captive portal, or a timeout:
|
|
64
|
+
* the request never reached a server, so it is environmental, not a proven
|
|
65
|
+
* first-party failure.
|
|
66
|
+
*
|
|
67
|
+
* This is decided at the fetch boundary, not inferred here. Every app-owned
|
|
68
|
+
* request goes through `taggedFetch`, which knows a `fetch()` rejection is
|
|
69
|
+
* transport-level because `fetch` rejects for no other reason, and tags it a
|
|
70
|
+
* `NetworkError`. Reading that tag is exact; matching browser failure strings
|
|
71
|
+
* was not — the list is open-ended (WebKit alone has four, undici another) and
|
|
72
|
+
* anything missed would put a full-screen fatal page in front of someone who
|
|
73
|
+
* had merely walked out of wifi range.
|
|
74
|
+
*
|
|
75
|
+
* `navigator.onLine === false` is kept as a second signal for an error that
|
|
76
|
+
* reached us from outside that boundary while the browser reports no
|
|
77
|
+
* connectivity at all.
|
|
78
|
+
*/
|
|
79
|
+
export const isNetworkError = (error: unknown): boolean => {
|
|
80
|
+
if (isAbortError(error)) return false;
|
|
81
|
+
if (hasHttpStatus(error)) return false;
|
|
82
|
+
if (error instanceof NetworkError) return true;
|
|
83
|
+
return isOffline();
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* An exception raised by our own client code rather than by a request: it
|
|
88
|
+
* carries no HTTP status, is not an abort, and was not tagged as transport.
|
|
89
|
+
* That leaves a programming error — the one class the user can do nothing
|
|
90
|
+
* about and must never be asked to shrug off.
|
|
91
|
+
*/
|
|
92
|
+
export const isClientBug = (error: unknown): boolean =>
|
|
93
|
+
!hasHttpStatus(error) && !isAbortError(error) && !isNetworkError(error);
|
|
94
|
+
|
|
58
95
|
/**
|
|
59
|
-
*
|
|
60
|
-
*
|
|
61
|
-
*
|
|
62
|
-
*
|
|
96
|
+
* Fatal with no opt-out: our API answered "I'm broken", or our own code threw.
|
|
97
|
+
* Neither is something a call site can reclassify as soft, and neither may be
|
|
98
|
+
* reduced to a dismissible banner — both belong on the full-screen page, which
|
|
99
|
+
* offers a way forward and a bug report (issue #55).
|
|
63
100
|
*/
|
|
64
|
-
export const
|
|
65
|
-
|
|
101
|
+
export const isAlwaysFatal = (error: unknown): boolean =>
|
|
102
|
+
isServerError(error) || isClientBug(error);
|
|
66
103
|
|
|
67
104
|
const isSoftErrorMeta = (meta: Record<string, unknown> | undefined): boolean =>
|
|
68
105
|
meta?.softError === true;
|
|
@@ -76,10 +113,12 @@ const isSoftErrorMeta = (meta: Record<string, unknown> | undefined): boolean =>
|
|
|
76
113
|
* 2. A 5xx (500–599) ALWAYS escalates — no opt-out, even on a background
|
|
77
114
|
* refetch, even when the call site marked `meta.softError`. Our API
|
|
78
115
|
* answered "I'm broken"; that is never benign.
|
|
79
|
-
* 3.
|
|
116
|
+
* 3. A client-side exception ALWAYS escalates, on the same terms. It is our
|
|
117
|
+
* bug; there is nothing for the user to retry and nothing to dismiss.
|
|
118
|
+
* 4. The ONLY soft (do-NOT-escalate) exemptions:
|
|
80
119
|
* a. aborts / cancellations — never a failure;
|
|
81
|
-
* b.
|
|
82
|
-
*
|
|
120
|
+
* b. network/offline errors — environmental, recovered by React Query's
|
|
121
|
+
* reconnect/retry;
|
|
83
122
|
* c. a non-5xx error on a query/mutation that opted out via
|
|
84
123
|
* `meta.softError === true` — the call site owns that error's UX
|
|
85
124
|
* (e.g. a 404 empty state, a 4xx "Reconnect" banner).
|
|
@@ -91,6 +130,7 @@ export const shouldEscalate = (
|
|
|
91
130
|
if (isServerError(error)) return true;
|
|
92
131
|
if (isAbortError(error)) return false;
|
|
93
132
|
if (isNetworkError(error)) return false;
|
|
133
|
+
if (isAlwaysFatal(error)) return true;
|
|
94
134
|
if (isSoftErrorMeta(meta)) return false;
|
|
95
135
|
return true;
|
|
96
136
|
};
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Transport failures are identified where they happen, not guessed at later.
|
|
3
|
+
*
|
|
4
|
+
* `fetch()` rejects for exactly two reasons: the request never completed at the
|
|
5
|
+
* transport level (DNS, TLS, connection lost, captive portal, timeout), or it
|
|
6
|
+
* was aborted. It never rejects for an HTTP status. So the one place that knows
|
|
7
|
+
* for certain that an error is a network error is the call site that wrapped
|
|
8
|
+
* `fetch` — everywhere downstream is guessing.
|
|
9
|
+
*
|
|
10
|
+
* Guessing is what the classifier used to do, first by treating every error
|
|
11
|
+
* without an HTTP status as a network blip (which swallowed our own exceptions),
|
|
12
|
+
* then by matching browser failure strings (which is unbounded: WebKit alone
|
|
13
|
+
* says "Failed to fetch", "Load failed", "The network connection was lost." and
|
|
14
|
+
* "The request timed out.", and undici says "fetch failed"). Tagging at the
|
|
15
|
+
* boundary makes the question decidable and the browser's wording irrelevant.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
/** A request that never reached a server. Always soft — never escalated. */
|
|
19
|
+
export class NetworkError extends Error {
|
|
20
|
+
constructor(cause: unknown) {
|
|
21
|
+
super(
|
|
22
|
+
cause instanceof Error && cause.message
|
|
23
|
+
? cause.message
|
|
24
|
+
: "The request could not be completed.",
|
|
25
|
+
{ cause },
|
|
26
|
+
);
|
|
27
|
+
this.name = "NetworkError";
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* A deliberate cancellation — a route change, a React Query cancellation, a
|
|
33
|
+
* caller's own `AbortController`. Its own category: not a failure at all, so it
|
|
34
|
+
* passes through untagged.
|
|
35
|
+
*
|
|
36
|
+
* `AbortSignal.timeout()` rejects with a `TimeoutError` rather than an
|
|
37
|
+
* `AbortError`, and a timeout IS a transport failure, so it is deliberately not
|
|
38
|
+
* matched here and gets tagged like any other.
|
|
39
|
+
*/
|
|
40
|
+
const isDeliberateAbort = (error: unknown): boolean =>
|
|
41
|
+
typeof error === "object" &&
|
|
42
|
+
error !== null &&
|
|
43
|
+
"name" in error &&
|
|
44
|
+
(error as { name?: unknown }).name === "AbortError";
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* `fetch`, with transport failures tagged. Every app-owned request goes through
|
|
48
|
+
* this — the generated client is configured with it, and the hand-written
|
|
49
|
+
* wrapper calls it — so a `NetworkError` downstream is a fact, not an inference.
|
|
50
|
+
*/
|
|
51
|
+
export const taggedFetch: typeof fetch = async (input, init) => {
|
|
52
|
+
try {
|
|
53
|
+
return await fetch(input, init);
|
|
54
|
+
} catch (error) {
|
|
55
|
+
if (isDeliberateAbort(error)) throw error;
|
|
56
|
+
throw new NetworkError(error);
|
|
57
|
+
}
|
|
58
|
+
};
|
|
@@ -3,6 +3,7 @@ import { afterEach, describe, it } from "node:test";
|
|
|
3
3
|
import type { Mutation, Query } from "@tanstack/react-query";
|
|
4
4
|
import { ApiError } from "./api";
|
|
5
5
|
import { __resetFatalError, subscribeFatalError } from "./fatal-error";
|
|
6
|
+
import { NetworkError } from "./network-error";
|
|
6
7
|
import {
|
|
7
8
|
handleMutationCacheError,
|
|
8
9
|
handleQueryCacheError,
|
|
@@ -95,12 +96,15 @@ describe("handleQueryCacheError (fail-fast contract #1059)", () => {
|
|
|
95
96
|
assert.equal(escalated, false);
|
|
96
97
|
});
|
|
97
98
|
|
|
98
|
-
it("does NOT escalate a
|
|
99
|
+
it("does NOT escalate a network blip tagged at the fetch boundary", () => {
|
|
99
100
|
let escalated = false;
|
|
100
101
|
subscribeFatalError(() => {
|
|
101
102
|
escalated = true;
|
|
102
103
|
});
|
|
103
|
-
handleQueryCacheError(
|
|
104
|
+
handleQueryCacheError(
|
|
105
|
+
new NetworkError(new TypeError("Failed to fetch")),
|
|
106
|
+
fakeQuery(),
|
|
107
|
+
);
|
|
104
108
|
assert.equal(escalated, false);
|
|
105
109
|
});
|
|
106
110
|
});
|
|
@@ -11,6 +11,7 @@ import { afterEach, describe, it } from "node:test";
|
|
|
11
11
|
import { MutationCache, QueryCache, QueryClient } from "@tanstack/react-query";
|
|
12
12
|
import { ApiError } from "./api";
|
|
13
13
|
import { __resetFatalError, subscribeFatalError } from "./fatal-error";
|
|
14
|
+
import { NetworkError } from "./network-error";
|
|
14
15
|
import {
|
|
15
16
|
handleMutationCacheError,
|
|
16
17
|
handleQueryCacheError,
|
|
@@ -117,7 +118,7 @@ describe("global query/mutation escalation", () => {
|
|
|
117
118
|
assert.deepEqual(seen, ["not found"]);
|
|
118
119
|
});
|
|
119
120
|
|
|
120
|
-
it("
|
|
121
|
+
it("an offline blip tagged at the fetch boundary does NOT escalate", async () => {
|
|
121
122
|
let escalated = false;
|
|
122
123
|
subscribeFatalError(() => {
|
|
123
124
|
escalated = true;
|
|
@@ -128,7 +129,7 @@ describe("global query/mutation escalation", () => {
|
|
|
128
129
|
.fetchQuery({
|
|
129
130
|
queryKey: ["offline"],
|
|
130
131
|
queryFn: async () => {
|
|
131
|
-
throw new TypeError("Failed to fetch");
|
|
132
|
+
throw new NetworkError(new TypeError("Failed to fetch"));
|
|
132
133
|
},
|
|
133
134
|
})
|
|
134
135
|
.catch(() => {});
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { describe, it } from "node:test";
|
|
3
|
+
import type { RemitImapAddressResponse } from "@remit/api-http-client/types.gen.ts";
|
|
4
|
+
import {
|
|
5
|
+
pickSenderAddress,
|
|
6
|
+
SENDER_ADDRESS_SEARCH_LIMIT,
|
|
7
|
+
senderAddressSearchQuery,
|
|
8
|
+
} from "./sender-address";
|
|
9
|
+
|
|
10
|
+
const address = (
|
|
11
|
+
addressId: string,
|
|
12
|
+
normalizedEmail: string,
|
|
13
|
+
): RemitImapAddressResponse =>
|
|
14
|
+
({ addressId, normalizedEmail }) as RemitImapAddressResponse;
|
|
15
|
+
|
|
16
|
+
describe("senderAddressSearchQuery", () => {
|
|
17
|
+
it("lowercases the address and asks for a window, not one row", () => {
|
|
18
|
+
assert.deepEqual(senderAddressSearchQuery("Support@NPMJS.com"), {
|
|
19
|
+
q: "support@npmjs.com",
|
|
20
|
+
limit: SENDER_ADDRESS_SEARCH_LIMIT,
|
|
21
|
+
});
|
|
22
|
+
assert.ok(SENDER_ADDRESS_SEARCH_LIMIT > 1);
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it("is stable for a missing sender, so the cache key stays valid", () => {
|
|
26
|
+
assert.deepEqual(senderAddressSearchQuery(undefined), {
|
|
27
|
+
q: "",
|
|
28
|
+
limit: SENDER_ADDRESS_SEARCH_LIMIT,
|
|
29
|
+
});
|
|
30
|
+
});
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
describe("pickSenderAddress", () => {
|
|
34
|
+
it("picks the exact address, not the first prefix match", () => {
|
|
35
|
+
const items = [
|
|
36
|
+
address("other", "sup@npmjs.com"),
|
|
37
|
+
address("wanted", "support@npmjs.com"),
|
|
38
|
+
];
|
|
39
|
+
assert.equal(
|
|
40
|
+
pickSenderAddress(items, "support@npmjs.com")?.addressId,
|
|
41
|
+
"wanted",
|
|
42
|
+
);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it("matches case-insensitively", () => {
|
|
46
|
+
const items = [address("wanted", "support@npmjs.com")];
|
|
47
|
+
assert.equal(
|
|
48
|
+
pickSenderAddress(items, "Support@NPMJS.com")?.addressId,
|
|
49
|
+
"wanted",
|
|
50
|
+
);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it("resolves to undefined rather than the wrong sender", () => {
|
|
54
|
+
const items = [address("other", "sup@npmjs.com")];
|
|
55
|
+
assert.equal(pickSenderAddress(items, "support@npmjs.com"), undefined);
|
|
56
|
+
assert.equal(pickSenderAddress([], "support@npmjs.com"), undefined);
|
|
57
|
+
assert.equal(pickSenderAddress(undefined, "support@npmjs.com"), undefined);
|
|
58
|
+
assert.equal(pickSenderAddress(items, undefined), undefined);
|
|
59
|
+
});
|
|
60
|
+
});
|