@cplieger/ui 0.0.0 → 1.0.0-dev.1

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.
Files changed (66) hide show
  1. package/NOTICE +3 -0
  2. package/README.md +145 -2
  3. package/THIRD_PARTY_NOTICES.md +8 -0
  4. package/css/ui-primitives.css +344 -0
  5. package/jsr.json +36 -4
  6. package/package.json +86 -4
  7. package/src/actions/api.ts +268 -0
  8. package/src/actions/async-feedback.ts +217 -0
  9. package/src/actions/cleanup.ts +66 -0
  10. package/src/actions/debounce.ts +150 -0
  11. package/src/actions/define-helpers.ts +87 -0
  12. package/src/actions/define.ts +851 -0
  13. package/src/actions/error.ts +161 -0
  14. package/src/actions/index.ts +60 -0
  15. package/src/actions/loading.ts +131 -0
  16. package/src/actions/notifier.ts +61 -0
  17. package/src/actions/poll-until.ts +133 -0
  18. package/src/actions/poll.ts +156 -0
  19. package/src/actions/registry.ts +259 -0
  20. package/src/actions/retry.ts +79 -0
  21. package/src/actions/testing.ts +29 -0
  22. package/src/actions/transport.ts +104 -0
  23. package/src/actions/types.ts +249 -0
  24. package/src/dom/bind-list.ts +103 -0
  25. package/src/dom/el.ts +67 -0
  26. package/src/dom/index.ts +9 -0
  27. package/src/dom/reconcile-tree.ts +268 -0
  28. package/src/dom/reconcile.ts +76 -0
  29. package/src/fetch/index.ts +18 -0
  30. package/src/fetch/instance.ts +37 -0
  31. package/src/fetch/request.ts +437 -0
  32. package/src/fetch/timeout.ts +67 -0
  33. package/src/fetch/types.ts +206 -0
  34. package/src/fetch/verbs.ts +103 -0
  35. package/src/index.ts +4 -8
  36. package/src/primitives/announce.ts +134 -0
  37. package/src/primitives/ask.ts +406 -0
  38. package/src/primitives/dialog-engine.ts +500 -0
  39. package/src/primitives/dialog.ts +263 -0
  40. package/src/primitives/disclosure.ts +287 -0
  41. package/src/primitives/focus-trap.ts +157 -0
  42. package/src/primitives/fx.ts +204 -0
  43. package/src/primitives/live-regions.ts +83 -0
  44. package/src/primitives/modal-host.ts +214 -0
  45. package/src/primitives/modal.ts +247 -0
  46. package/src/primitives/owner.ts +322 -0
  47. package/src/primitives/popover.ts +500 -0
  48. package/src/primitives/popup-core.ts +603 -0
  49. package/src/primitives/popup.ts +23 -0
  50. package/src/primitives/roving-focus.ts +198 -0
  51. package/src/primitives/scroll-lock.ts +119 -0
  52. package/src/primitives/skeleton.ts +142 -0
  53. package/src/primitives/theme.ts +202 -0
  54. package/src/primitives/toast/engine.ts +327 -0
  55. package/src/primitives/toast/index.ts +139 -0
  56. package/src/primitives/toast/view.ts +244 -0
  57. package/src/primitives/tooltip.ts +377 -0
  58. package/src/primitives/transition.ts +119 -0
  59. package/src/primitives/view-transition.ts +57 -0
  60. package/src/signal/bus.ts +126 -0
  61. package/src/signal/collection.ts +182 -0
  62. package/src/signal/index.ts +34 -0
  63. package/src/signal/resource.ts +102 -0
  64. package/src/signal/signal-map.ts +46 -0
  65. package/src/signal/signal.ts +1002 -0
  66. package/src/signal/store.ts +84 -0
@@ -0,0 +1,851 @@
1
+ import { notifyError, notifySuccess } from "./notifier.js";
2
+ import { toActionError } from "./error.js";
3
+ import { record } from "./registry.js";
4
+ import { sleep, waitForOnline, attachAttempts, readAttempts } from "./retry.js";
5
+ import { _registerAction } from "./cleanup.js";
6
+ import {
7
+ safeInvoke,
8
+ safeStringify,
9
+ _resetSymbols,
10
+ resolveNotification,
11
+ defaultErrorPrefix,
12
+ } from "./define-helpers.js";
13
+ import type {
14
+ Action,
15
+ ActionContext,
16
+ ActionDefinition,
17
+ ActionErrorLike,
18
+ ActionInstanceFields,
19
+ ActionOutcome,
20
+ DispatchHandle,
21
+ DispatchOptions,
22
+ } from "./types.js";
23
+
24
+ let instanceCounter = 0;
25
+
26
+ // Prefixed onto dedupe keys so two definitions sharing a name can never join
27
+ // each other's in-flight promise (cross-definition `as TResult` hazard).
28
+ let defCounter = 0;
29
+
30
+ const registeredNames = new Set<string>();
31
+
32
+ const NO_OPTS = Object.freeze({}) as DispatchOptions;
33
+ const NOOP = (): void => {
34
+ /* noop */
35
+ };
36
+
37
+ /** Preserves TimeoutError when aborted via AbortSignal.timeout(). */
38
+ function signalAbortError(signal: AbortSignal): DOMException {
39
+ if (signal.reason instanceof DOMException && signal.reason.name === "TimeoutError") {
40
+ return signal.reason;
41
+ }
42
+ return new DOMException("aborted", "AbortError");
43
+ }
44
+
45
+ function nextInstanceID(name: string): string {
46
+ instanceCounter += 1;
47
+ return `${name}#${String(instanceCounter)}`;
48
+ }
49
+
50
+ /** Header name used by apiAction when an idempotency key is generated. */
51
+ export const IDEMPOTENCY_HEADER = "Idempotency-Key";
52
+
53
+ function generateIdempotencyKey(): string {
54
+ const ts = Date.now().toString(36);
55
+ const buf = new Uint8Array(10);
56
+ crypto.getRandomValues(buf);
57
+ let rnd = "";
58
+ for (const b of buf) {
59
+ rnd += b.toString(36).padStart(2, "0");
60
+ }
61
+ return `${ts}-${rnd.slice(0, 14)}`;
62
+ }
63
+
64
+ const scopeChains = new Map<string, Promise<unknown>>();
65
+
66
+ function makeHandle<T>(
67
+ promise: Promise<T | null>,
68
+ abortFn: () => void,
69
+ outcome: Promise<ActionOutcome<T>>,
70
+ ): DispatchHandle<T> {
71
+ const handle = promise as DispatchHandle<T> & { outcome: Promise<ActionOutcome<T>> };
72
+ handle.abort = abortFn;
73
+ handle.outcome = outcome;
74
+ return handle;
75
+ }
76
+
77
+ /** How the leader dispatch ended, written by `runOnce` and read by every
78
+ * dispatch that joined its in-flight promise. `error` and `succeeded` are
79
+ * exhaustive: a leader that resolves `null` with neither set was cancelled. */
80
+ interface DedupeOutcome {
81
+ error?: ActionErrorLike;
82
+ succeeded?: boolean;
83
+ }
84
+ /** A slot reaches `activeDedupes` only once its leader's promise exists, which
85
+ * is what lets a joiner read `promise` without a presence check. */
86
+ interface DedupeSlot extends DedupeOutcome {
87
+ promise: Promise<unknown>;
88
+ }
89
+ const activeDedupes = new Map<string, DedupeSlot>();
90
+
91
+ type InstanceFields = Omit<ActionInstanceFields, "name"> & { readonly error?: ActionErrorLike };
92
+
93
+ /**
94
+ * Register an action and return the handle callers dispatch; call it once per
95
+ * action at module scope. A duplicate `def.name` only warns, and name-keyed
96
+ * helpers then answer for both definitions. Throws a TypeError for a
97
+ * `sensitive` definition with `retry` or `retryable`. Cancellation (`timeout`,
98
+ * `abort()`, `cancel()`, the dispatch's `signal`, page unload) only aborts the
99
+ * signal passed to `run`; a run that ignores it completes, result discarded.
100
+ */
101
+ export function defineAction<TArgs, TResult, TOp = unknown>(
102
+ def: ActionDefinition<TArgs, TResult, TOp>,
103
+ ): Action<TArgs, TResult> {
104
+ if (def.sensitive === true && (def.retry !== undefined || def.retryable !== undefined)) {
105
+ throw new TypeError(
106
+ `[actions] "${def.name}" is sensitive, so it takes neither retry nor retryable: a retry button would hold its args`,
107
+ );
108
+ }
109
+ defCounter += 1;
110
+ const defId = defCounter;
111
+ if (registeredNames.has(def.name)) {
112
+ console.warn(
113
+ `[actions] duplicate action name "${def.name}" — the registry log and name-keyed helpers (isPending, subscribeByName, bindLoadingState) will conflate the two definitions`,
114
+ );
115
+ } else {
116
+ registeredNames.add(def.name);
117
+ }
118
+ const inFlight = new Map<string, AbortController>();
119
+ const started = new Set<string>();
120
+ const scopeSkips = new Map<
121
+ string,
122
+ { readonly prev: Promise<unknown>; readonly resolve: () => void }
123
+ >();
124
+ const scopeCancelResolvers = new Map<string, () => void>();
125
+ const activeDedupeKeys = new Set<string>();
126
+
127
+ // Every registry write goes through here. A `sensitive` record copies only
128
+ // the lifecycle fields by name, never a spread, so args, result, an error
129
+ // whose message or cause holds them, and any field added later stay out of
130
+ // the log and every listener.
131
+ function recordState(
132
+ fields: InstanceFields,
133
+ args: TArgs,
134
+ success?: { readonly result: TResult },
135
+ ): void {
136
+ if (def.sensitive !== true) {
137
+ record({ ...fields, name: def.name, args, ...success });
138
+ return;
139
+ }
140
+ const { id, status, dispatchedAt, startedAt, completedAt, attempts } = fields;
141
+ record({
142
+ id,
143
+ name: def.name,
144
+ status,
145
+ dispatchedAt,
146
+ startedAt,
147
+ ...(completedAt !== undefined && { completedAt }),
148
+ ...(attempts !== undefined && { attempts }),
149
+ sensitive: true,
150
+ });
151
+ }
152
+
153
+ function fireDefSuccess(result: TResult, args: TArgs): void {
154
+ if (def.onSuccess) {
155
+ const cb = def.onSuccess;
156
+ safeInvoke(def.name, "def.onSuccess", () => {
157
+ cb(result, args);
158
+ });
159
+ }
160
+ }
161
+ function fireDefError(err: ActionErrorLike, args: TArgs): void {
162
+ if (def.onError) {
163
+ const cb = def.onError;
164
+ safeInvoke(def.name, "def.onError", () => {
165
+ cb(err, args);
166
+ });
167
+ }
168
+ }
169
+ function fireDefSettled(args: TArgs): void {
170
+ if (def.onSettled) {
171
+ const cb = def.onSettled;
172
+ safeInvoke(def.name, "def.onSettled", () => {
173
+ cb(args);
174
+ });
175
+ }
176
+ }
177
+ // Shared by the executing path and the dedupe-join path.
178
+ function fireSuccessCallbacks(
179
+ result: TResult,
180
+ args: TArgs,
181
+ opts: DispatchOptions<TArgs, TResult>,
182
+ ): void {
183
+ fireDefSuccess(result, args);
184
+ const onSuccessCb = opts.onSuccess;
185
+ if (onSuccessCb) {
186
+ safeInvoke(def.name, "onSuccess", () => {
187
+ onSuccessCb(result, args);
188
+ });
189
+ }
190
+ }
191
+ // Shared by the executing path and the dedupe-join path.
192
+ function fireErrorCallbacks(
193
+ err: ActionErrorLike,
194
+ args: TArgs,
195
+ opts: DispatchOptions<TArgs, TResult>,
196
+ ): void {
197
+ fireDefError(err, args);
198
+ const onErrorCb = opts.onError;
199
+ if (onErrorCb) {
200
+ safeInvoke(def.name, "onError", () => {
201
+ onErrorCb(err, args);
202
+ });
203
+ }
204
+ }
205
+
206
+ // A joiner owns no run, so aborting it (handle or signal) settles this call
207
+ // alone as cancelled and leaves the dispatch it joined running.
208
+ function joinDispatch(
209
+ entry: DedupeSlot,
210
+ args: TArgs,
211
+ opts: DispatchOptions<TArgs, TResult>,
212
+ outcomePromise: Promise<ActionOutcome<TResult>>,
213
+ resolveOutcome: (o: ActionOutcome<TResult>) => void,
214
+ fireSettledHooks: () => void,
215
+ ): DispatchHandle<TResult> {
216
+ const ext = opts.signal;
217
+ let done = false;
218
+ let resolveHandle!: (v: TResult | null) => void;
219
+ const handlePromise = new Promise<TResult | null>((r) => {
220
+ resolveHandle = r;
221
+ });
222
+ const finish = (): boolean => {
223
+ if (done) {
224
+ return false;
225
+ }
226
+ done = true;
227
+ ext?.removeEventListener("abort", detach);
228
+ return true;
229
+ };
230
+ function detach(): void {
231
+ if (finish()) {
232
+ resolveOutcome({ status: "cancelled" });
233
+ fireSettledHooks();
234
+ resolveHandle(null);
235
+ }
236
+ }
237
+ (entry.promise as Promise<TResult | null>).then(
238
+ (v) => {
239
+ if (!finish()) {
240
+ return;
241
+ }
242
+ if (v !== null || entry.succeeded === true) {
243
+ fireSuccessCallbacks(v as TResult, args, opts);
244
+ resolveOutcome({ status: "success", value: v as TResult });
245
+ } else if (entry.error !== undefined) {
246
+ const capturedErr = entry.error;
247
+ fireErrorCallbacks(capturedErr, args, opts);
248
+ resolveOutcome({ status: "error", error: capturedErr });
249
+ } else {
250
+ resolveOutcome({ status: "cancelled" });
251
+ }
252
+ fireSettledHooks();
253
+ resolveHandle(v);
254
+ },
255
+ (reason: unknown) => {
256
+ // Reached when the leader's own setup threw before `runOnce` could
257
+ // record an outcome, e.g. a `def.idempotencyKey` that raises.
258
+ if (!finish()) {
259
+ return;
260
+ }
261
+ resolveOutcome({ status: "error", error: toActionError(reason) });
262
+ fireSettledHooks();
263
+ resolveHandle(null);
264
+ },
265
+ );
266
+ if (ext?.aborted === true) {
267
+ detach();
268
+ } else {
269
+ ext?.addEventListener("abort", detach);
270
+ }
271
+ return makeHandle(handlePromise, detach, outcomePromise);
272
+ }
273
+
274
+ // A dispatch still queued behind its scope settles here at once and frees
275
+ // its place in the chain; a started one is left to runOnce, which sees the
276
+ // aborted signal.
277
+ function abortDispatch(id: string, reason?: unknown): void {
278
+ const controller = inFlight.get(id);
279
+ if (controller === undefined) {
280
+ return;
281
+ }
282
+ controller.abort(reason);
283
+ if (started.has(id)) {
284
+ return;
285
+ }
286
+ inFlight.delete(id);
287
+ const queued = scopeSkips.get(id);
288
+ if (queued !== undefined) {
289
+ scopeSkips.delete(id);
290
+ void queued.prev.then(queued.resolve, queued.resolve);
291
+ }
292
+ const earlyCancel = scopeCancelResolvers.get(id);
293
+ if (earlyCancel !== undefined) {
294
+ scopeCancelResolvers.delete(id);
295
+ earlyCancel();
296
+ }
297
+ }
298
+
299
+ function dispatch(
300
+ args: TArgs,
301
+ opts: DispatchOptions<TArgs, TResult> = NO_OPTS,
302
+ ): DispatchHandle<TResult> {
303
+ // Kept OUT of the legacy promise chain so existing resolution timing is untouched.
304
+ let resolveOutcome!: (o: ActionOutcome<TResult>) => void;
305
+ const outcomePromise = new Promise<ActionOutcome<TResult>>((r) => {
306
+ resolveOutcome = r;
307
+ });
308
+ const fireSettledHooks = (): void => {
309
+ fireDefSettled(args);
310
+ const onSettledCb = opts.onSettled;
311
+ if (onSettledCb) {
312
+ safeInvoke(def.name, "onSettled", () => {
313
+ onSettledCb(args);
314
+ });
315
+ }
316
+ };
317
+ const dedupeKey = dedupeKeyFor(args);
318
+ if (dedupeKey !== null) {
319
+ const entry = activeDedupes.get(dedupeKey);
320
+ if (entry !== undefined) {
321
+ return joinDispatch(entry, args, opts, outcomePromise, resolveOutcome, fireSettledHooks);
322
+ }
323
+ }
324
+
325
+ const scopeKey =
326
+ typeof def.scope === "function"
327
+ ? def.scope(args)
328
+ : typeof def.scope === "string"
329
+ ? def.scope
330
+ : null;
331
+
332
+ const ac = new AbortController();
333
+ const id = nextInstanceID(def.name);
334
+ inFlight.set(id, ac);
335
+ const dispatchedAt = Date.now();
336
+ const ext = opts.signal;
337
+ const onExtAbort = (): void => {
338
+ abortDispatch(id, ext?.reason);
339
+ };
340
+ // Aborted before runOnce, which starts synchronously when unscoped and
341
+ // must take its cancelled branch without calling `run`; a scoped dispatch
342
+ // is released from its queue once the chain below exists.
343
+ if (ext?.aborted === true) {
344
+ ac.abort();
345
+ } else {
346
+ ext?.addEventListener("abort", onExtAbort);
347
+ }
348
+
349
+ const dedupeEntry: DedupeOutcome | null = dedupeKey !== null ? {} : null;
350
+
351
+ let result: Promise<TResult | null>;
352
+ if (scopeKey === null) {
353
+ result = runOnce(args, opts, ac, id, dedupeEntry, dedupeKey, dispatchedAt, resolveOutcome);
354
+ } else {
355
+ const prev = scopeChains.get(scopeKey) ?? Promise.resolve();
356
+ const next = prev.then(() =>
357
+ runOnce(args, opts, ac, id, dedupeEntry, dedupeKey, dispatchedAt, resolveOutcome),
358
+ );
359
+ let tailResolve!: () => void;
360
+ const tail = new Promise<void>((r) => {
361
+ tailResolve = r;
362
+ });
363
+ // Paired in one entry because a skip that resolved ahead of `prev` would
364
+ // let a cancelled dispatch jump its own scope chain.
365
+ scopeSkips.set(id, { prev, resolve: tailResolve });
366
+ void next.then(tailResolve, tailResolve);
367
+ scopeChains.set(scopeKey, tail);
368
+ void next
369
+ .finally(() => {
370
+ scopeSkips.delete(id);
371
+ scopeCancelResolvers.delete(id);
372
+ if (scopeChains.get(scopeKey) === tail) {
373
+ scopeChains.delete(scopeKey);
374
+ }
375
+ })
376
+ .catch(NOOP);
377
+ let earlyCancelResolve!: (v: TResult | null) => void;
378
+ const earlyCancel = new Promise<TResult | null>((r) => {
379
+ earlyCancelResolve = r;
380
+ });
381
+ scopeCancelResolvers.set(id, () => {
382
+ try {
383
+ const now = Date.now();
384
+ evictDedupeSlot(dedupeKey, dedupeEntry);
385
+ recordState(
386
+ {
387
+ id,
388
+ status: "cancelled",
389
+ dispatchedAt,
390
+ startedAt: now,
391
+ completedAt: now,
392
+ },
393
+ args,
394
+ );
395
+ } finally {
396
+ fireSettledHooks();
397
+ resolveOutcome({ status: "cancelled" });
398
+ earlyCancelResolve(null);
399
+ }
400
+ });
401
+ result = Promise.race([next, earlyCancel]);
402
+ if (ac.signal.aborted) {
403
+ abortDispatch(id);
404
+ }
405
+ }
406
+ if (ext !== undefined) {
407
+ void result
408
+ .finally(() => {
409
+ ext.removeEventListener("abort", onExtAbort);
410
+ })
411
+ .catch(NOOP);
412
+ }
413
+
414
+ // A dedupe slot must describe a dispatch that is still running: runOnce's
415
+ // synchronous failure paths already left `inFlight`, so publishing a slot
416
+ // for them would hand the next same-key dispatch a finished error instead
417
+ // of letting it run.
418
+ if (dedupeKey !== null && dedupeEntry !== null && inFlight.has(id)) {
419
+ // `Object.assign` rather than a fresh object: `runOnce` holds this same
420
+ // reference and writes the leader's outcome onto it after publication.
421
+ const published: DedupeSlot = Object.assign(dedupeEntry, { promise: result });
422
+ activeDedupes.set(dedupeKey, published);
423
+ activeDedupeKeys.add(dedupeKey);
424
+ void result
425
+ .finally(() => {
426
+ if (activeDedupes.get(dedupeKey) === published) {
427
+ activeDedupes.delete(dedupeKey);
428
+ activeDedupeKeys.delete(dedupeKey);
429
+ }
430
+ })
431
+ .catch(NOOP);
432
+ }
433
+
434
+ return makeHandle(
435
+ result,
436
+ () => {
437
+ abortDispatch(id);
438
+ },
439
+ outcomePromise,
440
+ );
441
+ }
442
+
443
+ function dedupeKeyFor(args: TArgs): string | null {
444
+ const cfg = def.dedupe;
445
+ if (cfg === undefined || cfg === false) {
446
+ return null;
447
+ }
448
+ const argKey = typeof cfg === "function" ? cfg(args) : safeStringify(args);
449
+ // Without the defId prefix, two same-named definitions would join each
450
+ // other's in-flight promise and coerce a foreign result via `as TResult`.
451
+ return `${String(defId)}::${def.name}::${argKey}`;
452
+ }
453
+
454
+ function evictDedupeSlot(dk: string | null, entry: DedupeOutcome | null): void {
455
+ if (dk !== null && entry !== null && activeDedupes.get(dk) === entry) {
456
+ activeDedupes.delete(dk);
457
+ activeDedupeKeys.delete(dk);
458
+ }
459
+ }
460
+
461
+ async function runOnce(
462
+ args: TArgs,
463
+ opts: DispatchOptions<TArgs, TResult>,
464
+ ac: AbortController,
465
+ id: string,
466
+ dedupeEntry: DedupeOutcome | null,
467
+ dedupeKey: string | null,
468
+ dispatchedAt: number,
469
+ resolveOutcome: (o: ActionOutcome<TResult>) => void,
470
+ ): Promise<TResult | null> {
471
+ started.add(id);
472
+ if (!inFlight.has(id) && ac.signal.aborted) {
473
+ started.delete(id);
474
+ resolveOutcome({ status: "cancelled" });
475
+ return null;
476
+ }
477
+ const settle = (): void => {
478
+ inFlight.delete(id);
479
+ started.delete(id);
480
+ const onSettledCb = opts.onSettled;
481
+ if (onSettledCb) {
482
+ safeInvoke(def.name, "onSettled", () => {
483
+ onSettledCb(args);
484
+ });
485
+ }
486
+ };
487
+
488
+ // Reached only by an unscoped dispatch whose signal was aborted before it,
489
+ // so no dedupe slot was published yet.
490
+ if (ac.signal.aborted) {
491
+ const now = Date.now();
492
+ recordState(
493
+ {
494
+ id,
495
+ status: "cancelled",
496
+ dispatchedAt,
497
+ startedAt: now,
498
+ completedAt: now,
499
+ },
500
+ args,
501
+ );
502
+ fireDefSettled(args);
503
+ resolveOutcome({ status: "cancelled" });
504
+ settle();
505
+ return null;
506
+ }
507
+
508
+ const startedAt = Date.now();
509
+
510
+ const idemKey =
511
+ typeof def.idempotencyKey === "function"
512
+ ? def.idempotencyKey(args)
513
+ : def.idempotencyKey === true
514
+ ? generateIdempotencyKey()
515
+ : null;
516
+ const ctx: ActionContext =
517
+ idemKey !== null ? { instanceID: id, idempotencyKey: idemKey } : { instanceID: id };
518
+
519
+ const runSignal =
520
+ def.timeout !== undefined
521
+ ? AbortSignal.any([ac.signal, AbortSignal.timeout(def.timeout)])
522
+ : ac.signal;
523
+
524
+ let optOp: TOp | undefined;
525
+ if (def.optimistic !== undefined) {
526
+ try {
527
+ optOp = def.optimistic(args);
528
+ } catch (e) {
529
+ const raw = toActionError(e);
530
+ const err: ActionErrorLike =
531
+ raw.code !== undefined ? raw : { ...raw, code: "optimistic_failed" };
532
+ if (dedupeEntry !== null) {
533
+ dedupeEntry.error = err;
534
+ }
535
+ evictDedupeSlot(dedupeKey, dedupeEntry);
536
+ recordState(
537
+ {
538
+ id,
539
+ status: "error",
540
+ dispatchedAt,
541
+ startedAt,
542
+ completedAt: Date.now(),
543
+ error: err,
544
+ },
545
+ args,
546
+ );
547
+ emitErrorToast(args, err);
548
+ fireErrorCallbacks(err, args, opts);
549
+ fireDefSettled(args);
550
+ resolveOutcome({ status: "error", error: err });
551
+ settle();
552
+ return null;
553
+ }
554
+ }
555
+
556
+ recordState(
557
+ {
558
+ id,
559
+ status: "pending",
560
+ dispatchedAt,
561
+ startedAt,
562
+ },
563
+ args,
564
+ );
565
+
566
+ try {
567
+ const { result, attempts } = await runWithRetry(args, runSignal, ctx);
568
+ // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- signal state changes during async
569
+ if (ac.signal.aborted) {
570
+ evictDedupeSlot(dedupeKey, dedupeEntry);
571
+ recordState(
572
+ {
573
+ id,
574
+ status: "cancelled",
575
+ dispatchedAt,
576
+ startedAt,
577
+ completedAt: Date.now(),
578
+ attempts,
579
+ },
580
+ args,
581
+ );
582
+ if (def.rollback !== undefined) {
583
+ try {
584
+ def.rollback(args, optOp, { message: "cancelled", code: "cancelled" });
585
+ } catch (e) {
586
+ console.error(`[actions] rollback (cancellation) for ${def.name} threw`, e);
587
+ }
588
+ }
589
+ fireDefSettled(args);
590
+ resolveOutcome({ status: "cancelled" });
591
+ return null;
592
+ }
593
+ recordState(
594
+ {
595
+ id,
596
+ status: "success",
597
+ dispatchedAt,
598
+ startedAt,
599
+ completedAt: Date.now(),
600
+ attempts,
601
+ },
602
+ args,
603
+ { result },
604
+ );
605
+ if (dedupeEntry !== null) {
606
+ dedupeEntry.succeeded = true;
607
+ }
608
+ evictDedupeSlot(dedupeKey, dedupeEntry);
609
+ emitSuccessToast(args, result, opts);
610
+ fireSuccessCallbacks(result, args, opts);
611
+ fireDefSettled(args);
612
+ resolveOutcome({ status: "success", value: result, attempts });
613
+ return result;
614
+ } catch (e: unknown) {
615
+ const err = toActionError(e);
616
+ const attempts = readAttempts(e);
617
+ const cancelled = ac.signal.aborted as boolean;
618
+ const status = cancelled ? "cancelled" : "error";
619
+ if (dedupeEntry !== null && !cancelled) {
620
+ dedupeEntry.error = err;
621
+ }
622
+ evictDedupeSlot(dedupeKey, dedupeEntry);
623
+ recordState(
624
+ {
625
+ id,
626
+ status,
627
+ dispatchedAt,
628
+ startedAt,
629
+ completedAt: Date.now(),
630
+ ...(!cancelled && { error: err }),
631
+ ...(attempts !== undefined && { attempts }),
632
+ },
633
+ args,
634
+ );
635
+ if (def.rollback !== undefined) {
636
+ try {
637
+ const rbError = cancelled ? { message: "cancelled", code: "cancelled" } : err;
638
+ def.rollback(args, optOp, rbError);
639
+ } catch (rbCaught) {
640
+ console.error(`[actions] rollback for ${def.name} threw`, rbCaught);
641
+ }
642
+ }
643
+ if (!cancelled) {
644
+ emitErrorToast(args, err);
645
+ fireErrorCallbacks(err, args, opts);
646
+ }
647
+ fireDefSettled(args);
648
+ if (cancelled) {
649
+ resolveOutcome({ status: "cancelled" });
650
+ } else {
651
+ resolveOutcome({
652
+ status: "error",
653
+ error: err,
654
+ ...(attempts !== undefined && { attempts }),
655
+ });
656
+ }
657
+ return null;
658
+ } finally {
659
+ settle();
660
+ }
661
+ }
662
+
663
+ async function runWithRetry(
664
+ args: TArgs,
665
+ signal: AbortSignal,
666
+ ctx: ActionContext,
667
+ ): Promise<{ result: TResult; attempts: number }> {
668
+ const cfg = def.retry;
669
+ const maxAttempts = (cfg?.count ?? 0) + 1;
670
+ const baseDelay = cfg?.delay;
671
+ const factor = cfg?.factor ?? 2;
672
+ const networkMode = def.networkMode ?? "online";
673
+ let attempt = 0;
674
+ // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- intentional infinite loop with throw exits
675
+ while (true) {
676
+ if (signal.aborted) {
677
+ const abortErr = signalAbortError(signal);
678
+ attachAttempts(abortErr, attempt);
679
+ throw abortErr;
680
+ }
681
+ try {
682
+ attempt++;
683
+ const result = await def.run(args, signal, ctx);
684
+ return { result, attempts: attempt };
685
+ } catch (e) {
686
+ // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- signal state changes during async
687
+ if (signal.aborted) {
688
+ attachAttempts(e, attempt);
689
+ throw e;
690
+ }
691
+ if (attempt >= maxAttempts) {
692
+ attachAttempts(e, attempt);
693
+ throw e;
694
+ }
695
+ const err = toActionError(e);
696
+ if (!shouldRetry(err)) {
697
+ attachAttempts(e, attempt);
698
+ throw e;
699
+ }
700
+
701
+ if (networkMode === "online") {
702
+ try {
703
+ await waitForOnline(signal);
704
+ } catch {
705
+ const abortErr = signalAbortError(signal);
706
+ attachAttempts(abortErr, attempt);
707
+ throw abortErr;
708
+ }
709
+ }
710
+
711
+ let delayMs: number;
712
+ if (typeof baseDelay === "function") {
713
+ try {
714
+ delayMs = baseDelay(attempt, err);
715
+ } catch {
716
+ delayMs = 0;
717
+ }
718
+ } else if (typeof baseDelay === "number") {
719
+ delayMs = Math.min(baseDelay * Math.pow(factor, attempt - 1), 5000);
720
+ } else {
721
+ delayMs = 0;
722
+ }
723
+
724
+ try {
725
+ await sleep(delayMs, signal);
726
+ } catch {
727
+ const abortErr = signalAbortError(signal);
728
+ attachAttempts(abortErr, attempt);
729
+ throw abortErr;
730
+ }
731
+ }
732
+ }
733
+ }
734
+
735
+ function shouldRetry(err: ActionErrorLike): boolean {
736
+ if (def.retryable === undefined) {
737
+ return false;
738
+ }
739
+ try {
740
+ return def.retryable(err);
741
+ } catch {
742
+ return false;
743
+ }
744
+ }
745
+
746
+ function emitSuccessToast(
747
+ args: TArgs,
748
+ result: TResult,
749
+ opts: DispatchOptions<TArgs, TResult>,
750
+ ): void {
751
+ if (opts.silent === true) {
752
+ return;
753
+ }
754
+ try {
755
+ const msg = resolveNotification(def.success, args, result);
756
+ if (msg !== null) {
757
+ notifySuccess(msg);
758
+ }
759
+ } catch (e) {
760
+ console.error(`[actions] emitSuccessToast for ${def.name} threw`, e);
761
+ }
762
+ }
763
+
764
+ function emitErrorToast(args: TArgs, err: ActionErrorLike): void {
765
+ const spec = def.error;
766
+ if (spec === false) {
767
+ return;
768
+ }
769
+ const fallbackMsg = `${defaultErrorPrefix(def.name)}: ${err.message}`;
770
+ const retry = buildRetryButton(args, err);
771
+ try {
772
+ let msg: string;
773
+ if (typeof spec === "string") {
774
+ msg = `${spec}: ${err.message}`;
775
+ } else if (typeof spec === "function") {
776
+ msg = spec(args, err);
777
+ } else {
778
+ msg = fallbackMsg;
779
+ }
780
+ notifyError(msg, retry);
781
+ } catch (e) {
782
+ console.error(`[actions] emitErrorToast for ${def.name} threw`, e);
783
+ notifyError(fallbackMsg, retry);
784
+ }
785
+ }
786
+
787
+ function buildRetryButton(
788
+ args: TArgs,
789
+ err: ActionErrorLike,
790
+ ): { onClick: () => void } | undefined {
791
+ if (!shouldRetry(err)) {
792
+ return undefined;
793
+ }
794
+ let frozenArgs: TArgs;
795
+ try {
796
+ frozenArgs = structuredClone(args);
797
+ } catch {
798
+ if (args === null || args === undefined || typeof args !== "object") {
799
+ frozenArgs = args;
800
+ } else {
801
+ try {
802
+ frozenArgs = (Array.isArray(args) ? [...args] : { ...args }) as TArgs;
803
+ } catch {
804
+ frozenArgs = args;
805
+ }
806
+ }
807
+ }
808
+ return {
809
+ onClick: () => {
810
+ void dispatch(frozenArgs);
811
+ },
812
+ };
813
+ }
814
+
815
+ function cancel(): void {
816
+ if (inFlight.size === 0) {
817
+ return;
818
+ }
819
+ for (const dk of activeDedupeKeys) {
820
+ activeDedupes.delete(dk);
821
+ }
822
+ activeDedupeKeys.clear();
823
+ for (const id of [...inFlight.keys()]) {
824
+ abortDispatch(id);
825
+ }
826
+ }
827
+
828
+ const action: Action<TArgs, TResult> = {
829
+ name: def.name,
830
+ dispatch,
831
+ cancel,
832
+ };
833
+
834
+ _registerAction(action);
835
+
836
+ return action;
837
+ }
838
+
839
+ export function _resetForTest(): void {
840
+ instanceCounter = 0;
841
+ defCounter = 0;
842
+ registeredNames.clear();
843
+ _resetSymbols();
844
+ scopeChains.clear();
845
+ activeDedupes.clear();
846
+ }
847
+
848
+ // deadset:ignore DS1004 -- the leak tests read the private registry sizes to prove they drain to empty
849
+ export function _internalsForTest(): { scopeChains: number; activeDedupes: number } {
850
+ return { scopeChains: scopeChains.size, activeDedupes: activeDedupes.size };
851
+ }