@remit/web-client 0.0.197 → 0.0.199

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.199",
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,32 @@ 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
+
61
+ /**
62
+ * The watch is over on a settled status and on any error the read comes back
63
+ * with. The 404 is the settled state itself; every other refusal has already
64
+ * escalated once through the query cache, and asking again every two seconds
65
+ * only repeats that escalation — a fresh fatal event per tick — until the cap.
66
+ */
67
+ const isWatchOver = (status: string | undefined, error: unknown): boolean =>
68
+ isSettledStatus(status) || error != null;
69
+
44
70
  export const ComposeProvider = ({
45
71
  children,
46
72
  }: {
@@ -49,48 +75,46 @@ export const ComposeProvider = ({
49
75
  const [pollingMessageId, setPollingMessageId] = useState<
50
76
  string | undefined
51
77
  >();
52
- const startedAtRef = useRef(0);
53
78
  const queryClient = useQueryClient();
54
79
 
55
- const { data: polledMessage } = useQuery({
80
+ const { data: polledMessage, error: pollError } = useQuery({
56
81
  ...outboxDetailOperationsGetOutboxMessageOptions({
57
82
  path: { outboxMessageId: pollingMessageId ?? "" },
58
83
  }),
59
84
  enabled: !!pollingMessageId,
85
+ meta: OUTBOX_ROW_META,
86
+ retry: (failureCount, error) => !isNotFound(error) && failureCount < 1,
60
87
  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)
88
+ if (isWatchOver(query.state.data?.status, query.state.error))
70
89
  return false;
71
90
  return POLL_INTERVAL_MS;
72
91
  },
73
92
  });
74
93
 
94
+ const stopWatching = useCallback(() => {
95
+ setPollingMessageId(undefined);
96
+ queryClient.invalidateQueries({
97
+ queryKey: outboxOperationsListOutboxMessagesQueryKey(),
98
+ });
99
+ }, [queryClient]);
100
+
101
+ useEffect(() => {
102
+ if (!pollingMessageId) return;
103
+ if (!isWatchOver(polledMessage?.status, pollError)) return;
104
+
105
+ stopWatching();
106
+ }, [polledMessage, pollError, pollingMessageId, stopWatching]);
107
+
108
+ // The cap has to put the watch down rather than only stop the interval: a
109
+ // query left enabled comes back on the next window focus, minutes later, to
110
+ // poll a send nobody is waiting on any more.
75
111
  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]);
112
+ if (!pollingMessageId) return;
113
+ const giveUp = setTimeout(stopWatching, MAX_POLL_DURATION_MS);
114
+ return () => clearTimeout(giveUp);
115
+ }, [pollingMessageId, stopWatching]);
91
116
 
92
117
  const startSendPolling = useCallback((outboxMessageId: string) => {
93
- startedAtRef.current = Date.now();
94
118
  setPollingMessageId(outboxMessageId);
95
119
  }, []);
96
120
 
@@ -0,0 +1,120 @@
1
+ /**
2
+ * Issue #924: the send watch decided its next tick from the polled status
3
+ * alone. A 5xx carries no status, so nothing matched, the interval stayed at two
4
+ * seconds, and every failed tick reached the query cache and escalated again —
5
+ * one overlay for the user, a fresh fatal event with a fresh correlation id for
6
+ * telemetry, for the whole minute the cap allowed.
7
+ *
8
+ * A poll the server refuses is over. It escalates once, the watch comes down,
9
+ * and the outbox list is invalidated so the row's real state is what the user
10
+ * reads next.
11
+ */
12
+
13
+ import assert from "node:assert/strict";
14
+ import { afterEach, describe, it } from "node:test";
15
+ import { outboxOperationsListOutboxMessagesQueryKey } from "@remit/api-http-client/@tanstack/react-query.gen.ts";
16
+ import { QueryCache, QueryClient } from "@tanstack/react-query";
17
+ import { createElement, useEffect } from "react";
18
+ import { __resetFatalError, subscribeFatalError } from "../../lib/fatal-error";
19
+ import { handleQueryCacheError } from "../../lib/query-error-handler";
20
+ import { createDomHarness, type DomHarness } from "../../test-support/dom";
21
+ import { type HttpMock, httpError, mockFetch } from "../../test-support/http";
22
+ import { ComposeProvider, useCompose } from "./ComposeProvider";
23
+
24
+ const OUTBOX_MESSAGE_ID = "ob-924";
25
+ const POLL_INTERVAL_MS = 2000;
26
+
27
+ let harness: DomHarness | undefined;
28
+ let http: HttpMock | undefined;
29
+ let fatals = 0;
30
+ let unsubscribe: (() => void) | undefined;
31
+
32
+ afterEach(() => {
33
+ harness?.close();
34
+ harness = undefined;
35
+ http?.restore();
36
+ http = undefined;
37
+ unsubscribe?.();
38
+ unsubscribe = undefined;
39
+ fatals = 0;
40
+ __resetFatalError();
41
+ });
42
+
43
+ const polls = (): number =>
44
+ (http?.calls ?? []).filter(
45
+ (call) => call.method === "GET" && call.path.endsWith(OUTBOX_MESSAGE_ID),
46
+ ).length;
47
+
48
+ const outboxListIsStale = (): boolean =>
49
+ harness?.queryClient
50
+ .getQueryCache()
51
+ .find({ queryKey: outboxOperationsListOutboxMessagesQueryKey() })?.state
52
+ .isInvalidated === true;
53
+
54
+ const Sender = () => {
55
+ const { startSendPolling } = useCompose();
56
+
57
+ useEffect(() => {
58
+ startSendPolling(OUTBOX_MESSAGE_ID);
59
+ }, [startSendPolling]);
60
+
61
+ return null;
62
+ };
63
+
64
+ const mount = async (pollStatus: number): Promise<void> => {
65
+ http = mockFetch(async (call) => {
66
+ if (call.path.endsWith(OUTBOX_MESSAGE_ID)) {
67
+ return httpError(pollStatus, "the outbox is having a moment");
68
+ }
69
+ return { messages: [] };
70
+ });
71
+
72
+ unsubscribe = subscribeFatalError(() => {
73
+ fatals += 1;
74
+ });
75
+
76
+ const queryClient = new QueryClient({
77
+ queryCache: new QueryCache({ onError: handleQueryCacheError }),
78
+ // The watch sets its own retry policy; a zero delay only spares the test
79
+ // the backoff it would otherwise sit through.
80
+ defaultOptions: { queries: { retryDelay: 0 }, mutations: { retry: false } },
81
+ });
82
+
83
+ harness = createDomHarness({ queryClient });
84
+ queryClient.setQueryData(outboxOperationsListOutboxMessagesQueryKey(), {
85
+ messages: [],
86
+ });
87
+ harness.renderApp(
88
+ createElement(ComposeProvider, null, createElement(Sender)),
89
+ );
90
+ await harness.flush();
91
+ };
92
+
93
+ describe("a send watch the server refuses (#924)", () => {
94
+ it("escalates once and stops polling", async () => {
95
+ await mount(500);
96
+ await harness?.wait(POLL_INTERVAL_MS * 3);
97
+
98
+ assert.equal(fatals, 1, "the refusal escalated exactly once");
99
+ assert.equal(
100
+ polls(),
101
+ 2,
102
+ "the read was attempted once and retried once, then the watch came down",
103
+ );
104
+ });
105
+
106
+ it("invalidates the outbox list so the row's real state is on screen", async () => {
107
+ await mount(500);
108
+ await harness?.wait(POLL_INTERVAL_MS);
109
+
110
+ assert.ok(outboxListIsStale(), "the outbox list was marked for a refetch");
111
+ });
112
+
113
+ it("leaves the settled 404 alone — one stop, no escalation", async () => {
114
+ await mount(404);
115
+ await harness?.wait(POLL_INTERVAL_MS * 2);
116
+
117
+ assert.equal(fatals, 0, "the deleted row is the confirmation, not a fault");
118
+ assert.equal(polls(), 1, "a 404 is not retried and not polled again");
119
+ });
120
+ });
@@ -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;