@remit/web-client 0.0.134 → 0.0.135

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.134",
3
+ "version": "0.0.135",
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": {
@@ -3,7 +3,6 @@ import {
3
3
  outboxDetailOperationsDeleteOutboxMessageMutation,
4
4
  outboxDetailOperationsGetOutboxMessageOptions,
5
5
  outboxDetailOperationsSendOutboxMessageMutation,
6
- outboxOperationsCreateOutboxMessageMutation,
7
6
  } from "@remit/api-http-client/@tanstack/react-query.gen.ts";
8
7
  import type {
9
8
  RemitImapAccountResponse,
@@ -442,10 +441,11 @@ export const ComposeForm = ({
442
441
  [setOutboxMessageId],
443
442
  );
444
443
 
445
- const { saveStatus, saveError, saveDraft, stopAutoSave } = useSaveDraft({
446
- outboxMessageId,
447
- onDraftCreated: adoptCreatedDraft,
448
- });
444
+ const { saveStatus, saveError, saveDraft, saveImmediately, stopAutoSave } =
445
+ useSaveDraft({
446
+ outboxMessageId,
447
+ onDraftCreated: adoptCreatedDraft,
448
+ });
449
449
 
450
450
  // Auto-save runs on a debounce, so a failure has no inline call site to
451
451
  // surface it. Push the real error detail to a banner instead of leaving only
@@ -460,10 +460,6 @@ export const ComposeForm = ({
460
460
  });
461
461
  }, [saveError, pushError]);
462
462
 
463
- const createMutation = useMutation(
464
- outboxOperationsCreateOutboxMessageMutation(),
465
- );
466
-
467
463
  const sendMutation = useMutation(
468
464
  outboxDetailOperationsSendOutboxMessageMutation(),
469
465
  );
@@ -496,7 +492,11 @@ export const ComposeForm = ({
496
492
  ? accountIsMissingSmtp(selectedAccount)
497
493
  : false;
498
494
 
499
- const isSending = createMutation.isPending || sendMutation.isPending;
495
+ // The action bar refuses a second press while one is in flight, but the
496
+ // editor's own Cmd+Enter goes straight to `handleSend`, and the write that
497
+ // now precedes the request widens the window a second press lands in.
498
+ const sendInFlightRef = useRef(false);
499
+ const [isSending, setIsSending] = useState(false);
500
500
  const canSend =
501
501
  toAddresses.length > 0 &&
502
502
  !!selectedAccountId &&
@@ -538,76 +538,79 @@ export const ComposeForm = ({
538
538
  ]);
539
539
 
540
540
  const handleSend = useCallback(async () => {
541
+ if (sendInFlightRef.current) return;
541
542
  if (!selectedAccountId || toAddresses.length === 0) return;
542
543
 
543
- stopAutoSave();
544
-
545
- const replyData =
546
- sourceMessage && (mode === "reply" || mode === "reply_all")
547
- ? getReferences(sourceMessage)
548
- : {};
544
+ sendInFlightRef.current = true;
545
+ setIsSending(true);
546
+ try {
547
+ stopAutoSave();
549
548
 
550
- let messageId = outboxMessageId;
551
- let createdThisAttempt = false;
549
+ const replyData =
550
+ sourceMessage && (mode === "reply" || mode === "reply_all")
551
+ ? getReferences(sourceMessage)
552
+ : {};
552
553
 
553
- if (!messageId) {
554
554
  const { html: htmlBody, text: textBody } = body;
555
+ const createdThisAttempt = !outboxMessageId;
556
+
557
+ // The debounce dropped above may have been holding the last two seconds
558
+ // of typing, and an existing entry would otherwise go out as the server
559
+ // last saw it (#674). What is on screen is written first, and a write
560
+ // that fails stops the send rather than transmitting the older copy.
561
+ const flushed = await saveImmediately({
562
+ accountId: selectedAccountId,
563
+ toAddresses: toAddresses.map((a) => a.email),
564
+ ccAddresses:
565
+ ccAddresses.length > 0 ? ccAddresses.map((a) => a.email) : undefined,
566
+ bccAddresses:
567
+ bccAddresses.length > 0
568
+ ? bccAddresses.map((a) => a.email)
569
+ : undefined,
570
+ subject: subject || undefined,
571
+ textBody: textBody || undefined,
572
+ htmlBody: htmlBody || undefined,
573
+ ...replyData,
574
+ });
555
575
 
556
- const outboxMessage = await createMutation
576
+ if (flushed.outcome === "failed") {
577
+ pushError({
578
+ title: "Couldn't send message",
579
+ detail:
580
+ formatErrorDetail(flushed.error) ??
581
+ "Saving the message failed, so nothing was sent. Try again.",
582
+ error: flushed.error,
583
+ });
584
+ return;
585
+ }
586
+
587
+ const messageId = flushed.outboxMessageId;
588
+
589
+ const sent = await sendMutation
557
590
  .mutateAsync({
558
- body: {
559
- accountId: selectedAccountId,
560
- toAddresses: toAddresses.map((a) => a.email),
561
- ccAddresses:
562
- ccAddresses.length > 0
563
- ? ccAddresses.map((a) => a.email)
564
- : undefined,
565
- bccAddresses:
566
- bccAddresses.length > 0
567
- ? bccAddresses.map((a) => a.email)
568
- : undefined,
569
- subject: subject || undefined,
570
- textBody: textBody || undefined,
571
- htmlBody: htmlBody || undefined,
572
- sendImmediately: false,
573
- ...replyData,
574
- },
591
+ path: { outboxMessageId: messageId },
575
592
  })
576
593
  .catch((error: unknown) => {
577
594
  pushError({
578
595
  title: "Couldn't send message",
579
- detail: formatErrorDetail(error) ?? "Saving the draft failed.",
596
+ detail:
597
+ formatErrorDetail(error) ??
598
+ (createdThisAttempt
599
+ ? "The draft was saved but the send request failed. Try again from the Outbox."
600
+ : "The send request failed. Try again."),
580
601
  error,
581
602
  });
582
603
  return null;
583
604
  });
584
- if (outboxMessage === null) return;
585
- messageId = outboxMessage.outboxMessageId;
586
- createdThisAttempt = true;
587
- setOutboxMessageId(messageId);
605
+ if (sent === null) return;
606
+
607
+ stopAutoSave(messageId);
608
+ startSendPolling(messageId);
609
+ onClose();
610
+ } finally {
611
+ sendInFlightRef.current = false;
612
+ setIsSending(false);
588
613
  }
589
-
590
- const sent = await sendMutation
591
- .mutateAsync({
592
- path: { outboxMessageId: messageId },
593
- })
594
- .catch((error: unknown) => {
595
- pushError({
596
- title: "Couldn't send message",
597
- detail:
598
- formatErrorDetail(error) ??
599
- (createdThisAttempt
600
- ? "The draft was saved but the send request failed. Try again from the Outbox."
601
- : "The send request failed. Try again."),
602
- error,
603
- });
604
- return null;
605
- });
606
- if (sent === null) return;
607
-
608
- stopAutoSave(messageId);
609
- startSendPolling(messageId);
610
- onClose();
611
614
  }, [
612
615
  selectedAccountId,
613
616
  toAddresses,
@@ -618,11 +621,10 @@ export const ComposeForm = ({
618
621
  mode,
619
622
  sourceMessage,
620
623
  outboxMessageId,
621
- createMutation,
624
+ saveImmediately,
622
625
  sendMutation,
623
626
  stopAutoSave,
624
627
  startSendPolling,
625
- setOutboxMessageId,
626
628
  pushError,
627
629
  onClose,
628
630
  ]);
@@ -14,6 +14,12 @@
14
14
  * then sends it — and creating it as already-queued makes that send a second
15
15
  * dispatch, which the server refuses.
16
16
  *
17
+ * Issue #674 is the boundary between the two: pressing Send inside the two
18
+ * seconds the debounce is still counting down. The cancelled timer was carrying
19
+ * the last edits, and the entry went out as the server last saw it. So the send
20
+ * writes what is on screen first, and a write that fails takes the send with it
21
+ * rather than transmitting the older copy behind the user's back.
22
+ *
17
23
  * The stub holds the outbox rule the API holds: only a draft accepts a send.
18
24
  */
19
25
 
@@ -81,8 +87,18 @@ afterEach(() => {
81
87
  const callsTo = (suffix: string) =>
82
88
  (http?.calls ?? []).filter((call) => call.path.endsWith(suffix));
83
89
 
84
- const patchCount = (): number =>
85
- (http?.calls ?? []).filter((call) => call.method === "PATCH").length;
90
+ const patches = () =>
91
+ (http?.calls ?? []).filter((call) => call.method === "PATCH");
92
+
93
+ const patchCount = (): number => patches().length;
94
+
95
+ const patchesAfterTheSend = (): number => {
96
+ const calls = http?.calls ?? [];
97
+ const sendIndex = calls.findIndex((call) => call.path.endsWith("/send"));
98
+ if (sendIndex === -1) throw new Error("the send never went out");
99
+ return calls.slice(sendIndex + 1).filter((call) => call.method === "PATCH")
100
+ .length;
101
+ };
86
102
 
87
103
  const Opened = ({ outboxMessageId }: { outboxMessageId?: string }) => {
88
104
  const { state, openCompose } = useCompose();
@@ -108,6 +124,8 @@ interface MountOptions {
108
124
  outboxMessageId?: string;
109
125
  /** Hold the PATCH response open so a save is still in flight at send time. */
110
126
  gatePatch?: boolean;
127
+ /** Refuse every PATCH, so the write compose makes before sending fails. */
128
+ failPatch?: boolean;
111
129
  }
112
130
 
113
131
  const mount = async (
@@ -140,6 +158,7 @@ const mount = async (
140
158
 
141
159
  if (call.method === "PATCH") {
142
160
  await patchGate;
161
+ if (options.failPatch) return httpError(409, "The draft moved on.");
143
162
  return outboxEntry(status);
144
163
  }
145
164
 
@@ -172,7 +191,7 @@ const sendButton = (): HTMLElement => {
172
191
  return button;
173
192
  };
174
193
 
175
- describe("compose and the outbox entry it is sending (#604)", () => {
194
+ describe("compose and the outbox entry it is sending (#604, #674)", () => {
176
195
  it("writes no further PATCH to an entry once it has been sent", async () => {
177
196
  const { releasePatch } = await mount({
178
197
  outboxMessageId: OUTBOX_MESSAGE_ID,
@@ -193,7 +212,76 @@ describe("compose and the outbox entry it is sending (#604)", () => {
193
212
  await harness?.wait(AUTOSAVE_DEBOUNCE_MS + 200);
194
213
 
195
214
  assert.equal(callsTo("/send").length, 1, "the send went out");
196
- assert.equal(patchCount(), 1, "no autosave PATCH followed the send");
215
+ assert.equal(patchesAfterTheSend(), 0, "no write followed the send");
216
+ // The in-flight autosave and the write before the send are the two, in
217
+ // that order: a second write issued alongside the first could land behind
218
+ // it and put the older body back.
219
+ assert.equal(
220
+ patchCount(),
221
+ 2,
222
+ "the two writes went out one after the other",
223
+ );
224
+ });
225
+
226
+ it("writes the edit made inside the debounce window before it sends", async () => {
227
+ await mount({ outboxMessageId: OUTBOX_MESSAGE_ID });
228
+
229
+ harness?.type(subjectField(), "Re: Lunch tomorrow");
230
+ await harness?.wait(AUTOSAVE_DEBOUNCE_MS + 100);
231
+ assert.equal(patchCount(), 1, "the typing burst autosaves once");
232
+
233
+ // Inside the two seconds the next autosave would have waited.
234
+ harness?.type(subjectField(), "Re: Lunch on Thursday");
235
+ harness?.click(sendButton());
236
+ await harness?.flush();
237
+ await harness?.wait(100);
238
+
239
+ assert.equal(callsTo("/send").length, 1, "the send went out");
240
+ const written = patches().at(-1);
241
+ assert.equal(
242
+ written?.body?.subject,
243
+ "Re: Lunch on Thursday",
244
+ "the entry carries the edit the debounce was still holding",
245
+ );
246
+ // Autosave never writes these, so an entry autosaved before Send used to
247
+ // go out unchained from the message it replies to.
248
+ assert.equal(written?.body?.inReplyTo, "<m1@example.com>");
249
+ assert.deepEqual(written?.body?.references, ["<m1@example.com>"]);
250
+ assert.equal(closed, 1, "compose closed on a successful send");
251
+ });
252
+
253
+ it("sends nothing, and says so, when that write fails", async () => {
254
+ await mount({ outboxMessageId: OUTBOX_MESSAGE_ID, failPatch: true });
255
+
256
+ harness?.type(subjectField(), "Re: Lunch on Thursday");
257
+ harness?.click(sendButton());
258
+ await harness?.flush();
259
+ await harness?.wait(100);
260
+
261
+ assert.equal(patchCount(), 1, "the write was attempted");
262
+ assert.equal(callsTo("/send").length, 0, "the older copy stayed put");
263
+ assert.equal(closed, 0, "compose stayed open");
264
+ assert.match(harness?.text() ?? "", /Couldn't send message/);
265
+ });
266
+
267
+ it("dispatches once when Send is pressed twice over the write", async () => {
268
+ const { releasePatch } = await mount({
269
+ outboxMessageId: OUTBOX_MESSAGE_ID,
270
+ gatePatch: true,
271
+ });
272
+
273
+ harness?.type(subjectField(), "Re: Lunch on Thursday");
274
+ harness?.click(sendButton());
275
+ harness?.click(sendButton());
276
+ await harness?.flush();
277
+
278
+ releasePatch();
279
+ await harness?.flush();
280
+ await harness?.wait(100);
281
+
282
+ assert.equal(patchCount(), 1, "the entry was written once");
283
+ assert.equal(callsTo("/send").length, 1, "the message went out once");
284
+ assert.equal(closed, 1, "compose closed on a successful send");
197
285
  });
198
286
 
199
287
  it("sends a reply pressed before the first autosave, in one dispatch", async () => {
@@ -8,6 +8,10 @@ import { useCallback, useRef, useState } from "react";
8
8
 
9
9
  export type SaveStatus = "idle" | "saving" | "saved" | "error";
10
10
 
11
+ export type ImmediateSave =
12
+ | { outcome: "saved"; outboxMessageId: string }
13
+ | { outcome: "failed"; error: unknown };
14
+
11
15
  interface DraftData {
12
16
  accountId: string;
13
17
  toAddresses: string[];
@@ -25,6 +29,12 @@ interface UseSaveDraftOptions {
25
29
  onDraftCreated: (id: string) => void;
26
30
  }
27
31
 
32
+ const settled = (promise: Promise<unknown>): Promise<void> =>
33
+ promise.then(
34
+ () => undefined,
35
+ () => undefined,
36
+ );
37
+
28
38
  export const useSaveDraft = ({
29
39
  outboxMessageId,
30
40
  onDraftCreated,
@@ -35,6 +45,17 @@ export const useSaveDraft = ({
35
45
  const closedIdsRef = useRef<Set<string>>(new Set());
36
46
  const queryClient = useQueryClient();
37
47
 
48
+ // The entry a save writes to. That is the prop, except between a save
49
+ // creating the draft and the id arriving back as the prop — reading the prop
50
+ // in that window creates the same draft a second time and strands one of the
51
+ // two in the outbox.
52
+ const propIdRef = useRef(outboxMessageId);
53
+ const targetIdRef = useRef(outboxMessageId);
54
+ if (propIdRef.current !== outboxMessageId) {
55
+ propIdRef.current = outboxMessageId;
56
+ targetIdRef.current = outboxMessageId;
57
+ }
58
+
38
59
  const createMutation = useMutation(
39
60
  outboxOperationsCreateOutboxMessageMutation(),
40
61
  );
@@ -47,9 +68,10 @@ export const useSaveDraft = ({
47
68
  setSaveStatus("saving");
48
69
  setSaveError(null);
49
70
 
50
- if (outboxMessageId) {
71
+ const targetId = targetIdRef.current;
72
+ if (targetId) {
51
73
  const result = await updateMutation.mutateAsync({
52
- path: { outboxMessageId },
74
+ path: { outboxMessageId: targetId },
53
75
  body: {
54
76
  toAddresses: data.toAddresses,
55
77
  ccAddresses: data.ccAddresses,
@@ -71,6 +93,7 @@ export const useSaveDraft = ({
71
93
  sendImmediately: false,
72
94
  },
73
95
  });
96
+ targetIdRef.current = result.outboxMessageId;
74
97
  onDraftCreated(result.outboxMessageId);
75
98
  setSaveStatus("saved");
76
99
  queryClient.invalidateQueries({
@@ -78,38 +101,60 @@ export const useSaveDraft = ({
78
101
  });
79
102
  return result;
80
103
  },
81
- [
82
- outboxMessageId,
83
- createMutation,
84
- updateMutation,
85
- onDraftCreated,
86
- queryClient,
87
- ],
104
+ [createMutation, updateMutation, onDraftCreated, queryClient],
105
+ );
106
+
107
+ // One entry takes one write at a time. Overlapping writes settle in whatever
108
+ // order the network gives them, so an older body can land last — and two of
109
+ // them racing while the draft has no id yet each create one.
110
+ const writesRef = useRef<Promise<void>>(Promise.resolve());
111
+ const enqueueSave = useCallback(
112
+ (data: DraftData) => {
113
+ const write = writesRef.current.then(() => executeSave(data));
114
+ writesRef.current = settled(write);
115
+ return write;
116
+ },
117
+ [executeSave],
88
118
  );
89
119
 
90
120
  const saveDraft = useCallback(
91
121
  (data: DraftData) => {
92
- if (outboxMessageId && closedIdsRef.current.has(outboxMessageId)) return;
93
122
  if (timerRef.current) clearTimeout(timerRef.current);
94
123
  timerRef.current = setTimeout(() => {
124
+ const targetId = targetIdRef.current;
125
+ if (targetId && closedIdsRef.current.has(targetId)) return;
95
126
  // Keep the real error, not just a vague "error" status — the caller
96
127
  // surfaces its detail in a banner. A fatal 5xx additionally escalates
97
128
  // through the global MutationCache.onError sink.
98
- executeSave(data).catch((error: unknown) => {
129
+ enqueueSave(data).catch((error: unknown) => {
99
130
  setSaveError(error);
100
131
  setSaveStatus("error");
101
132
  });
102
133
  }, 2000);
103
134
  },
104
- [executeSave, outboxMessageId],
135
+ [enqueueSave],
105
136
  );
106
137
 
138
+ // Whoever asks for this is acting on the draft right now and owns the
139
+ // outcome, so the failure is returned rather than thrown and `saveError` is
140
+ // left alone — the caller's own message is the accurate one, and setting
141
+ // `saveError` would raise a second "Couldn't save draft" banner beside it.
107
142
  const saveImmediately = useCallback(
108
- (data: DraftData) => {
143
+ (data: DraftData): Promise<ImmediateSave> => {
109
144
  if (timerRef.current) clearTimeout(timerRef.current);
110
- return executeSave(data);
145
+ return enqueueSave(data)
146
+ .then(
147
+ (result): ImmediateSave => ({
148
+ outcome: "saved",
149
+ outboxMessageId: result.outboxMessageId,
150
+ }),
151
+ )
152
+ .catch((error: unknown): ImmediateSave => {
153
+ setSaveStatus("error");
154
+ return { outcome: "failed", error };
155
+ });
111
156
  },
112
- [executeSave],
157
+ [enqueueSave],
113
158
  );
114
159
 
115
160
  // Called with an id, the entry is closed to autosave for good. Sending and