@remit/web-client 0.0.198 → 0.0.200

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.198",
3
+ "version": "0.0.200",
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": {
@@ -1,17 +1,24 @@
1
1
  import { addressOperationsSearchAddressesOptions } from "@remit/api-http-client/@tanstack/react-query.gen.ts";
2
- import { type AddressEntry, ComposeAddressField } from "@remit/ui";
2
+ import {
3
+ type AddressEntry,
4
+ ComposeAddressField,
5
+ type ComposeAddressFieldHandle,
6
+ type ParsedAddressInput,
7
+ } from "@remit/ui";
3
8
  import { useQuery } from "@tanstack/react-query";
4
- import { useMemo, useState } from "react";
9
+ import { type Ref, useMemo, useState } from "react";
5
10
  import { useDebouncedValue } from "@/hooks/useDebouncedValue";
6
11
  import { offerableAsRecipient } from "@/lib/recipient-suggestions";
7
12
 
8
- export type { AddressEntry };
13
+ export type { AddressEntry, ComposeAddressFieldHandle, ParsedAddressInput };
9
14
 
10
15
  interface AddressFieldProps {
11
16
  label: string;
12
17
  addresses: AddressEntry[];
13
18
  onChange: (addresses: AddressEntry[]) => void;
14
19
  placeholder?: string;
20
+ onPendingChange?: (pending: ParsedAddressInput) => void;
21
+ ref?: Ref<ComposeAddressFieldHandle>;
15
22
  }
16
23
 
17
24
  /** Looks the account's known correspondents up for the field to offer. */
@@ -20,6 +27,8 @@ export const AddressField = ({
20
27
  addresses,
21
28
  onChange,
22
29
  placeholder,
30
+ onPendingChange,
31
+ ref,
23
32
  }: AddressFieldProps) => {
24
33
  const [query, setQuery] = useState("");
25
34
  const debouncedQuery = useDebouncedValue(query, 200);
@@ -48,6 +57,8 @@ export const AddressField = ({
48
57
  placeholder={placeholder}
49
58
  suggestions={suggestions}
50
59
  onQueryChange={setQuery}
60
+ onPendingChange={onPendingChange}
61
+ ref={ref}
51
62
  />
52
63
  );
53
64
  };
@@ -28,6 +28,7 @@ import {
28
28
  } from "@remit/ui";
29
29
  import type { ComposeBodyMode } from "@remit/ui/rich-text";
30
30
  import { useMutation, useQuery } from "@tanstack/react-query";
31
+ import type { RefObject } from "react";
31
32
  import {
32
33
  lazy,
33
34
  Suspense,
@@ -47,7 +48,11 @@ import {
47
48
  buildMutationErrorBanner,
48
49
  formatErrorDetail,
49
50
  } from "../ui/error-banners.js";
50
- import type { AddressEntry } from "./AddressField";
51
+ import type {
52
+ AddressEntry,
53
+ ComposeAddressFieldHandle,
54
+ ParsedAddressInput,
55
+ } from "./AddressField";
51
56
  import { AddressField } from "./AddressField";
52
57
  import { ComposeSmtpMissingBanner } from "./ComposeSmtpMissingBanner";
53
58
  import { composeSpellcheck } from "./compose-spellcheck.js";
@@ -213,6 +218,20 @@ type SendReadiness =
213
218
  | { status: "blocked"; reason: string }
214
219
  | { status: "ready"; accountId: string };
215
220
 
221
+ /** The three recipient lists a send goes out with. */
222
+ interface Recipients {
223
+ to: AddressEntry[];
224
+ cc: AddressEntry[];
225
+ bcc: AddressEntry[];
226
+ }
227
+
228
+ /** The fields a send takes its recipients from, in the order they are read. */
229
+ interface AddressFieldHandles {
230
+ to: RefObject<ComposeAddressFieldHandle | null>;
231
+ cc: RefObject<ComposeAddressFieldHandle | null>;
232
+ bcc: RefObject<ComposeAddressFieldHandle | null>;
233
+ }
234
+
216
235
  /**
217
236
  * Naming To, not "a recipient". Sending goes through the draft, and a draft is
218
237
  * created against `CreateOutboxMessageInput`, whose `@minItems(1)` is on
@@ -224,6 +243,28 @@ type SendReadiness =
224
243
  */
225
244
  const NO_TO_ADDRESS_MESSAGE = "Add a To address before sending.";
226
245
 
246
+ /** A field holding text that is not an address, and which field it is. */
247
+ interface UnparsedField {
248
+ label: string;
249
+ text: string;
250
+ }
251
+
252
+ /**
253
+ * Text a recipient field could not read as an address stops the send and is
254
+ * quoted back. It is on screen and it was meant for somebody, so sending
255
+ * without it would deliver a message to fewer people than it was addressed to,
256
+ * and the composer closing on the send would take the text with it.
257
+ */
258
+ const unparsedRefusal = ({ label, text }: UnparsedField): string =>
259
+ `${label} holds "${text}", which is not an address.`;
260
+
261
+ const firstUnparsed = (
262
+ fields: readonly UnparsedField[],
263
+ ): UnparsedField | undefined =>
264
+ fields.find((field) => field.text.trim() !== "");
265
+
266
+ const EMPTY_PENDING: ParsedAddressInput = { entries: [], unparsed: "" };
267
+
227
268
  const isFormEmpty = (
228
269
  toAddresses: AddressEntry[],
229
270
  ccAddresses: AddressEntry[],
@@ -257,6 +298,8 @@ interface WiredComposeHeaderProps {
257
298
  setShowBcc: (v: boolean) => void;
258
299
  subject: string;
259
300
  setSubject: (v: string) => void;
301
+ fieldHandles: AddressFieldHandles;
302
+ onToPendingChange: (pending: ParsedAddressInput) => void;
260
303
  }
261
304
 
262
305
  const WiredComposeHeader = ({
@@ -275,6 +318,8 @@ const WiredComposeHeader = ({
275
318
  setShowBcc,
276
319
  subject,
277
320
  setSubject,
321
+ fieldHandles,
322
+ onToPendingChange,
278
323
  }: WiredComposeHeaderProps) => {
279
324
  const isDesktop = useIsDesktop();
280
325
  const { isKeyboardOpen } = useVisualViewport();
@@ -307,6 +352,8 @@ const WiredComposeHeader = ({
307
352
  addresses={toAddresses}
308
353
  onChange={setToAddresses}
309
354
  placeholder="Recipients"
355
+ onPendingChange={onToPendingChange}
356
+ ref={fieldHandles.to}
310
357
  />
311
358
  }
312
359
  cc={
@@ -315,6 +362,7 @@ const WiredComposeHeader = ({
315
362
  label="Cc"
316
363
  addresses={ccAddresses}
317
364
  onChange={setCcAddresses}
365
+ ref={fieldHandles.cc}
318
366
  />
319
367
  ) : undefined
320
368
  }
@@ -324,6 +372,7 @@ const WiredComposeHeader = ({
324
372
  label="Bcc"
325
373
  addresses={bccAddresses}
326
374
  onChange={setBccAddresses}
375
+ ref={fieldHandles.bcc}
327
376
  />
328
377
  ) : undefined
329
378
  }
@@ -350,6 +399,25 @@ export const ComposeForm = ({
350
399
  const [toAddresses, setToAddresses] = useState<AddressEntry[]>([]);
351
400
  const [ccAddresses, setCcAddresses] = useState<AddressEntry[]>([]);
352
401
  const [bccAddresses, setBccAddresses] = useState<AddressEntry[]>([]);
402
+ /**
403
+ * What To is holding but has not committed. A press on Send takes it, so the
404
+ * refusal for having no recipient must not stand while an address is on
405
+ * screen — the button's own reason comes from this state, before the press.
406
+ */
407
+ const [pendingTo, setPendingTo] = useState<ParsedAddressInput>(EMPTY_PENDING);
408
+ const toFieldRef = useRef<ComposeAddressFieldHandle>(null);
409
+ const ccFieldRef = useRef<ComposeAddressFieldHandle>(null);
410
+ const bccFieldRef = useRef<ComposeAddressFieldHandle>(null);
411
+ const fieldHandles = useMemo<AddressFieldHandles>(
412
+ () => ({ to: toFieldRef, cc: ccFieldRef, bcc: bccFieldRef }),
413
+ [],
414
+ );
415
+ /** A new document leaves nothing typed behind in a field that did not remount. */
416
+ const clearPendingFields = useCallback(() => {
417
+ toFieldRef.current?.clearPending();
418
+ ccFieldRef.current?.clearPending();
419
+ bccFieldRef.current?.clearPending();
420
+ }, []);
353
421
  const [subject, setSubject] = useState("");
354
422
  const [showCc, setShowCc] = useState(false);
355
423
  const [showBcc, setShowBcc] = useState(false);
@@ -420,6 +488,7 @@ export const ComposeForm = ({
420
488
  setToAddresses([]);
421
489
  setCcAddresses([]);
422
490
  setBccAddresses([]);
491
+ clearPendingFields();
423
492
  setSubject("");
424
493
  setShowCc(false);
425
494
  setShowBcc(false);
@@ -431,7 +500,7 @@ export const ComposeForm = ({
431
500
  setDocumentGeneration((generation) => generation + 1);
432
501
  setDraftLoaded(false);
433
502
  setOpenDocumentId(outboxMessageId);
434
- }, [outboxMessageId]);
503
+ }, [outboxMessageId, clearPendingFields]);
435
504
 
436
505
  const { signature } = useSignature(selectedAccountId);
437
506
  // Read when a new message starts, never depended on: a signature arriving is
@@ -527,6 +596,11 @@ export const ComposeForm = ({
527
596
  if (resumedDraftRef.current) return;
528
597
  if (!sourceMessage) return;
529
598
 
599
+ // The fields are being rewritten for a different answer, so what was typed
600
+ // into one and left there belongs to the answer being left behind. Without
601
+ // this the blur timer commits it into the new one 150 ms later.
602
+ clearPendingFields();
603
+
530
604
  if (mode === "reply" || mode === "reply-all") {
531
605
  const { to, cc } = getReplyAddresses(sourceMessage, mode, account?.email);
532
606
  setToAddresses(to);
@@ -545,7 +619,7 @@ export const ComposeForm = ({
545
619
  setBccAddresses([]);
546
620
  setSubject(buildForwardSubject(sourceMessage.envelope.subject));
547
621
  }
548
- }, [mode, sourceMessage, account?.email]);
622
+ }, [mode, sourceMessage, account?.email, clearPendingFields]);
549
623
 
550
624
  // Quoted reply/forward content lives at the per-part `contentUrl` since
551
625
  // #224 PR 3 — fetch it via the same hook MessageBody uses, and degrade
@@ -666,28 +740,42 @@ export const ComposeForm = ({
666
740
  // no-op: the state it reads has no way to be blocked without a reason. A
667
741
  // ready state carries the account the message goes out from, so the send
668
742
  // path has no condition of its own left to refuse on in silence.
669
- const sendReadiness = useMemo<SendReadiness>(() => {
670
- if (isSending) return { status: "sending" };
671
- if (!selectedAccountId) {
672
- return { status: "blocked", reason: "Choose an account to send from." };
673
- }
674
- if (selectedAccountMissingSmtp) {
675
- return { status: "blocked", reason: SMTP_MISSING_MESSAGE };
676
- }
677
- if (toAddresses.length === 0) {
678
- return { status: "blocked", reason: NO_TO_ADDRESS_MESSAGE };
679
- }
680
- return { status: "ready", accountId: selectedAccountId };
681
- }, [
682
- isSending,
683
- selectedAccountId,
684
- selectedAccountMissingSmtp,
685
- toAddresses.length,
686
- ]);
743
+ const readinessFor = useCallback(
744
+ (toCount: number, unparsed: UnparsedField | undefined): SendReadiness => {
745
+ if (isSending) return { status: "sending" };
746
+ if (!selectedAccountId) {
747
+ return { status: "blocked", reason: "Choose an account to send from." };
748
+ }
749
+ if (selectedAccountMissingSmtp) {
750
+ return { status: "blocked", reason: SMTP_MISSING_MESSAGE };
751
+ }
752
+ if (unparsed) {
753
+ return { status: "blocked", reason: unparsedRefusal(unparsed) };
754
+ }
755
+ if (toCount === 0) {
756
+ return { status: "blocked", reason: NO_TO_ADDRESS_MESSAGE };
757
+ }
758
+ return { status: "ready", accountId: selectedAccountId };
759
+ },
760
+ [isSending, selectedAccountId, selectedAccountMissingSmtp],
761
+ );
762
+ const sendReadiness = useMemo<SendReadiness>(
763
+ () =>
764
+ readinessFor(
765
+ toAddresses.length + pendingTo.entries.length,
766
+ firstUnparsed([{ label: "To", text: pendingTo.unparsed }]),
767
+ ),
768
+ [readinessFor, toAddresses.length, pendingTo],
769
+ );
687
770
  const sendState: ComposeSendState = sendReadiness;
688
771
 
689
772
  useEffect(() => {
690
773
  if (!selectedAccountId) return;
774
+ // Nor one that is on its way out. Send commits the address fields as it
775
+ // goes, which lands here as a recipient change and would re-arm the timer
776
+ // `stopAutoSave` has just dropped — a draft write arriving after the send
777
+ // took the row out of draft (#845.6).
778
+ if (sendInFlightRef.current) return;
691
779
  // Don't autosave a document the fields are no longer on. They trail the
692
780
  // address by a commit whenever it changes, so writing here sends the
693
781
  // message just left to whatever the address now names — the previous
@@ -734,7 +822,7 @@ export const ComposeForm = ({
734
822
  ]);
735
823
 
736
824
  const handleSend = useCallback(
737
- async (accountId: string) => {
825
+ async (accountId: string, recipients: Recipients) => {
738
826
  if (sendInFlightRef.current) return;
739
827
 
740
828
  sendInFlightRef.current = true;
@@ -760,14 +848,14 @@ export const ComposeForm = ({
760
848
  // that fails stops the send rather than transmitting the older copy.
761
849
  const flushed = await saveImmediately({
762
850
  accountId,
763
- toAddresses: toAddresses.map((a) => a.email),
851
+ toAddresses: recipients.to.map((a) => a.email),
764
852
  ccAddresses:
765
- ccAddresses.length > 0
766
- ? ccAddresses.map((a) => a.email)
853
+ recipients.cc.length > 0
854
+ ? recipients.cc.map((a) => a.email)
767
855
  : undefined,
768
856
  bccAddresses:
769
- bccAddresses.length > 0
770
- ? bccAddresses.map((a) => a.email)
857
+ recipients.bcc.length > 0
858
+ ? recipients.bcc.map((a) => a.email)
771
859
  : undefined,
772
860
  subject: subject || undefined,
773
861
  textBody,
@@ -815,9 +903,6 @@ export const ComposeForm = ({
815
903
  }
816
904
  },
817
905
  [
818
- toAddresses,
819
- ccAddresses,
820
- bccAddresses,
821
906
  subject,
822
907
  body,
823
908
  bodyMode,
@@ -856,14 +941,49 @@ export const ComposeForm = ({
856
941
  [pushError],
857
942
  );
858
943
 
944
+ /**
945
+ * Send goes out to the addresses on screen, not to the ones the fields have
946
+ * got round to committing. Each field commits on blur behind a timer, and the
947
+ * press that sends is the press that blurs — so a recipient typed and left in
948
+ * the field is taken here, in the same tick, before the message is judged to
949
+ * have anywhere to go (#845.6).
950
+ */
859
951
  const attemptSend = useCallback(() => {
860
952
  if (sendReadiness.status === "sending") return;
861
- if (sendReadiness.status === "blocked") {
862
- reportBlocked(sendReadiness.reason);
953
+
954
+ const to = fieldHandles.to.current?.commitPending();
955
+ const cc = fieldHandles.cc.current?.commitPending();
956
+ const bcc = fieldHandles.bcc.current?.commitPending();
957
+ const recipients: Recipients = {
958
+ to: to?.addresses ?? toAddresses,
959
+ cc: cc?.addresses ?? ccAddresses,
960
+ bcc: bcc?.addresses ?? bccAddresses,
961
+ };
962
+
963
+ const readiness = readinessFor(
964
+ recipients.to.length,
965
+ firstUnparsed([
966
+ { label: "To", text: to?.unparsed ?? "" },
967
+ { label: "Cc", text: cc?.unparsed ?? "" },
968
+ { label: "Bcc", text: bcc?.unparsed ?? "" },
969
+ ]),
970
+ );
971
+ if (readiness.status === "sending") return;
972
+ if (readiness.status === "blocked") {
973
+ reportBlocked(readiness.reason);
863
974
  return;
864
975
  }
865
- void handleSend(sendReadiness.accountId);
866
- }, [sendReadiness, reportBlocked, handleSend]);
976
+ void handleSend(readiness.accountId, recipients);
977
+ }, [
978
+ sendReadiness.status,
979
+ fieldHandles,
980
+ toAddresses,
981
+ ccAddresses,
982
+ bccAddresses,
983
+ readinessFor,
984
+ reportBlocked,
985
+ handleSend,
986
+ ]);
867
987
 
868
988
  const handleAccountChange = useCallback(
869
989
  (acct: RemitImapAccountResponse) => {
@@ -901,6 +1021,8 @@ export const ComposeForm = ({
901
1021
  setShowBcc={setShowBcc}
902
1022
  subject={subject}
903
1023
  setSubject={setSubject}
1024
+ fieldHandles={fieldHandles}
1025
+ onToPendingChange={setPendingTo}
904
1026
  />
905
1027
  }
906
1028
  quoted={
@@ -58,6 +58,15 @@ const isSettledStatus = (status: string | undefined): boolean =>
58
58
  */
59
59
  export const OUTBOX_ROW_META = softErrorStatuses(404);
60
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
+
61
70
  export const ComposeProvider = ({
62
71
  children,
63
72
  }: {
@@ -76,8 +85,8 @@ export const ComposeProvider = ({
76
85
  meta: OUTBOX_ROW_META,
77
86
  retry: (failureCount, error) => !isNotFound(error) && failureCount < 1,
78
87
  refetchInterval: (query) => {
79
- if (isSettledStatus(query.state.data?.status)) return false;
80
- if (isNotFound(query.state.error)) return false;
88
+ if (isWatchOver(query.state.data?.status, query.state.error))
89
+ return false;
81
90
  return POLL_INTERVAL_MS;
82
91
  },
83
92
  });
@@ -91,8 +100,7 @@ export const ComposeProvider = ({
91
100
 
92
101
  useEffect(() => {
93
102
  if (!pollingMessageId) return;
94
- if (!isSettledStatus(polledMessage?.status) && !isNotFound(pollError))
95
- return;
103
+ if (!isWatchOver(polledMessage?.status, pollError)) return;
96
104
 
97
105
  stopWatching();
98
106
  }, [polledMessage, pollError, pollingMessageId, stopWatching]);
@@ -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
+ });