@remit/ui 0.0.49 → 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.49",
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} />,
@@ -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";
@@ -84,12 +92,16 @@ export interface FilterRuleEditorProps {
84
92
  onChangeMatchOperator?: (operator: MatchOperator) => void;
85
93
  onChangeMove?: (mailboxId: string) => void;
86
94
  /**
87
- * Create a new destination folder from within the editor. Given a folder
88
- * name, resolves to the created folder once the backend has queued it. When
89
- * absent, the "New folder…" option is not offered the editor stays
90
- * 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.
91
100
  */
92
- onCreateFolder?: (name: string) => Promise<FolderOption>;
101
+ onCreateFolder?: (
102
+ name: string,
103
+ signal?: AbortSignal,
104
+ ) => Promise<FolderOption>;
93
105
  onChangeScope?: (scope: RuleScope) => void;
94
106
  onChangeName?: (name: string) => void;
95
107
  onChangeUntil?: (date: string) => void;
@@ -116,6 +128,13 @@ const CREATE_FOLDER_VALUE = "__filter_create_folder__";
116
128
  * name field; on resolve the new folder is added to the local option set (so it
117
129
  * is selectable even before the caller's folder list refetches) and picked as
118
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.
119
138
  */
120
139
  function MoveDestinationField({
121
140
  folders,
@@ -126,13 +145,21 @@ function MoveDestinationField({
126
145
  folders: FolderOption[];
127
146
  value: string;
128
147
  onChangeMove?: (mailboxId: string) => void;
129
- onCreateFolder?: (name: string) => Promise<FolderOption>;
148
+ onCreateFolder?: (
149
+ name: string,
150
+ signal?: AbortSignal,
151
+ ) => Promise<FolderOption>;
130
152
  }) {
131
153
  const [creating, setCreating] = useState(false);
132
154
  const [name, setName] = useState("");
133
155
  const [pending, setPending] = useState(false);
134
156
  const [error, setError] = useState<string>();
135
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(), []);
136
163
 
137
164
  const options = useMemo(() => {
138
165
  const known = new Set(folders.map((folder) => folder.id));
@@ -157,7 +184,10 @@ function MoveDestinationField({
157
184
  if (trimmed === "") return;
158
185
  setPending(true);
159
186
  setError(undefined);
160
- onCreateFolder(trimmed)
187
+ createAbort.current?.abort();
188
+ const controller = new AbortController();
189
+ createAbort.current = controller;
190
+ onCreateFolder(trimmed, controller.signal)
161
191
  .then((folder) => {
162
192
  setCreatedFolders((prev) =>
163
193
  prev.some((entry) => entry.id === folder.id)
@@ -170,6 +200,7 @@ function MoveDestinationField({
170
200
  setPending(false);
171
201
  })
172
202
  .catch((error: unknown) => {
203
+ if (isAbortError(error)) return;
173
204
  setError(
174
205
  error instanceof Error
175
206
  ? error.message
@@ -180,9 +211,11 @@ function MoveDestinationField({
180
211
  };
181
212
 
182
213
  const cancel = () => {
214
+ createAbort.current?.abort();
183
215
  setCreating(false);
184
216
  setName("");
185
217
  setError(undefined);
218
+ setPending(false);
186
219
  };
187
220
 
188
221
  return (
@@ -234,7 +267,7 @@ function MoveDestinationField({
234
267
  onClick={submit}
235
268
  disabled={pending || name.trim() === ""}
236
269
  >
237
- {pending ? "Creating…" : "Create folder"}
270
+ {pending ? "Creating folder…" : "Create folder"}
238
271
  </Button>
239
272
  <Button
240
273
  variant="ghost"
@@ -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";