@remit/web-client 0.0.12 → 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.
Files changed (45) hide show
  1. package/package.json +1 -1
  2. package/src/auth/better-auth-config.ts +4 -1
  3. package/src/components/compose/ComposeForm.tsx +3 -0
  4. package/src/components/layout/ComposeFab.tsx +19 -34
  5. package/src/components/layout/MailTopBar.tsx +64 -0
  6. package/src/components/mail/BriefPane.tsx +1 -12
  7. package/src/components/mail/FlaggedPane.tsx +1 -12
  8. package/src/components/mail/IntelligencePane.tsx +10 -8
  9. package/src/components/mail/MailListHeader.tsx +9 -5
  10. package/src/components/mail/MailboxPane.tsx +0 -10
  11. package/src/components/mail/MessageActionMenu.tsx +18 -25
  12. package/src/components/mail/MessageDetail.tsx +1 -0
  13. package/src/components/mail/MessageToolbar.render.test.ts +39 -0
  14. package/src/components/mail/MessageToolbar.tsx +8 -48
  15. package/src/components/ui/ErrorBanner.tsx +6 -3
  16. package/src/components/ui/ErrorBannerProvider.render.test.ts +85 -0
  17. package/src/components/ui/ErrorBannerProvider.tsx +18 -0
  18. package/src/components/ui/error-banners.ts +9 -0
  19. package/src/hooks/useComposeTarget.ts +92 -0
  20. package/src/hooks/useDeleteMessages.ts +14 -21
  21. package/src/hooks/useIntelligenceData.ts +14 -3
  22. package/src/hooks/useLayoutTier.test.ts +26 -0
  23. package/src/hooks/useMarkAsRead.ts +14 -23
  24. package/src/hooks/useMessageBodyContent.ts +2 -1
  25. package/src/hooks/useMoveMessages.ts +14 -21
  26. package/src/hooks/useStaleAccountSync.test.ts +22 -1
  27. package/src/hooks/useToggleStar.test.ts +31 -0
  28. package/src/hooks/useToggleStar.ts +27 -31
  29. package/src/hooks/useUpdateAddressFlags.ts +3 -1
  30. package/src/lib/api.ts +3 -1
  31. package/src/lib/client.ts +3 -0
  32. package/src/lib/compose-routes.test.ts +44 -0
  33. package/src/lib/compose-routes.ts +25 -0
  34. package/src/lib/error-classifier.test.ts +156 -8
  35. package/src/lib/error-classifier.ts +49 -9
  36. package/src/lib/network-error.ts +58 -0
  37. package/src/lib/query-error-handler.test.ts +6 -2
  38. package/src/lib/query-escalation.integration.test.ts +3 -2
  39. package/src/lib/route-search-query.test.ts +48 -0
  40. package/src/lib/sender-address.test.ts +60 -0
  41. package/src/lib/sender-address.ts +37 -0
  42. package/src/lib/thread-cache.test.ts +65 -0
  43. package/src/lib/thread-cache.ts +76 -0
  44. package/src/routes/mail/outbox.tsx +5 -0
  45. package/src/routes/mail.tsx +15 -1
@@ -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
- interface ThreadsListPage {
50
- items: RemitImapThreadMessageResponse[];
51
- [key: string]: unknown;
52
- }
53
-
54
- interface ThreadsListData {
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
- const patchItemsData = (old: ThreadMessagesData | undefined) => {
160
- if (!old) return old;
161
- return {
162
- ...old,
163
- items: toggleStarsInItems(old.items, messageId, nextStarred),
164
- };
165
- };
166
-
167
- queryClient.setQueriesData<ThreadMessagesData>(
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<ThreadMessagesData>(
172
+ queryClient.setQueriesData(
172
173
  { queryKey: unifiedThreadsPrefix },
173
174
  patchItemsData,
174
175
  );
175
176
 
176
- const patchListData = (old: ThreadsListData | undefined) => {
177
- if (!old) return old;
178
- return {
179
- ...old,
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<ThreadsListData>(
182
+ queryClient.setQueriesData(
188
183
  { queryKey: threadsListPrefix },
189
184
  patchListData,
190
185
  );
191
- queryClient.setQueriesData<ThreadsListData>(
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: { q: senderEmail ?? "", limit: 1 },
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 fetch(url, {
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
  /**
@@ -0,0 +1,44 @@
1
+ /**
2
+ * The one route resolver behind both compose entry points — the desktop top
3
+ * bar's button and the mobile `ComposeFab`. When they each carried their own
4
+ * copy the two disagreed, and the FAB opened compose state on routes that
5
+ * mount no surface: a dead button.
6
+ */
7
+ import assert from "node:assert/strict";
8
+ import { describe, it } from "node:test";
9
+ import { hostsComposeSurface } from "./compose-routes";
10
+
11
+ describe("hostsComposeSurface", () => {
12
+ it("is true for a mailbox route, which mounts FullCompose", () => {
13
+ assert.equal(hostsComposeSurface("/mail/INBOX"), true);
14
+ assert.equal(hostsComposeSurface("/mail/abc-123"), true);
15
+ });
16
+
17
+ it("is false for the virtual views, which mount no surface", () => {
18
+ assert.equal(hostsComposeSurface("/mail/outbox"), false);
19
+ assert.equal(hostsComposeSurface("/mail/flagged"), false);
20
+ });
21
+
22
+ it("is false for the daily brief, which is /mail itself", () => {
23
+ assert.equal(hostsComposeSurface("/mail"), false);
24
+ assert.equal(hostsComposeSurface("/mail/"), false);
25
+ });
26
+
27
+ it("is false outside the mail shell", () => {
28
+ assert.equal(hostsComposeSurface("/settings/accounts"), false);
29
+ assert.equal(hostsComposeSurface("/"), false);
30
+ assert.equal(hostsComposeSurface("/mailroom/x"), false);
31
+ });
32
+
33
+ it("matches whole segments, so a mailbox may be named after a view", () => {
34
+ assert.equal(hostsComposeSurface("/mail/outbox-2024"), true);
35
+ assert.equal(hostsComposeSurface("/mail/flagged-archive"), true);
36
+ assert.equal(hostsComposeSurface("/mail/outboxes"), true);
37
+ });
38
+
39
+ it("ignores a query string or hash on the path", () => {
40
+ assert.equal(hostsComposeSurface("/mail/INBOX?q=invoice"), true);
41
+ assert.equal(hostsComposeSurface("/mail/outbox?q=invoice"), false);
42
+ assert.equal(hostsComposeSurface("/mail/INBOX#top"), true);
43
+ });
44
+ });
@@ -0,0 +1,25 @@
1
+ /**
2
+ * Which routes mount the compose surface.
3
+ *
4
+ * `FullCompose` is mounted by the mailbox route only, so compose started from
5
+ * anywhere else has to carry the user to a mailbox first. Kept apart from
6
+ * `useComposeTarget` so it stays a plain function with no React or API
7
+ * dependencies — both compose entry points resolve routes through this one
8
+ * definition, and a divergent second copy is what left the mobile FAB dead on
9
+ * `/mail/flagged` and on the brief.
10
+ */
11
+
12
+ /** `/mail/<segment>` values that name a view rather than a mailbox. */
13
+ const VIRTUAL_MAIL_VIEWS = new Set(["outbox", "flagged"]);
14
+
15
+ /**
16
+ * True for `/mail/<id>` where `<id>` is a real mailbox.
17
+ *
18
+ * Compares whole path segments: a mailbox genuinely named `outbox-2024` hosts
19
+ * the surface and must not be read as the virtual outbox.
20
+ */
21
+ export const hostsComposeSurface = (pathname: string): boolean => {
22
+ const [, root, view] = pathname.split(/[?#]/)[0].split("/");
23
+ if (root !== "mail" || !view) return false;
24
+ return !VIRTUAL_MAIL_VIEWS.has(view);
25
+ };
@@ -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
- it("is true for a statusless transport failure", () => {
79
- assert.equal(isNetworkError(new TypeError("Failed to fetch")), true);
80
- assert.equal(isNetworkError(new Error("network down")), true);
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 statusless network/offline blip", () => {
123
- assert.equal(shouldEscalate(new TypeError("Failed to fetch")), false);
124
- assert.equal(shouldEscalate(new Error("network down")), false);
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 statusless error is soft regardless of meta", () => {
267
+ it("a network error is soft regardless of meta", () => {
128
268
  assert.equal(
129
- shouldEscalate(new TypeError("Failed to fetch"), { softError: false }),
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
- * A statusless transport/network failure (`TypeError: Failed to fetch` from a
60
- * wifi drop, tab wake, captive portal, or a background refetch): the request
61
- * never reached a server, so it is environmental, not a proven first-party
62
- * failure. Excludes deliberate aborts (also statusless, but their own category).
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 isNetworkError = (error: unknown): boolean =>
65
- !isAbortError(error) && !hasHttpStatus(error);
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. The ONLY soft (do-NOT-escalate) exemptions:
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. statusless network/offline errors — environmental, recovered by React
82
- * Query's reconnect/retry;
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 statusless network blip", () => {
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(new TypeError("Failed to fetch"), fakeQuery());
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("a statusless `Failed to fetch` (offline blip) from a query does NOT escalate", async () => {
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,48 @@
1
+ /**
2
+ * `q` lives on the parent `/mail` route, but every child re-declares it: a
3
+ * child's `validateSearch` is authoritative for its own URL, so a child that
4
+ * omits `q` strips it. The top bar mounts a search field on all four of these
5
+ * routes, so a stripped `q` means typing a query does nothing and the query is
6
+ * lost on the next navigation.
7
+ */
8
+ import assert from "node:assert/strict";
9
+ import { describe, it } from "node:test";
10
+ import { z } from "zod";
11
+ import { Route as MailboxRoute } from "../routes/mail/$mailboxId";
12
+ import { Route as FlaggedRoute } from "../routes/mail/flagged";
13
+ import { Route as BriefRoute } from "../routes/mail/index";
14
+ import { Route as OutboxRoute } from "../routes/mail/outbox";
15
+
16
+ const routes = {
17
+ "/mail/ (daily brief)": BriefRoute,
18
+ "/mail/flagged": FlaggedRoute,
19
+ "/mail/outbox": OutboxRoute,
20
+ "/mail/$mailboxId": MailboxRoute,
21
+ };
22
+
23
+ const parse = (route: { options: { validateSearch?: unknown } }) => {
24
+ const schema = route.options.validateSearch;
25
+ assert.ok(
26
+ schema instanceof z.ZodType,
27
+ "route must validate its search with a zod schema",
28
+ );
29
+ return (search: Record<string, unknown>) => schema.parse(search);
30
+ };
31
+
32
+ describe("every /mail child route carries `q` through its own validation", () => {
33
+ for (const [name, route] of Object.entries(routes)) {
34
+ it(`${name} preserves a query`, () => {
35
+ const parsed = parse(route)({ q: "invoice" }) as { q?: string };
36
+ assert.equal(
37
+ parsed.q,
38
+ "invoice",
39
+ `${name} drops q, so the top bar's search field is inert there`,
40
+ );
41
+ });
42
+
43
+ it(`${name} leaves q absent when there is none`, () => {
44
+ const parsed = parse(route)({}) as { q?: string };
45
+ assert.equal(parsed.q, undefined);
46
+ });
47
+ }
48
+ });