@remit/ui 0.0.48 → 0.0.50

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/ui",
3
- "version": "0.0.48",
3
+ "version": "0.0.50",
4
4
  "type": "module",
5
5
  "files": [
6
6
  "src"
@@ -55,7 +55,10 @@ function LiveEditor({
55
55
  }: {
56
56
  initialRule: FilterRule;
57
57
  semanticAvailable?: boolean;
58
- onCreateFolder?: (name: string) => Promise<FolderOption>;
58
+ onCreateFolder?: (
59
+ name: string,
60
+ signal?: AbortSignal,
61
+ ) => Promise<FolderOption>;
59
62
  }) {
60
63
  const [rule, setRule] = useState<FilterRule>(initialRule);
61
64
  const [clauseEdit, setClauseEdit] = useState<ClauseEditState | undefined>();
@@ -170,6 +173,182 @@ export const WithNewFolderOption: Story = {
170
173
  ),
171
174
  };
172
175
 
176
+ /**
177
+ * Drive the destination field into its create sub-form: pick "+ New folder…",
178
+ * type a name, and press "Create folder". Used by the pending and error stories
179
+ * below so each lands in the state it documents without a manual click-through.
180
+ */
181
+ async function openCreateAndSubmit(
182
+ canvasElement: HTMLElement,
183
+ folderName: string,
184
+ ) {
185
+ const setSelectValue = Object.getOwnPropertyDescriptor(
186
+ HTMLSelectElement.prototype,
187
+ "value",
188
+ )?.set;
189
+ const setInputValue = Object.getOwnPropertyDescriptor(
190
+ HTMLInputElement.prototype,
191
+ "value",
192
+ )?.set;
193
+ const select = canvasElement.querySelector<HTMLSelectElement>(
194
+ 'select[aria-label="Destination folder"]',
195
+ );
196
+ if (!select) return;
197
+ setSelectValue?.call(select, CREATE_FOLDER_STORY_VALUE);
198
+ select.dispatchEvent(new Event("change", { bubbles: true }));
199
+ const input = canvasElement.querySelector<HTMLInputElement>(
200
+ 'input[aria-label="New folder name"]',
201
+ );
202
+ if (!input) return;
203
+ setInputValue?.call(input, folderName);
204
+ input.dispatchEvent(new Event("input", { bubbles: true }));
205
+ const createButton = Array.from(
206
+ canvasElement.querySelectorAll<HTMLButtonElement>("button"),
207
+ ).find((button) => button.textContent?.trim() === "Create folder");
208
+ createButton?.click();
209
+ }
210
+
211
+ /** Matches the internal CREATE_FOLDER_VALUE option in the destination select. */
212
+ const CREATE_FOLDER_STORY_VALUE = "__filter_create_folder__";
213
+
214
+ /** Mirrors the web-client wait's honest timeout copy. */
215
+ const TIMEOUT_MESSAGE =
216
+ "The folder was created but the mail server hasn't confirmed it yet, so nothing was attached to it. It's in your folder list — try again in a moment.";
217
+
218
+ const tick = () => new Promise((resolve) => setTimeout(resolve, 60));
219
+
220
+ const neverResolvesCreateFolder = (): Promise<FolderOption> =>
221
+ new Promise<FolderOption>(() => undefined);
222
+
223
+ const rejectingCreateFolder = (message: string) => (): Promise<FolderOption> =>
224
+ Promise.reject(new Error(message));
225
+
226
+ /** Rejects the first attempt, resolves the retry — the resume the hook performs. */
227
+ const failThenSucceedCreateFolder = () => {
228
+ let attempts = 0;
229
+ return (name: string): Promise<FolderOption> => {
230
+ attempts += 1;
231
+ return attempts === 1
232
+ ? Promise.reject(new Error(TIMEOUT_MESSAGE))
233
+ : Promise.resolve({ id: "mbx-created", label: name });
234
+ };
235
+ };
236
+
237
+ /** Never resolves on its own; rejects with an AbortError when the signal aborts. */
238
+ const abortAwareCreateFolder = (
239
+ _name: string,
240
+ signal?: AbortSignal,
241
+ ): Promise<FolderOption> =>
242
+ new Promise<FolderOption>((_resolve, reject) => {
243
+ signal?.addEventListener("abort", () =>
244
+ reject(new DOMException("Aborted", "AbortError")),
245
+ );
246
+ });
247
+
248
+ /**
249
+ * The folder is a dependent write for the filter, so creating it waits for the
250
+ * mail server to confirm the folder before it can be picked as the destination.
251
+ * The wait shows as "Creating folder…" — held for the whole confirmation, not
252
+ * just a fast optimistic round-trip.
253
+ */
254
+ export const NewFolderCreating: Story = {
255
+ name: "New folder — creating (waiting for the server)",
256
+ render: () => (
257
+ <LiveEditor
258
+ initialRule={demoRule}
259
+ onCreateFolder={neverResolvesCreateFolder}
260
+ />
261
+ ),
262
+ play: async ({ canvasElement }) => {
263
+ await openCreateAndSubmit(canvasElement, "Receipts");
264
+ },
265
+ };
266
+
267
+ /**
268
+ * The folder create failed on the mail server. The rule is not committed against
269
+ * a folder that does not exist: the error is surfaced inline with the create form
270
+ * still open, so the create can be retried or cancelled.
271
+ */
272
+ export const NewFolderCreateFailed: Story = {
273
+ name: "New folder — create failed (retry / cancel)",
274
+ render: () => (
275
+ <LiveEditor
276
+ initialRule={demoRule}
277
+ onCreateFolder={rejectingCreateFolder(
278
+ "The folder couldn't be created on the mail server. Please try again.",
279
+ )}
280
+ />
281
+ ),
282
+ play: async ({ canvasElement }) => {
283
+ await openCreateAndSubmit(canvasElement, "Receipts");
284
+ },
285
+ };
286
+
287
+ /**
288
+ * The folder create was never confirmed within the wait bound. Distinct from a
289
+ * hard failure — the message names the timeout — and, like a failure, leaves no
290
+ * folder selected, so no filter is written against it.
291
+ */
292
+ export const NewFolderCreateTimedOut: Story = {
293
+ name: "New folder — create timed out (retry / cancel)",
294
+ render: () => (
295
+ <LiveEditor
296
+ initialRule={demoRule}
297
+ onCreateFolder={rejectingCreateFolder(TIMEOUT_MESSAGE)}
298
+ />
299
+ ),
300
+ play: async ({ canvasElement }) => {
301
+ await openCreateAndSubmit(canvasElement, "Receipts");
302
+ },
303
+ };
304
+
305
+ /**
306
+ * Retry is a resume: the first attempt times out (the folder was made but not yet
307
+ * confirmed), and pressing "Create folder" again with the same name resolves —
308
+ * the hook re-waits on the folder it already made rather than re-creating it, so
309
+ * the retry the failure message points at actually works.
310
+ */
311
+ export const NewFolderCreateRetrySucceeds: Story = {
312
+ name: "New folder — retry resumes and succeeds",
313
+ render: () => (
314
+ <LiveEditor
315
+ initialRule={demoRule}
316
+ onCreateFolder={failThenSucceedCreateFolder()}
317
+ />
318
+ ),
319
+ play: async ({ canvasElement }) => {
320
+ await openCreateAndSubmit(canvasElement, "Receipts");
321
+ await tick();
322
+ const retry = Array.from(
323
+ canvasElement.querySelectorAll<HTMLButtonElement>("button"),
324
+ ).find((button) => button.textContent?.trim() === "Create folder");
325
+ retry?.click();
326
+ },
327
+ };
328
+
329
+ /**
330
+ * Cancelling while "Creating folder…" is in flight aborts the wait: the create
331
+ * promise rejects with an AbortError the field swallows, so no destination binds
332
+ * after the user backed out — the sub-form just closes.
333
+ */
334
+ export const NewFolderCreateCancelledMidWait: Story = {
335
+ name: "New folder — cancel aborts the wait",
336
+ render: () => (
337
+ <LiveEditor
338
+ initialRule={demoRule}
339
+ onCreateFolder={abortAwareCreateFolder}
340
+ />
341
+ ),
342
+ play: async ({ canvasElement }) => {
343
+ await openCreateAndSubmit(canvasElement, "Receipts");
344
+ await tick();
345
+ const cancel = Array.from(
346
+ canvasElement.querySelectorAll<HTMLButtonElement>("button"),
347
+ ).find((button) => button.textContent?.trim() === "Cancel");
348
+ cancel?.click();
349
+ },
350
+ };
351
+
173
352
  /** Literal clauses joined with "or", including the ticket-B ListId and FromDomain fields. */
174
353
  export const AnyOfTheseClauses: Story = {
175
354
  render: () => <LiveEditor initialRule={demoVocabularyRule} />,
@@ -254,11 +433,13 @@ export const DegradedStandingWiden: Story = {
254
433
  };
255
434
 
256
435
  /**
257
- * Editing a persisted filter (RFC 038 D6): scope and expiry render read-only,
258
- * with a note that they and the semantic anchor are fixed at creation. The
259
- * name and the literal clauses stay editable; the widen chip is display-only.
436
+ * Editing a persisted filter (RFC 038 D6, reader #266): scope and expiry stay
437
+ * live and editable a standing filter can move to "until a date" and back,
438
+ * or its date can change. The semantic anchor is the one thing fixed at
439
+ * creation: the widen chip renders display-only with a one-line note, and
440
+ * "Just once" drops out of the scope toggle since no saved filter can hold it.
260
441
  */
261
- export const LifecycleLocked: Story = {
442
+ export const AnchorLocked: Story = {
262
443
  args: {
263
444
  rule: {
264
445
  ...demoRule,
@@ -269,7 +450,7 @@ export const LifecycleLocked: Story = {
269
450
  folders: demoFolders,
270
451
  preview: READY(31),
271
452
  semanticAvailable: false,
272
- lifecycleLocked: true,
453
+ anchorLocked: true,
273
454
  },
274
455
  };
275
456
 
@@ -1,4 +1,12 @@
1
- import { Fragment, type ReactNode, useMemo, useState } from "react";
1
+ import {
2
+ Fragment,
3
+ type ReactNode,
4
+ useEffect,
5
+ useMemo,
6
+ useRef,
7
+ useState,
8
+ } from "react";
9
+ import { isAbortError } from "../lib/abort.js";
2
10
  import { BottomSheet } from "./bottom-sheet.js";
3
11
  import { Button } from "./button.js";
4
12
  import { Dialog } from "./dialog.js";
@@ -56,14 +64,20 @@ export interface FilterRuleEditorProps {
56
64
  */
57
65
  clauseFields?: ClauseField[];
58
66
  /**
59
- * Render the scope and expiry read-only (RFC 038 D6). A persisted filter's
60
- * scope, expiry, and semantic anchor are fixed at creation the update
61
- * endpoint carries none of them so editing a filter shows them as a static
62
- * summary with a note rather than live controls that would silently discard
63
- * the change (reader #266 tracks lifting this). The name and the literal
64
- * predicate stay editable.
67
+ * Whether the semantic anchor is fixed and cannot be added, removed, or
68
+ * repointed (RFC 038 D6, reader #266). True for an existing, persisted
69
+ * filter the update endpoint carries no anchor field at all, so
70
+ * repointing one would silently change what a saved filter matches with
71
+ * nothing visible changing, and that deserves a new filter instead. The
72
+ * widen chip renders display-only (no remove affordance regardless of
73
+ * `onRemoveWiden`) and carries a one-line note explaining why.
74
+ *
75
+ * Scope and expiry are NOT covered by this flag — they stay editable even
76
+ * on an existing filter (reader #266). The only other thing this locks is
77
+ * the "Just once" scope option, meaningless for an already-persisted
78
+ * filter, which is dropped from the scope toggle.
65
79
  */
66
- lifecycleLocked?: boolean;
80
+ anchorLocked?: boolean;
67
81
  /** The inline clause form, when adding or editing a clause. */
68
82
  clauseEdit?: ClauseEditState;
69
83
  onStartAddClause?: () => void;
@@ -78,12 +92,16 @@ export interface FilterRuleEditorProps {
78
92
  onChangeMatchOperator?: (operator: MatchOperator) => void;
79
93
  onChangeMove?: (mailboxId: string) => void;
80
94
  /**
81
- * Create a new destination folder from within the editor. Given a folder
82
- * name, resolves to the created folder once the backend has queued it. When
83
- * absent, the "New folder…" option is not offered the editor stays
84
- * data-agnostic, so stories and consumers without wiring render unchanged.
95
+ * Create a new destination folder from within the editor. Given a folder name
96
+ * and an abort signal, resolves to the created folder once the mail server
97
+ * confirms it. The editor aborts the signal on unmount or cancel. When absent,
98
+ * the "New folder…" option is not offered the editor stays data-agnostic, so
99
+ * stories and consumers without wiring render unchanged.
85
100
  */
86
- onCreateFolder?: (name: string) => Promise<FolderOption>;
101
+ onCreateFolder?: (
102
+ name: string,
103
+ signal?: AbortSignal,
104
+ ) => Promise<FolderOption>;
87
105
  onChangeScope?: (scope: RuleScope) => void;
88
106
  onChangeName?: (name: string) => void;
89
107
  onChangeUntil?: (date: string) => void;
@@ -110,6 +128,13 @@ const CREATE_FOLDER_VALUE = "__filter_create_folder__";
110
128
  * name field; on resolve the new folder is added to the local option set (so it
111
129
  * is selectable even before the caller's folder list refetches) and picked as
112
130
  * the destination. Without `onCreateFolder` this is the bare select.
131
+ *
132
+ * The destination is a dependent write: the filter this editor commits binds to
133
+ * the folder, so `onCreateFolder` resolves only once the folder is confirmed on
134
+ * the mail server, not when the create is merely queued. The pending state holds
135
+ * "Creating folder…" for that whole wait, and a create that fails or never
136
+ * confirms rejects with its own message here — the folder is never selected, so
137
+ * the caller cannot commit a filter against a folder that does not exist.
113
138
  */
114
139
  function MoveDestinationField({
115
140
  folders,
@@ -120,13 +145,21 @@ function MoveDestinationField({
120
145
  folders: FolderOption[];
121
146
  value: string;
122
147
  onChangeMove?: (mailboxId: string) => void;
123
- onCreateFolder?: (name: string) => Promise<FolderOption>;
148
+ onCreateFolder?: (
149
+ name: string,
150
+ signal?: AbortSignal,
151
+ ) => Promise<FolderOption>;
124
152
  }) {
125
153
  const [creating, setCreating] = useState(false);
126
154
  const [name, setName] = useState("");
127
155
  const [pending, setPending] = useState(false);
128
156
  const [error, setError] = useState<string>();
129
157
  const [createdFolders, setCreatedFolders] = useState<FolderOption[]>([]);
158
+ // The create waits for the mail server to confirm the folder; abort it on
159
+ // unmount or cancel so a late confirmation never binds the destination after
160
+ // the editor is gone or the sub-form dismissed.
161
+ const createAbort = useRef<AbortController | null>(null);
162
+ useEffect(() => () => createAbort.current?.abort(), []);
130
163
 
131
164
  const options = useMemo(() => {
132
165
  const known = new Set(folders.map((folder) => folder.id));
@@ -151,7 +184,10 @@ function MoveDestinationField({
151
184
  if (trimmed === "") return;
152
185
  setPending(true);
153
186
  setError(undefined);
154
- onCreateFolder(trimmed)
187
+ createAbort.current?.abort();
188
+ const controller = new AbortController();
189
+ createAbort.current = controller;
190
+ onCreateFolder(trimmed, controller.signal)
155
191
  .then((folder) => {
156
192
  setCreatedFolders((prev) =>
157
193
  prev.some((entry) => entry.id === folder.id)
@@ -164,6 +200,7 @@ function MoveDestinationField({
164
200
  setPending(false);
165
201
  })
166
202
  .catch((error: unknown) => {
203
+ if (isAbortError(error)) return;
167
204
  setError(
168
205
  error instanceof Error
169
206
  ? error.message
@@ -174,9 +211,11 @@ function MoveDestinationField({
174
211
  };
175
212
 
176
213
  const cancel = () => {
214
+ createAbort.current?.abort();
177
215
  setCreating(false);
178
216
  setName("");
179
217
  setError(undefined);
218
+ setPending(false);
180
219
  };
181
220
 
182
221
  return (
@@ -228,7 +267,7 @@ function MoveDestinationField({
228
267
  onClick={submit}
229
268
  disabled={pending || name.trim() === ""}
230
269
  >
231
- {pending ? "Creating…" : "Create folder"}
270
+ {pending ? "Creating folder…" : "Create folder"}
232
271
  </Button>
233
272
  <Button
234
273
  variant="ghost"
@@ -252,7 +291,7 @@ export function FilterRuleEditor({
252
291
  notice,
253
292
  semanticAvailable = false,
254
293
  clauseFields,
255
- lifecycleLocked = false,
294
+ anchorLocked = false,
256
295
  clauseEdit,
257
296
  onStartAddClause,
258
297
  onStartEditClause,
@@ -278,6 +317,12 @@ export function FilterRuleEditor({
278
317
  const join = matchJoinWord(rule.matchOperator);
279
318
  const blockedReason = commitBlockedReason(rule, preview);
280
319
  const needsName = rule.scope === "standing" || rule.scope === "until";
320
+ // A persisted filter is always Standing or Temporary — "once" was never a
321
+ // scope a saved row could hold, so an existing filter's editor never offers
322
+ // it (reader #266).
323
+ const availableScopeOptions = anchorLocked
324
+ ? scopeOptions.filter((option) => option.value !== "once")
325
+ : scopeOptions;
281
326
 
282
327
  return (
283
328
  <div className="flex min-h-0 flex-col">
@@ -314,16 +359,22 @@ export function FilterRuleEditor({
314
359
  {join}
315
360
  </span>
316
361
  )}
317
- <WidenChip widen={rule.widen} onRemove={onRemoveWiden} />
362
+ <WidenChip
363
+ widen={rule.widen}
364
+ onRemove={anchorLocked ? undefined : onRemoveWiden}
365
+ />
318
366
  </>
319
367
  )}
320
368
 
321
369
  {!clauseEdit && (
322
370
  <AddChipButton label="Add clause" onClick={onStartAddClause} />
323
371
  )}
324
- {!rule.widen && semanticAvailable && !clauseEdit && (
325
- <AddChipButton label="…and similar" onClick={onAddWiden} />
326
- )}
372
+ {!rule.widen &&
373
+ !anchorLocked &&
374
+ semanticAvailable &&
375
+ !clauseEdit && (
376
+ <AddChipButton label="…and similar" onClick={onAddWiden} />
377
+ )}
327
378
  </div>
328
379
 
329
380
  {clauseEdit && (
@@ -338,6 +389,13 @@ export function FilterRuleEditor({
338
389
  />
339
390
  )}
340
391
 
392
+ {anchorLocked && rule.widen && (
393
+ <p className="text-2xs text-fg-subtle">
394
+ This filter's similar-mail match is fixed to the message it was
395
+ created from — create a new filter to change it.
396
+ </p>
397
+ )}
398
+
341
399
  {showOperator && (
342
400
  <div className="flex items-center gap-2">
343
401
  <span className="text-xs text-fg-muted">Match</span>
@@ -374,22 +432,14 @@ export function FilterRuleEditor({
374
432
 
375
433
  <section className="space-y-2">
376
434
  <p className="text-xs font-medium text-fg-muted">How long</p>
377
- {lifecycleLocked ? (
378
- <p className="text-xs text-fg">
379
- {rule.scope === "until"
380
- ? `Until ${rule.until ?? ""}`
381
- : "Always — runs on matching mail"}
382
- </p>
383
- ) : (
384
- <SegmentedControl
385
- name="rule-scope"
386
- size="sm"
387
- aria-label="Rule scope"
388
- options={scopeOptions}
389
- value={rule.scope}
390
- onChange={(value) => onChangeScope?.(value)}
391
- />
392
- )}
435
+ <SegmentedControl
436
+ name="rule-scope"
437
+ size="sm"
438
+ aria-label="Rule scope"
439
+ options={availableScopeOptions}
440
+ value={rule.scope}
441
+ onChange={(value) => onChangeScope?.(value)}
442
+ />
393
443
  {needsName && (
394
444
  <Input
395
445
  value={rule.name ?? ""}
@@ -399,7 +449,7 @@ export function FilterRuleEditor({
399
449
  className="w-full"
400
450
  />
401
451
  )}
402
- {!lifecycleLocked && rule.scope === "until" && (
452
+ {rule.scope === "until" && (
403
453
  <div className="flex items-center gap-2 text-xs text-fg-muted">
404
454
  <span>Until</span>
405
455
  <Input
@@ -411,13 +461,6 @@ export function FilterRuleEditor({
411
461
  />
412
462
  </div>
413
463
  )}
414
- {lifecycleLocked && (
415
- <p className="text-2xs text-fg-subtle">
416
- {rule.widen
417
- ? "The scope, expiry, and similar-mail match are set when a filter is created."
418
- : "The scope and expiry are set when a filter is created."}
419
- </p>
420
- )}
421
464
  </section>
422
465
 
423
466
  <div className="border-t border-line pt-3">
@@ -555,35 +555,48 @@ describe("FilterRuleEditor", () => {
555
555
  assert.match(editor(), /47 messages match/);
556
556
  });
557
557
 
558
- it("renders scope and expiry read-only when the lifecycle is locked", () => {
558
+ it("keeps scope and expiry live and editable on an anchor-locked (existing) filter (reader #266)", () => {
559
559
  const html = editor({
560
560
  rule: { ...demoRule, scope: "until", until: "2027-09-01" },
561
- lifecycleLocked: true,
561
+ anchorLocked: true,
562
562
  });
563
- // No live scope toggle, no editable date input — a static summary and a note.
564
- assert.doesNotMatch(html, /aria-label="Rule scope"/);
565
- assert.doesNotMatch(html, /aria-label="Expiry date"/);
566
- assert.match(html, /Until 2027-09-01/);
567
- assert.match(html, /set when a filter is created/);
568
- // The name stays editable.
563
+ assert.match(html, /aria-label="Rule scope"/);
564
+ assert.match(html, /aria-label="Expiry date"/);
569
565
  assert.match(html, /aria-label="Rule name"/);
570
566
  });
571
567
 
572
- it("names the similar-mail match in the locked note only when a widen is present", () => {
568
+ it("drops the once option from the scope toggle on an anchor-locked filter", () => {
569
+ const locked = editor({ anchorLocked: true });
570
+ assert.doesNotMatch(locked, />Just once</);
571
+ const unlocked = editor({ anchorLocked: false });
572
+ assert.match(unlocked, />Just once</);
573
+ });
574
+
575
+ it("locks the widen chip and explains why only when one is present", () => {
573
576
  const withWiden = editor({
574
577
  rule: { ...demoRule, widen: { anchorCount: 2 } },
575
- lifecycleLocked: true,
578
+ anchorLocked: true,
576
579
  });
577
- assert.match(
580
+ assert.doesNotMatch(
578
581
  withWiden,
579
- /similar-mail match are set when a filter is created/,
582
+ /aria-label="Remove the similar-mail widen"/,
580
583
  );
584
+ assert.match(withWiden, /similar-mail match is fixed to the message/);
585
+
581
586
  const literal = editor({
582
587
  rule: { ...demoRule, widen: undefined },
583
- lifecycleLocked: true,
588
+ anchorLocked: true,
589
+ });
590
+ assert.doesNotMatch(literal, /similar-mail match is fixed/);
591
+ });
592
+
593
+ it("never offers to add a widen on an anchor-locked filter, even when the deployment can serve it", () => {
594
+ const html = editor({
595
+ rule: { ...demoRule, widen: undefined },
596
+ anchorLocked: true,
597
+ semanticAvailable: true,
584
598
  });
585
- assert.match(literal, /scope and expiry are set when a filter is created/);
586
- assert.doesNotMatch(literal, /similar-mail match/);
599
+ assert.doesNotMatch(html, /…and similar/);
587
600
  });
588
601
  });
589
602
 
@@ -112,3 +112,202 @@ export const CreateAndMove: Story = {
112
112
  name: "Create folder from search",
113
113
  render: () => <CreatePicker />,
114
114
  };
115
+
116
+ /**
117
+ * Type a folder name into the search box and press the create-and-move row —
118
+ * used by the pending and error stories so each lands in its state without a
119
+ * manual click-through.
120
+ */
121
+ async function typeAndCreate(canvasElement: HTMLElement, folderName: string) {
122
+ const setInputValue = Object.getOwnPropertyDescriptor(
123
+ HTMLInputElement.prototype,
124
+ "value",
125
+ )?.set;
126
+ const input = canvasElement.querySelector<HTMLInputElement>(
127
+ 'input[type="search"]',
128
+ );
129
+ if (!input) return;
130
+ setInputValue?.call(input, folderName);
131
+ input.dispatchEvent(new Event("input", { bubbles: true }));
132
+ const createButton = Array.from(
133
+ canvasElement.querySelectorAll<HTMLButtonElement>("button"),
134
+ ).find((button) => button.textContent?.includes(`Create "${folderName}"`));
135
+ createButton?.click();
136
+ }
137
+
138
+ /** Mirrors the web-client wait's honest timeout copy. */
139
+ const TIMEOUT_MESSAGE =
140
+ "The folder was created but the mail server hasn't confirmed it yet, so nothing was attached to it. It's in your folder list — try again in a moment.";
141
+
142
+ const tick = () => new Promise((resolve) => setTimeout(resolve, 60));
143
+
144
+ const neverResolvesCreateFolder = (): Promise<MoveMailboxOption> =>
145
+ new Promise<MoveMailboxOption>(() => undefined);
146
+
147
+ const rejectingCreateFolder =
148
+ (message: string) => (): Promise<MoveMailboxOption> =>
149
+ Promise.reject(new Error(message));
150
+
151
+ /** Rejects the first attempt, resolves the retry — the resume the hook performs. */
152
+ const failThenSucceedCreateFolder = () => {
153
+ let attempts = 0;
154
+ return (name: string): Promise<MoveMailboxOption> => {
155
+ attempts += 1;
156
+ return attempts === 1
157
+ ? Promise.reject(new Error(TIMEOUT_MESSAGE))
158
+ : Promise.resolve({ id: "mbx-created", label: name });
159
+ };
160
+ };
161
+
162
+ /** Never resolves on its own; rejects with an AbortError when the signal aborts. */
163
+ const abortAwareCreateFolder = (
164
+ _name: string,
165
+ signal?: AbortSignal,
166
+ ): Promise<MoveMailboxOption> =>
167
+ new Promise<MoveMailboxOption>((_resolve, reject) => {
168
+ signal?.addEventListener("abort", () =>
169
+ reject(new DOMException("Aborted", "AbortError")),
170
+ );
171
+ });
172
+
173
+ /**
174
+ * The move is a dependent write on the folder: the create-and-move row does not
175
+ * resolve until the mail server confirms the folder, so the move never races the
176
+ * folder into existence. The wait shows as "Creating folder…".
177
+ */
178
+ export const CreateFolderInFlight: Story = {
179
+ name: "Create folder — waiting for the server",
180
+ render: () => (
181
+ <MoveMailboxPicker
182
+ mailboxes={mailboxes}
183
+ onSelect={() => undefined}
184
+ onCreateFolder={neverResolvesCreateFolder}
185
+ />
186
+ ),
187
+ play: async ({ canvasElement }) => {
188
+ await typeAndCreate(canvasElement, "Taxes");
189
+ },
190
+ };
191
+
192
+ /**
193
+ * The folder create failed on the mail server. No move runs; the error is shown
194
+ * inline and the create row can be pressed again to retry.
195
+ */
196
+ export const CreateFolderFailed: Story = {
197
+ name: "Create folder — failed (retry)",
198
+ render: () => (
199
+ <MoveMailboxPicker
200
+ mailboxes={mailboxes}
201
+ onSelect={() => undefined}
202
+ onCreateFolder={rejectingCreateFolder(
203
+ "The folder couldn't be created on the mail server. Please try again.",
204
+ )}
205
+ />
206
+ ),
207
+ play: async ({ canvasElement }) => {
208
+ await typeAndCreate(canvasElement, "Taxes");
209
+ },
210
+ };
211
+
212
+ /**
213
+ * The folder create was never confirmed within the wait bound — the timeout is
214
+ * named distinctly, and no move runs.
215
+ */
216
+ export const CreateFolderTimedOut: Story = {
217
+ name: "Create folder — timed out (retry)",
218
+ render: () => (
219
+ <MoveMailboxPicker
220
+ mailboxes={mailboxes}
221
+ onSelect={() => undefined}
222
+ onCreateFolder={rejectingCreateFolder(TIMEOUT_MESSAGE)}
223
+ />
224
+ ),
225
+ play: async ({ canvasElement }) => {
226
+ await typeAndCreate(canvasElement, "Taxes");
227
+ },
228
+ };
229
+
230
+ /**
231
+ * Retry is a resume: the first create times out, and pressing the create row
232
+ * again with the same name resolves and moves — the hook re-waits on the folder
233
+ * it already made rather than re-creating it.
234
+ */
235
+ export const CreateFolderRetrySucceeds: Story = {
236
+ name: "Create folder — retry resumes and moves",
237
+ render: () => {
238
+ const RetryStage = () => {
239
+ const [moved, setMoved] = useState<string | null>(null);
240
+ return (
241
+ <div className="flex flex-col">
242
+ <MoveMailboxPicker
243
+ mailboxes={mailboxes}
244
+ onSelect={setMoved}
245
+ onCreateFolder={failThenSucceedCreateFolder()}
246
+ />
247
+ {moved && (
248
+ <p className="border-t border-line px-3 py-2 text-xs text-fg-muted">
249
+ Moved to {moved}
250
+ </p>
251
+ )}
252
+ </div>
253
+ );
254
+ };
255
+ return <RetryStage />;
256
+ },
257
+ play: async ({ canvasElement }) => {
258
+ await typeAndCreate(canvasElement, "Taxes");
259
+ await tick();
260
+ const retry = Array.from(
261
+ canvasElement.querySelectorAll<HTMLButtonElement>("button"),
262
+ ).find((button) => button.textContent?.includes('Create "Taxes"'));
263
+ retry?.click();
264
+ },
265
+ };
266
+
267
+ /**
268
+ * Closing the picker while "Creating folder…" is in flight aborts the wait: the
269
+ * create promise rejects with an AbortError, so a folder that would confirm later
270
+ * never fires the move after the picker is gone. Here "Close picker" unmounts it
271
+ * mid-wait; no "Moved to" line appears.
272
+ */
273
+ export const CreateFolderClosedMidWait: Story = {
274
+ name: "Create folder — closing aborts the move",
275
+ render: () => {
276
+ const AbortStage = () => {
277
+ const [open, setOpen] = useState(true);
278
+ const [moved, setMoved] = useState<string | null>(null);
279
+ return (
280
+ <div className="flex flex-col">
281
+ <button
282
+ type="button"
283
+ onClick={() => setOpen(false)}
284
+ className="border-b border-line px-3 py-2 text-left text-xs text-fg-muted"
285
+ >
286
+ Close picker
287
+ </button>
288
+ {open && (
289
+ <MoveMailboxPicker
290
+ mailboxes={mailboxes}
291
+ onSelect={setMoved}
292
+ onCreateFolder={abortAwareCreateFolder}
293
+ />
294
+ )}
295
+ {moved && (
296
+ <p className="border-t border-line px-3 py-2 text-xs text-fg-muted">
297
+ Moved to {moved}
298
+ </p>
299
+ )}
300
+ </div>
301
+ );
302
+ };
303
+ return <AbortStage />;
304
+ },
305
+ play: async ({ canvasElement }) => {
306
+ await typeAndCreate(canvasElement, "Taxes");
307
+ await tick();
308
+ const close = Array.from(
309
+ canvasElement.querySelectorAll<HTMLButtonElement>("button"),
310
+ ).find((button) => button.textContent?.trim() === "Close picker");
311
+ close?.click();
312
+ },
313
+ };
@@ -7,6 +7,7 @@ import {
7
7
  useRef,
8
8
  useState,
9
9
  } from "react";
10
+ import { isAbortError } from "../lib/abort.js";
10
11
  import { cn } from "../lib/cn.js";
11
12
  import { Input } from "./input.js";
12
13
 
@@ -60,10 +61,15 @@ export interface MoveMailboxPickerProps {
60
61
  /**
61
62
  * Create a folder named by the current search query. When provided and the
62
63
  * query names no existing folder, a create-and-move row is offered at the
63
- * bottom of the list; resolving it yields the new folder, which is selected
64
- * (moved into) immediately. Absent means no create affordance renders.
64
+ * bottom of the list; resolving it once the mail server confirms the folder
65
+ * — yields the new folder, which is selected (moved into). The picker aborts
66
+ * the passed signal on unmount, so a folder that confirms after the picker is
67
+ * closed never fires the move. Absent means no create affordance renders.
65
68
  */
66
- onCreateFolder?: (name: string) => Promise<MoveMailboxOption>;
69
+ onCreateFolder?: (
70
+ name: string,
71
+ signal?: AbortSignal,
72
+ ) => Promise<MoveMailboxOption>;
67
73
  /**
68
74
  * Called when the user dismisses the picker via Escape. Trigger consumers
69
75
  * use this to close their popover/drawer; the picker never owns
@@ -153,6 +159,11 @@ export const MoveMailboxPicker = ({
153
159
  );
154
160
  const inputRef = useRef<HTMLInputElement>(null);
155
161
  const optionRefs = useRef<Array<HTMLButtonElement | null>>([]);
162
+ // The create waits for the mail server to confirm the folder; abort it when
163
+ // the picker unmounts (its popover/drawer closes) so a late confirmation never
164
+ // fires the move after the picker is gone.
165
+ const createAbort = useRef<AbortController | null>(null);
166
+ useEffect(() => () => createAbort.current?.abort(), []);
156
167
 
157
168
  useEffect(() => {
158
169
  if (autoFocus) inputRef.current?.focus();
@@ -206,12 +217,16 @@ export const MoveMailboxPicker = ({
206
217
  if (name === "") return;
207
218
  setCreating(true);
208
219
  setCreateError(undefined);
209
- onCreateFolder(name)
220
+ createAbort.current?.abort();
221
+ const controller = new AbortController();
222
+ createAbort.current = controller;
223
+ onCreateFolder(name, controller.signal)
210
224
  .then((folder) => {
211
225
  onSelect(folder.id);
212
226
  setCreating(false);
213
227
  })
214
228
  .catch((error: unknown) => {
229
+ if (isAbortError(error)) return;
215
230
  setCreateError(
216
231
  error instanceof Error ? error.message : text.createError,
217
232
  );
@@ -0,0 +1,11 @@
1
+ /**
2
+ * True for the rejection an aborted `AbortSignal` produces (a `DOMException`
3
+ * named `AbortError`, or any error carrying that name). A create affordance that
4
+ * is unmounted or cancelled aborts its in-flight folder create; the rejection is
5
+ * expected, not a failure to show — the surface is already gone.
6
+ */
7
+ export const isAbortError = (error: unknown): boolean =>
8
+ typeof error === "object" &&
9
+ error !== null &&
10
+ "name" in error &&
11
+ (error as { name?: unknown }).name === "AbortError";