@remit/web-client 0.0.221 → 0.0.222

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.221",
3
+ "version": "0.0.222",
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": {
@@ -431,6 +431,21 @@ export const ComposeForm = ({
431
431
  * commit out — the fields still hold the message that has just been left.
432
432
  */
433
433
  const [openDocumentId, setOpenDocumentId] = useState(outboxMessageId);
434
+ /**
435
+ * What the server's copy of this document would autosave as, so a draft that
436
+ * is only being looked at writes nothing.
437
+ *
438
+ * Opening one runs the autosave effect on the render that fills the fields
439
+ * from the read, and that write is no longer free: an edit returns a settled
440
+ * failure to `draft` (#933), so opening a Failed message and pressing Escape
441
+ * would take it out of the Outbox with the reader having changed nothing. The
442
+ * first run after a load records what was loaded; only a payload that differs
443
+ * from it is an edit. Nothing updates it afterwards — once the reader has
444
+ * touched the document, every later run saves as it always did, retries of a
445
+ * failed write included.
446
+ */
447
+ const loadedPayloadRef = useRef<string | undefined>(undefined);
448
+ const captureLoadedPayloadRef = useRef(false);
434
449
  const smtpConfigureRef = useRef<HTMLButtonElement>(null);
435
450
  const prevOutboxMessageIdRef = useRef<string | undefined>(outboxMessageId);
436
451
  /**
@@ -528,6 +543,8 @@ export const ComposeForm = ({
528
543
  setBody({ ...opening, formatting: [] });
529
544
  setDocumentGeneration((generation) => generation + 1);
530
545
  setDraftLoaded(false);
546
+ loadedPayloadRef.current = undefined;
547
+ captureLoadedPayloadRef.current = false;
531
548
  setOpenDocumentId(outboxMessageId);
532
549
  }, [outboxMessageId, clearPendingFields]);
533
550
 
@@ -619,6 +636,7 @@ export const ComposeForm = ({
619
636
  setDocumentGeneration((generation) => generation + 1);
620
637
  setSelectedAccountId(draftData.accountId);
621
638
  setDraftLoaded(true);
639
+ captureLoadedPayloadRef.current = true;
622
640
  }, [draftData, draftLoaded]);
623
641
 
624
642
  useEffect(() => {
@@ -834,7 +852,7 @@ export const ComposeForm = ({
834
852
  composeLanguage,
835
853
  );
836
854
 
837
- saveDraft({
855
+ const payload = {
838
856
  accountId: selectedAccountId,
839
857
  toAddresses: toAddresses.map((a) => a.email),
840
858
  ccAddresses:
@@ -844,7 +862,19 @@ export const ComposeForm = ({
844
862
  subject: subject || undefined,
845
863
  textBody,
846
864
  htmlBody,
847
- });
865
+ };
866
+
867
+ // Nor a document nobody has touched. Reopening one is not editing it, and
868
+ // a PATCH is now a status change as well as a content one (#933).
869
+ const fingerprint = JSON.stringify(payload);
870
+ if (captureLoadedPayloadRef.current) {
871
+ captureLoadedPayloadRef.current = false;
872
+ loadedPayloadRef.current = fingerprint;
873
+ return;
874
+ }
875
+ if (loadedPayloadRef.current === fingerprint) return;
876
+
877
+ saveDraft(payload);
848
878
  }, [
849
879
  selectedAccountId,
850
880
  outboxMessageId,
@@ -0,0 +1,147 @@
1
+ /**
2
+ * Issue #933: opening a message is not editing it.
3
+ *
4
+ * Editing a settled failure returns the row to `draft`, which is what gets a
5
+ * message refused for a bad address back on its way. The composer's autosave
6
+ * had no dirty check, though, and its effect runs on the render that fills the
7
+ * fields from the server's copy — so opening a Failed message and pressing
8
+ * Escape wrote a PATCH, and the row left the Outbox with the reader having
9
+ * changed nothing. A status change nobody asked for, on a message they were
10
+ * only reading.
11
+ *
12
+ * The rule pinned here: an untouched document writes nothing, and the first
13
+ * real edit writes as it always did.
14
+ */
15
+
16
+ import assert from "node:assert/strict";
17
+ import { afterEach, describe, it } from "node:test";
18
+ import type { RemitImapAccountResponse } from "@remit/api-http-client/types.gen.ts";
19
+ import {
20
+ type AnyRouter,
21
+ createMemoryHistory,
22
+ createRootRoute,
23
+ createRoute,
24
+ createRouter,
25
+ RouterContextProvider,
26
+ } from "@tanstack/react-router";
27
+ import { createElement, useState } from "react";
28
+ import { createDomHarness, type DomHarness } from "../../test-support/dom";
29
+ import { type HttpMock, mockFetch } from "../../test-support/http";
30
+ import { ComposeForm } from "./ComposeForm";
31
+ import { ComposeProvider } from "./ComposeProvider";
32
+
33
+ const ACCOUNT_ID = "acc-1";
34
+ const OUTBOX_MESSAGE_ID = "ob-933";
35
+ const AUTOSAVE_DEBOUNCE_MS = 2000;
36
+
37
+ const account = {
38
+ accountId: ACCOUNT_ID,
39
+ email: "me@example.com",
40
+ smtpEnabled: true,
41
+ } as unknown as RemitImapAccountResponse;
42
+
43
+ /** The row as the Outbox holds it: refused, and still carrying what was typed. */
44
+ const failedEntry = {
45
+ outboxMessageId: OUTBOX_MESSAGE_ID,
46
+ accountId: ACCOUNT_ID,
47
+ fromAddress: account.email,
48
+ toAddresses: ["typo@exmaple.com"],
49
+ ccAddresses: [],
50
+ bccAddresses: [],
51
+ references: [],
52
+ subject: "Invoice",
53
+ textBody: "Attached.",
54
+ status: "failed",
55
+ lastError: "550 5.1.0 mailbox unavailable",
56
+ };
57
+
58
+ let harness: DomHarness | undefined;
59
+ let http: HttpMock | undefined;
60
+
61
+ afterEach(() => {
62
+ harness?.close();
63
+ harness = undefined;
64
+ http?.restore();
65
+ http = undefined;
66
+ });
67
+
68
+ (globalThis as { self?: typeof globalThis }).self ??= globalThis;
69
+
70
+ const rootRoute = createRootRoute();
71
+ const mailboxRoute = createRoute({
72
+ getParentRoute: () => rootRoute,
73
+ path: "/mail/$mailboxId",
74
+ validateSearch: (search: Record<string, unknown>) => search,
75
+ });
76
+
77
+ const testRouter = (): AnyRouter =>
78
+ createRouter({
79
+ routeTree: rootRoute.addChildren([mailboxRoute]),
80
+ history: createMemoryHistory({ initialEntries: ["/mail/mbx-1"] }),
81
+ }) as unknown as AnyRouter;
82
+
83
+ const Opened = () => {
84
+ const [draftId, setDraftId] = useState<string | undefined>(OUTBOX_MESSAGE_ID);
85
+
86
+ return createElement(ComposeForm, {
87
+ mode: "new",
88
+ account,
89
+ outboxMessageId: draftId,
90
+ onDraftCreated: setDraftId,
91
+ onClose: () => undefined,
92
+ });
93
+ };
94
+
95
+ const writes = () =>
96
+ (http?.calls ?? []).filter(
97
+ (call) => call.method === "PATCH" || call.method === "POST",
98
+ );
99
+
100
+ const mount = async (): Promise<void> => {
101
+ http = mockFetch(async (call) => {
102
+ if (call.path.endsWith("/config")) return { accounts: [account] };
103
+ return failedEntry;
104
+ });
105
+
106
+ harness = createDomHarness();
107
+ harness.renderApp(
108
+ createElement(RouterContextProvider, {
109
+ router: testRouter(),
110
+ // biome-ignore lint/correctness/noChildrenProp: RouterContextProvider types `children` as a required prop, which createElement's rest-argument form does not satisfy
111
+ children: createElement(ComposeProvider, null, createElement(Opened)),
112
+ }),
113
+ );
114
+ await harness.flush();
115
+ await harness.wait(50);
116
+ };
117
+
118
+ const subjectField = (): HTMLElement => {
119
+ const field = harness?.query("[data-subject-field]");
120
+ if (!field) throw new Error("the compose subject field is not mounted");
121
+ return field;
122
+ };
123
+
124
+ describe("a message reopened in the composer", () => {
125
+ it("writes nothing while nobody has touched it", async () => {
126
+ await mount();
127
+
128
+ await harness?.wait(AUTOSAVE_DEBOUNCE_MS + 200);
129
+
130
+ assert.deepEqual(
131
+ writes().map((call) => call.method),
132
+ [],
133
+ "reopening a message must not change it",
134
+ );
135
+ });
136
+
137
+ it("writes the first real edit", async () => {
138
+ await mount();
139
+
140
+ harness?.type(subjectField(), "Invoice, corrected");
141
+ await harness?.wait(AUTOSAVE_DEBOUNCE_MS + 200);
142
+
143
+ const written = writes().at(-1);
144
+ assert.equal(written?.method, "PATCH");
145
+ assert.equal(written?.body?.subject, "Invoice, corrected");
146
+ });
147
+ });