@remit/web-client 0.0.197 → 0.0.198

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.197",
3
+ "version": "0.0.198",
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": {
@@ -40,7 +40,7 @@ import {
40
40
  import { useMessageBodyContent } from "../../hooks/useMessageBodyContent";
41
41
  import { useSaveDraft } from "../../hooks/useSaveDraft";
42
42
  import { useSignature } from "../../hooks/useSignature.js";
43
- import { softErrorMeta } from "../../lib/error-classifier";
43
+ import { isNotFound, softErrorMeta } from "../../lib/error-classifier";
44
44
  import { accountIsMissingSmtp } from "../settings/account-form-helpers.js";
45
45
  import { useErrorBanners } from "../ui/ErrorBannerProvider.js";
46
46
  import {
@@ -59,7 +59,7 @@ const LazyComposeBody = lazy(() =>
59
59
  import { useIsDesktop } from "../../hooks/useMediaQuery.js";
60
60
  import { useVisualViewport } from "../../hooks/useVisualViewport.js";
61
61
  import type { ComposeMode } from "./ComposeProvider";
62
- import { useCompose } from "./ComposeProvider";
62
+ import { OUTBOX_ROW_META, useCompose } from "./ComposeProvider";
63
63
  import { FromSelector } from "./FromSelector";
64
64
 
65
65
  interface ComposeFormProps {
@@ -458,13 +458,25 @@ export const ComposeForm = ({
458
458
  formatting: [],
459
459
  }));
460
460
 
461
- const { data: draftData } = useQuery({
461
+ const { data: draftData, error: draftError } = useQuery({
462
462
  ...outboxDetailOperationsGetOutboxMessageOptions({
463
463
  path: { outboxMessageId: outboxMessageId ?? "" },
464
464
  }),
465
465
  enabled: !!outboxMessageId && !draftLoaded,
466
+ meta: OUTBOX_ROW_META,
467
+ retry: (failureCount, error) => !isNotFound(error) && failureCount < 1,
466
468
  });
467
469
 
470
+ // The draft id is a path segment, so Back after a send — or a restored tab —
471
+ // reopens the composer on a row the send already filed and deleted. There is
472
+ // no document left to reopen and nothing failed, so the surface closes the way
473
+ // it closes on Send. Only the 404 is read this way; a lapsed session still
474
+ // escalates.
475
+ useEffect(() => {
476
+ if (!isNotFound(draftError)) return;
477
+ onClose();
478
+ }, [draftError, onClose]);
479
+
468
480
  useEffect(() => {
469
481
  if (!draftData || draftLoaded) return;
470
482
 
@@ -9,9 +9,9 @@ import {
9
9
  useContext,
10
10
  useEffect,
11
11
  useMemo,
12
- useRef,
13
12
  useState,
14
13
  } from "react";
14
+ import { isNotFound, softErrorStatuses } from "@/lib/error-classifier";
15
15
  import type { ReplyMode } from "@/routing";
16
16
 
17
17
  /**
@@ -41,6 +41,23 @@ const ComposeContext = createContext<ComposeContextValue | undefined>(
41
41
  const POLL_INTERVAL_MS = 2000;
42
42
  const MAX_POLL_DURATION_MS = 60_000;
43
43
 
44
+ const isSettledStatus = (status: string | undefined): boolean =>
45
+ status === "sent" ||
46
+ status === "unfiled" ||
47
+ status === "failed" ||
48
+ status === "blocked";
49
+
50
+ /**
51
+ * A send that lands is filed and then forgotten: the worker APPENDs the message
52
+ * to Sent and drops the outbox row, so the row's absence is the settled state
53
+ * and the 404 is the confirmation. `sent` lives for under a second and this poll
54
+ * runs every two, so the 404 is the outcome it normally reads.
55
+ *
56
+ * Only the 404 is this call site's to own. A 401 or a 403 here is a session that
57
+ * lapsed under a send, which the app owes the user an answer about.
58
+ */
59
+ export const OUTBOX_ROW_META = softErrorStatuses(404);
60
+
44
61
  export const ComposeProvider = ({
45
62
  children,
46
63
  }: {
@@ -49,48 +66,47 @@ export const ComposeProvider = ({
49
66
  const [pollingMessageId, setPollingMessageId] = useState<
50
67
  string | undefined
51
68
  >();
52
- const startedAtRef = useRef(0);
53
69
  const queryClient = useQueryClient();
54
70
 
55
- const { data: polledMessage } = useQuery({
71
+ const { data: polledMessage, error: pollError } = useQuery({
56
72
  ...outboxDetailOperationsGetOutboxMessageOptions({
57
73
  path: { outboxMessageId: pollingMessageId ?? "" },
58
74
  }),
59
75
  enabled: !!pollingMessageId,
76
+ meta: OUTBOX_ROW_META,
77
+ retry: (failureCount, error) => !isNotFound(error) && failureCount < 1,
60
78
  refetchInterval: (query) => {
61
- const status = query.state.data?.status;
62
- if (
63
- status === "sent" ||
64
- status === "unfiled" ||
65
- status === "failed" ||
66
- status === "blocked"
67
- )
68
- return false;
69
- if (Date.now() - startedAtRef.current > MAX_POLL_DURATION_MS)
70
- return false;
79
+ if (isSettledStatus(query.state.data?.status)) return false;
80
+ if (isNotFound(query.state.error)) return false;
71
81
  return POLL_INTERVAL_MS;
72
82
  },
73
83
  });
74
84
 
85
+ const stopWatching = useCallback(() => {
86
+ setPollingMessageId(undefined);
87
+ queryClient.invalidateQueries({
88
+ queryKey: outboxOperationsListOutboxMessagesQueryKey(),
89
+ });
90
+ }, [queryClient]);
91
+
92
+ useEffect(() => {
93
+ if (!pollingMessageId) return;
94
+ if (!isSettledStatus(polledMessage?.status) && !isNotFound(pollError))
95
+ return;
96
+
97
+ stopWatching();
98
+ }, [polledMessage, pollError, pollingMessageId, stopWatching]);
99
+
100
+ // The cap has to put the watch down rather than only stop the interval: a
101
+ // query left enabled comes back on the next window focus, minutes later, to
102
+ // poll a send nobody is waiting on any more.
75
103
  useEffect(() => {
76
- if (!polledMessage || !pollingMessageId) return;
77
-
78
- const terminal =
79
- polledMessage.status === "sent" ||
80
- polledMessage.status === "unfiled" ||
81
- polledMessage.status === "failed" ||
82
- polledMessage.status === "blocked";
83
-
84
- if (terminal) {
85
- setPollingMessageId(undefined);
86
- queryClient.invalidateQueries({
87
- queryKey: outboxOperationsListOutboxMessagesQueryKey(),
88
- });
89
- }
90
- }, [polledMessage, pollingMessageId, queryClient]);
104
+ if (!pollingMessageId) return;
105
+ const giveUp = setTimeout(stopWatching, MAX_POLL_DURATION_MS);
106
+ return () => clearTimeout(giveUp);
107
+ }, [pollingMessageId, stopWatching]);
91
108
 
92
109
  const startSendPolling = useCallback((outboxMessageId: string) => {
93
- startedAtRef.current = Date.now();
94
110
  setPollingMessageId(outboxMessageId);
95
111
  }, []);
96
112
 
@@ -7,8 +7,10 @@ import {
7
7
  isAbortError,
8
8
  isClientBug,
9
9
  isNetworkError,
10
+ isNotFound,
10
11
  isServerError,
11
12
  shouldEscalate,
13
+ softErrorStatuses,
12
14
  } from "./error-classifier";
13
15
  import { NetworkError, taggedFetch } from "./network-error";
14
16
 
@@ -325,3 +327,49 @@ describe("shouldEscalate (the fail-fast decision table — #1059)", () => {
325
327
  );
326
328
  });
327
329
  });
330
+
331
+ /**
332
+ * The send watch reads a row the server deletes on success, so the 404 is the
333
+ * outcome it is there to see. Everything else that endpoint can answer is still
334
+ * news — a lapsed session most of all, because a background read that renders it
335
+ * as nothing leaves the user on a screen that has quietly stopped working.
336
+ */
337
+ describe("meta.softErrorStatuses (one outcome owned, the rest not)", () => {
338
+ const ownsNotFound = softErrorStatuses(404);
339
+
340
+ it("does NOT escalate the status the call site named", () => {
341
+ assert.equal(
342
+ shouldEscalate(new ApiError("gone", 404), ownsNotFound, "nobody"),
343
+ false,
344
+ );
345
+ });
346
+
347
+ it("escalates every other 4xx, the lapsed session included", () => {
348
+ assert.equal(
349
+ shouldEscalate(new ApiError("signed out", 401), ownsNotFound, "nobody"),
350
+ true,
351
+ );
352
+ assert.equal(
353
+ shouldEscalate(new ApiError("not yours", 403), ownsNotFound, "nobody"),
354
+ true,
355
+ );
356
+ });
357
+
358
+ it("escalates a 5xx and a client bug, as every opt-out does", () => {
359
+ assert.equal(
360
+ shouldEscalate(new ApiError("boom", 500), ownsNotFound, "nobody"),
361
+ true,
362
+ );
363
+ assert.equal(
364
+ shouldEscalate(new TypeError("x is undefined"), ownsNotFound, "nobody"),
365
+ true,
366
+ );
367
+ });
368
+
369
+ it("names the record's absence, whichever client raised it", () => {
370
+ assert.equal(isNotFound(new ApiError("gone", 404)), true);
371
+ assert.equal(isNotFound({ status: 404 }), true);
372
+ assert.equal(isNotFound(new ApiError("gone", 410)), false);
373
+ assert.equal(isNotFound(new TypeError("x is undefined")), false);
374
+ });
375
+ });
@@ -132,8 +132,16 @@ export const isUnauthenticated = (error: unknown): boolean =>
132
132
  */
133
133
  export type Awaiting = "user" | "nobody";
134
134
 
135
- const isSoftErrorMeta = (meta: Record<string, unknown> | undefined): boolean =>
136
- meta?.softError === true;
135
+ const isSoftErrorMeta = (
136
+ error: unknown,
137
+ meta: Record<string, unknown> | undefined,
138
+ ): boolean => {
139
+ if (meta?.softError === true) return true;
140
+ const owned = meta?.softErrorStatuses;
141
+ if (!Array.isArray(owned)) return false;
142
+ const status = getErrorStatus(error);
143
+ return status !== undefined && owned.includes(status);
144
+ };
137
145
 
138
146
  /**
139
147
  * The single fail-fast decision: should this error escalate to the full-screen
@@ -157,7 +165,8 @@ const isSoftErrorMeta = (meta: Record<string, unknown> | undefined): boolean =>
157
165
  * reconnect/retry;
158
166
  * c. a non-5xx error on a query/mutation that opted out via
159
167
  * `meta.softError === true` — the call site owns that error's UX
160
- * (e.g. a 404 empty state, a 4xx "Reconnect" banner).
168
+ * (e.g. a 404 empty state, a 4xx "Reconnect" banner) — or, where it owns
169
+ * one status and not the rest, `meta.softErrorStatuses`.
161
170
  */
162
171
  export const shouldEscalate = (
163
172
  error: unknown,
@@ -169,7 +178,7 @@ export const shouldEscalate = (
169
178
  if (isNetworkError(error)) return false;
170
179
  if (awaiting === "user" && isUnauthenticated(error)) return true;
171
180
  if (isAlwaysFatal(error)) return true;
172
- if (isSoftErrorMeta(meta)) return false;
181
+ if (isSoftErrorMeta(error, meta)) return false;
173
182
  return true;
174
183
  };
175
184
 
@@ -188,3 +197,18 @@ export const shouldEscalate = (
188
197
  * than the failure it reports.
189
198
  */
190
199
  export const softErrorMeta: { softError: true } = { softError: true };
200
+
201
+ /**
202
+ * The narrow form, for a call site that owns one outcome and none of the others.
203
+ * A read whose record may legitimately be gone owns the 404 and nothing else: a
204
+ * blanket `softErrorMeta` there would also swallow the 401 and the 403, and a
205
+ * background read that answers a lapsed session with nothing leaves the user
206
+ * looking at a screen that has quietly stopped working.
207
+ */
208
+ export const softErrorStatuses = (
209
+ ...statuses: number[]
210
+ ): { softErrorStatuses: number[] } => ({ softErrorStatuses: statuses });
211
+
212
+ /** The record is not there. */
213
+ export const isNotFound = (error: unknown): boolean =>
214
+ getErrorStatus(error) === 404;