@cplieger/actions 1.1.5 → 2.0.0

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/README.md CHANGED
@@ -7,7 +7,7 @@
7
7
 
8
8
  > Declarative UI-actions framework with lifecycle management, retry, debounce, and polling.
9
9
 
10
- A standalone TypeScript library for defining and dispatching UI actions with full lifecycle support: optimistic updates, automatic retry with backoff, scope serialization, dedupe collapsing, notification wiring, and a registry for observability. Zero runtime dependenciesnotification display and streaming transport are injected by the consumer via small interfaces.
10
+ A standalone TypeScript library for defining and dispatching UI actions with full lifecycle support: optimistic updates, automatic retry with backoff, scope serialization, dedupe collapsing, notification wiring, polling, button-feedback helpers, and a registry for observability. Built on [`@cplieger/reactive`](https://github.com/cplieger/reactive)action pending-state is signal-backed, so `isPending`/`pendingCount` are reactive and `bindLoadingState` is a plain effect over them. Notification display and streaming transport are injected by the consumer via small interfaces.
11
11
 
12
12
  ## Install
13
13
 
@@ -99,12 +99,14 @@ const action = apiAction({
99
99
  - `transportAction(def)` — create a transport/SSE-backed action
100
100
  - `debouncedDispatch(action, opts)` — debounce wrapper
101
101
  - `pollAction(action, args, opts)` — interval polling with pause/backoff
102
- - `bindLoadingState(name, el, opts?)` — bind element disabled state to action pending
103
- - `subscribeToActions(fn)` — subscribe to all lifecycle events
104
- - `subscribeByName(name, fn)` — subscribe to lifecycle events for a single action name
102
+ - `bindLoadingState(name, el, opts?)` — bind an element's disabled/aria-busy state to action pending; a reactive effect over the pending signals
103
+ - `pollUntil(step, opts)` — poll until a terminal condition (wait-then-poll, `until` predicate, `maxAttempts`/`timeoutMs` budgets, backoff-on-transient); returns `{status:'done'|'timeout'|'aborted'}`. A standalone sibling to `pollAction` for one-shot terminal-state waits.
104
+ - `withAsyncFeedback(btn, fn, opts?)` — per-button async feedback (spinner ✓/✗ → restore) with a re-entry guard + sr-only announce + injectable glyphs. `target?: HTMLElement` runs the cycle on a child slot via in-place element replacement (siblings/label untouched); `resetMs: 0` persists the outcome glyph (no auto-revert).
105
+ - `subscribeToActions(fn)` — subscribe to all lifecycle events (discrete event stream)
106
+ - `subscribeByName(name, fn)` — subscribe to lifecycle events for a single action name (discrete event stream)
105
107
  - `getActionLog()` — read the recent action log (for devtools/debugging)
106
- - `pendingCount(names?)` — query pending action count
107
- - `isPending(name)` — O(1) check if a named action is in-flight
108
+ - `pendingCount(names?)` — pending action count; reactive (tracks inside an effect)
109
+ - `isPending(name)` — check if a named action is in-flight; reactive (tracks inside an effect)
108
110
  - `registerCleanup(fn)` — register teardown hooks for page unload
109
111
  - `ActionError` — structured error class with status/code
110
112
  - `retryNetwork` — preset retry classifier for transient failures
@@ -114,6 +116,29 @@ const action = apiAction({
114
116
  - `API_TIMEOUT_MS` — default API request timeout (30 000 ms)
115
117
  - `RETRY_STANDARD` — standard retry config (2 retries, 300ms)
116
118
 
119
+ ### Test utilities (`@cplieger/actions/testing`)
120
+
121
+ The `./testing` subpath exports test-only helpers. Import only from test code:
122
+
123
+ ```typescript
124
+ import { resetActionFramework } from "@cplieger/actions/testing";
125
+
126
+ beforeEach(() => {
127
+ resetActionFramework();
128
+ });
129
+ ```
130
+
131
+ - `resetActionFramework()` — clear every framework state slot (define, registry, cleanup, api, transport, notifier). Call from `beforeEach()` to isolate tests.
132
+
133
+ > **Breaking change in v2.0:** the `./src/*` deep-import escape hatch was removed
134
+ > from `package.json` exports. Consumers that previously reached into
135
+ > `@cplieger/actions/src/define`, `…/src/registry`, `…/src/cleanup`,
136
+ > `…/src/api`, `…/src/transport`, or `…/src/notifier` to call
137
+ > `_resetForTest`/`_resetApiConfigForTest`/`_resetTransportForTest`/
138
+ > `_resetNotifierForTest` must migrate to
139
+ > `@cplieger/actions/testing` for `resetActionFramework()`, or to the
140
+ > public surface for everything else.
141
+
117
142
  ### Definition-level callbacks (TanStack Query pattern)
118
143
 
119
144
  `ActionDefinition` supports `onSuccess`, `onError`, and `onSettled` callbacks that fire on every dispatch without the caller needing to pass them each time:
package/jsr.json CHANGED
@@ -1,8 +1,9 @@
1
1
  {
2
2
  "name": "@cplieger/actions",
3
- "version": "1.1.5",
3
+ "version": "2.0.0",
4
4
  "exports": {
5
- ".": "./src/index.ts"
5
+ ".": "./src/index.ts",
6
+ "./testing": "./src/testing.ts"
6
7
  },
7
8
  "publish": {
8
9
  "include": [
package/package.json CHANGED
@@ -1,24 +1,27 @@
1
1
  {
2
2
  "name": "@cplieger/actions",
3
- "version": "1.1.5",
3
+ "version": "2.0.0",
4
4
  "type": "module",
5
5
  "sideEffects": false,
6
6
  "exports": {
7
7
  ".": "./src/index.ts",
8
- "./src/*": "./src/*"
8
+ "./testing": "./src/testing.ts"
9
9
  },
10
10
  "scripts": {
11
11
  "typecheck": "tsgo -project tsconfig.json",
12
12
  "typecheck:tests": "tsgo -project tsconfig.test.json",
13
13
  "test": "vitest --run"
14
14
  },
15
+ "dependencies": {
16
+ "@cplieger/reactive": "1.1.0"
17
+ },
15
18
  "devDependencies": {
16
19
  "@eslint/js": "10.0.1",
17
- "@types/node": "24.13.0",
20
+ "@types/node": "24.13.1",
18
21
  "@vitest/coverage-v8": "4.1.8",
19
22
  "eslint": "10.4.1",
20
23
  "fast-check": "4.8.0",
21
- "happy-dom": "20.10.1",
24
+ "happy-dom": "20.10.2",
22
25
  "prettier": "3.8.3",
23
26
  "typescript-eslint": "8.60.1",
24
27
  "vitest": "4.1.8"
@@ -0,0 +1,240 @@
1
+ // withAsyncFeedback: per-button async feedback. Shows a spinner during the
2
+ // operation, a ✓ on success / ✗ on error, then reverts. Disables the button
3
+ // while pending and guards against re-entry.
4
+ //
5
+ // Works for both icon-only buttons (action pills) and text buttons. The
6
+ // button's original child nodes and disabled state are snapshotted and
7
+ // restored when the feedback cycle resets. State is exposed as
8
+ // `data-async-status` on the button so CSS or tests can observe it without
9
+ // parsing innerHTML.
10
+ //
11
+ // Promoted from vibekit's async-button.ts. The glyphs are injectable so apps
12
+ // with their own icon system (e.g. `<span class="icon icon-check">`) can reuse
13
+ // it; the defaults match vibekit's inline SVGs, making vibekit a zero-visual-
14
+ // change consumer.
15
+ // ---------------------------------------------------------------------------
16
+
17
+ import { el } from "@cplieger/reactive";
18
+
19
+ const RESET_MS = 1200;
20
+
21
+ const DEFAULT_ANNOUNCE = { success: "Action completed", error: "Action failed" } as const;
22
+
23
+ const CHECK_HTML =
24
+ '<svg class="btn-async-glyph" width="14" height="14" viewBox="0 0 24 24" fill="none" ' +
25
+ 'stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round" ' +
26
+ 'aria-hidden="true"><polyline points="20 6 9 17 4 12"/></svg>';
27
+
28
+ const X_HTML =
29
+ '<svg class="btn-async-glyph" width="14" height="14" viewBox="0 0 24 24" fill="none" ' +
30
+ 'stroke="currentColor" stroke-width="3" stroke-linecap="round" ' +
31
+ 'aria-hidden="true"><path d="M18 6L6 18M6 6l12 12"/></svg>';
32
+
33
+ /** Parse a static SVG string via <template> and return a fresh clone. The
34
+ * markup is a library-controlled constant, so a single root element is
35
+ * guaranteed. */
36
+ function svgNode(svg: string): Node {
37
+ const template = document.createElement("template");
38
+ template.innerHTML = svg;
39
+ // eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- default glyph markup always has a single root element
40
+ return template.content.firstElementChild!.cloneNode(true);
41
+ }
42
+
43
+ const defaultRenderPending = (): Node =>
44
+ el("span", { className: "spinner-sm btn-async-spinner", "aria-hidden": "true" });
45
+ const defaultRenderSuccess = (): Node => svgNode(CHECK_HTML);
46
+ const defaultRenderError = (): Node => svgNode(X_HTML);
47
+
48
+ /** WeakMap tracking the pending reset timer per button. */
49
+ const resetTimers = new WeakMap<HTMLButtonElement, ReturnType<typeof setTimeout>>();
50
+
51
+ /** Tracks buttons with an in-flight operation. The re-entry guard keys on
52
+ * THIS (not `data-async-status`) so a persisted outcome glyph (resetMs<=0)
53
+ * does not permanently block a subsequent dispatch. */
54
+ const inFlight = new WeakSet<HTMLButtonElement>();
55
+
56
+ /** Lazily-created live region for announcing async-button outcomes. */
57
+ let liveRegion: HTMLElement | null = null;
58
+
59
+ function announce(message: string): void {
60
+ if (liveRegion === null) {
61
+ liveRegion = el("span", {
62
+ className: "sr-only",
63
+ "aria-live": "polite",
64
+ "aria-atomic": "true",
65
+ });
66
+ document.body.appendChild(liveRegion);
67
+ }
68
+ const region = liveRegion;
69
+ // Clear then set to ensure re-announcement of identical messages.
70
+ region.textContent = "";
71
+ setTimeout(() => {
72
+ region.textContent = message;
73
+ }, 50);
74
+ }
75
+
76
+ /** Options for {@link withAsyncFeedback}. All fields are optional; the glyph
77
+ * renderers default to vibekit's inline SVGs. */
78
+ export interface AsyncFeedbackOptions {
79
+ /** Post-completion glyph hold in ms before the content reverts. Default
80
+ * 1200. A value of `0` (or any value `<= 0`) means *persist*: the content
81
+ * revert is never scheduled, so the success/error glyph stays in place
82
+ * indefinitely (a later caller-driven re-render is expected to clear it).
83
+ * Under persist the button is still re-enabled and `aria-busy` restored at
84
+ * outcome time, but `data-async-status` keeps its terminal `success`/
85
+ * `error` value (it is not cleared). Applies to both the whole-button and
86
+ * `target` paths. */
87
+ resetMs?: number;
88
+ /** When true, prepend the spinner before the existing content (e.g.
89
+ * "⟳ Cloning…") instead of replacing the content with just the spinner.
90
+ * Default false (icon-only replace). Ignored when {@link target} is set. */
91
+ keepLabel?: boolean;
92
+ /** Opt-in: drive a single child slot of the button instead of the button's
93
+ * whole content. When provided, the spinner / outcome glyph / reset cycle
94
+ * operates on `target` by REPLACING THE ELEMENT IN THE DOM
95
+ * (`current.replaceWith(next)`), leaving every other child of `btn` (e.g. a
96
+ * text label sibling) untouched. The original `target` node reference is
97
+ * kept so a (non-persist) reset restores that exact node. `target` is
98
+ * expected to be a descendant of `btn` (not hard-validated). The
99
+ * button-level concerns are unchanged: `disabled` toggle, the
100
+ * `data-async-status` re-entry guard, `aria-busy`, and the sr-only
101
+ * announce all still apply to `btn`. `keepLabel` is irrelevant here and is
102
+ * ignored. Default: whole-button (childNodes) mode. */
103
+ target?: HTMLElement;
104
+ /** Pending-state node factory. Returns a fresh node per call. */
105
+ renderPending?: () => Node;
106
+ /** Success-glyph node factory. Returns a fresh node per call. */
107
+ renderSuccess?: () => Node;
108
+ /** Error-glyph node factory. Returns a fresh node per call. */
109
+ renderError?: () => Node;
110
+ /** Live-region announcement text, or `false` to disable announcing. */
111
+ announce?: { readonly success: string; readonly error: string } | false;
112
+ }
113
+
114
+ /** Run an async function with consistent button feedback. The button is
115
+ * disabled during the call. Re-entrant calls (clicking again while a cycle
116
+ * is active) are ignored. */
117
+ export async function withAsyncFeedback(
118
+ btn: HTMLButtonElement,
119
+ fn: () => Promise<unknown>,
120
+ opts: AsyncFeedbackOptions = {},
121
+ ): Promise<void> {
122
+ // Guard: reject re-entry only while an operation is in flight. (Keying on
123
+ // `data-async-status` would deadlock the persist path, whose terminal status
124
+ // is intentionally never cleared.)
125
+ if (inFlight.has(btn)) {
126
+ return;
127
+ }
128
+ inFlight.add(btn);
129
+
130
+ // Cancel any pending reset timer from a prior cycle to avoid stale restores.
131
+ const prevTimer = resetTimers.get(btn);
132
+ if (prevTimer !== undefined) {
133
+ clearTimeout(prevTimer);
134
+ resetTimers.delete(btn);
135
+ }
136
+
137
+ const renderPending = opts.renderPending ?? defaultRenderPending;
138
+ const renderSuccess = opts.renderSuccess ?? defaultRenderSuccess;
139
+ const renderError = opts.renderError ?? defaultRenderError;
140
+ const announceCfg = opts.announce ?? DEFAULT_ANNOUNCE;
141
+
142
+ // Target mode operates on a single child slot via element replacement and
143
+ // never snapshots/replaces the button's own childNodes. `originalTarget`
144
+ // keeps the exact node so a non-persist reset can restore it; `currentSlot`
145
+ // tracks whichever node currently occupies the slot.
146
+ const target = opts.target;
147
+ const useTarget = target !== undefined;
148
+ const originalTarget: ChildNode | null = useTarget ? target : null;
149
+ let currentSlot: ChildNode | null = useTarget ? target : null;
150
+
151
+ // Replace the live slot node with `next` and track it as the new slot.
152
+ // Render factories return a single node (Element/Text), both of which are
153
+ // ChildNode at runtime; the cast reflects that contract.
154
+ const swapSlot = (next: Node): void => {
155
+ if (currentSlot === null) {
156
+ return;
157
+ }
158
+ currentSlot.replaceWith(next);
159
+ currentSlot = next as ChildNode;
160
+ };
161
+
162
+ const origNodes = useTarget ? [] : [...btn.childNodes].map((n) => n.cloneNode(true));
163
+ const origDisabled = btn.disabled;
164
+ const origAriaBusy = btn.getAttribute("aria-busy");
165
+
166
+ const restoreAriaBusy = (): void => {
167
+ if (origAriaBusy === null) {
168
+ btn.removeAttribute("aria-busy");
169
+ } else {
170
+ btn.setAttribute("aria-busy", origAriaBusy);
171
+ }
172
+ };
173
+
174
+ btn.dataset["asyncStatus"] = "pending";
175
+ btn.disabled = true;
176
+ btn.setAttribute("aria-busy", "true");
177
+ if (useTarget) {
178
+ swapSlot(renderPending());
179
+ } else if (opts.keepLabel === true) {
180
+ btn.prepend(renderPending(), document.createTextNode(" "));
181
+ } else {
182
+ btn.replaceChildren(renderPending());
183
+ }
184
+
185
+ let ok = true;
186
+ try {
187
+ await fn();
188
+ } catch {
189
+ ok = false;
190
+ }
191
+
192
+ // The button may have been removed from the DOM by the async operation
193
+ // (e.g. a re-rendered list). Skip the success/error visual in that case —
194
+ // the new DOM already reflects the result.
195
+ if (!btn.isConnected) {
196
+ inFlight.delete(btn);
197
+ restoreAriaBusy();
198
+ delete btn.dataset["asyncStatus"];
199
+ return;
200
+ }
201
+
202
+ btn.dataset["asyncStatus"] = ok ? "success" : "error";
203
+ if (useTarget) {
204
+ swapSlot(ok ? renderSuccess() : renderError());
205
+ } else {
206
+ btn.replaceChildren(ok ? renderSuccess() : renderError());
207
+ }
208
+ restoreAriaBusy();
209
+ inFlight.delete(btn);
210
+ if (announceCfg !== false) {
211
+ announce(ok ? announceCfg.success : announceCfg.error);
212
+ }
213
+
214
+ // `resetMs <= 0` => persist: skip the content-revert timer entirely. The
215
+ // glyph stays and `data-async-status` keeps its terminal value, but the
216
+ // button is re-enabled now (there is no reset callback to do it later).
217
+ const reset = opts.resetMs ?? RESET_MS;
218
+ if (reset <= 0) {
219
+ btn.disabled = origDisabled;
220
+ return;
221
+ }
222
+
223
+ const timerId = setTimeout(() => {
224
+ resetTimers.delete(btn);
225
+ if (!btn.isConnected) {
226
+ return;
227
+ }
228
+ if (useTarget) {
229
+ if (currentSlot !== null && originalTarget !== null) {
230
+ currentSlot.replaceWith(originalTarget);
231
+ currentSlot = originalTarget;
232
+ }
233
+ } else {
234
+ btn.replaceChildren(...origNodes.map((n) => n.cloneNode(true)));
235
+ }
236
+ btn.disabled = origDisabled;
237
+ delete btn.dataset["asyncStatus"];
238
+ }, reset);
239
+ resetTimers.set(btn, timerId);
240
+ }
package/src/index.ts CHANGED
@@ -29,6 +29,10 @@ export {
29
29
  // Loading-state helper
30
30
  export { bindLoadingState } from "./loading.js";
31
31
 
32
+ // Async button-feedback helper
33
+ export { withAsyncFeedback } from "./async-feedback.js";
34
+ export type { AsyncFeedbackOptions } from "./async-feedback.js";
35
+
32
36
  // Cleanup hooks
33
37
  export { registerCleanup } from "./cleanup.js";
34
38
 
@@ -40,6 +44,10 @@ export type { DebouncedDispatch } from "./debounce.js";
40
44
  export { pollAction } from "./poll.js";
41
45
  export type { PollOptions } from "./poll.js";
42
46
 
47
+ // Poll-until-terminal helper
48
+ export { pollUntil } from "./poll-until.js";
49
+ export type { PollUntilOptions, PollUntilOutcome } from "./poll-until.js";
50
+
43
51
  // Types
44
52
  export type {
45
53
  Action,
package/src/loading.ts CHANGED
@@ -1,16 +1,20 @@
1
1
  // bindLoadingState: bind a button or input element's disabled / aria-busy
2
- // state to one or more named actions' pending count.
2
+ // state to one or more named actions' pending state.
3
3
  //
4
4
  // While ANY of the named actions is pending, the element gets:
5
5
  // - disabled = true
6
6
  // - aria-busy = "true" (omit by passing { ariaBusy: false })
7
7
  // - optionally an extra CSS class via { pendingClass: "btn-loading" }
8
8
  //
9
- // Returns an unsubscribe function. Call it from the view's teardown
10
- // hook to stop receiving updates and avoid leaking listeners.
9
+ // Implemented as a reactive effect over the registry's pending signals: it
10
+ // re-runs automatically whenever a bound action's pending state changes, so
11
+ // there is no bespoke subscription here. Returns a dispose function — call it
12
+ // from the view's teardown hook to stop updates and release the element.
11
13
  // ---------------------------------------------------------------------------
12
14
 
13
- import { subscribeByName, isPending, pendingCount } from "./registry.js";
15
+ import { effect } from "@cplieger/reactive";
16
+
17
+ import { isPending, pendingCount } from "./registry.js";
14
18
 
15
19
  /** Element types that have a `.disabled` writable boolean. */
16
20
  type DisableableElement =
@@ -56,6 +60,9 @@ export function bindLoadingState(
56
60
  let hadFocus = false;
57
61
  let disposed = false;
58
62
  let wasConnected = el.isConnected;
63
+ // Holder so run() can self-dispose the effect on detach without a
64
+ // forward-referenced `let` (assigned just below, after run() is defined).
65
+ const handle: { dispose?: () => void } = {};
59
66
 
60
67
  const resolveBase = (): boolean => {
61
68
  if (disabledFn !== undefined) {
@@ -89,21 +96,19 @@ export function bindLoadingState(
89
96
  // eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- length checked above
90
97
  names.length === 1 ? isPending(names[0]!) : pendingCount(names) > 0;
91
98
 
92
- // eslint-disable-next-line prefer-const -- assigned after apply() closure is defined
93
- let _unsubs: (() => void)[] | undefined;
94
-
95
- const apply = (): void => {
99
+ // Reactive effect: readPending() tracks the registry's pending signals, so
100
+ // this re-runs whenever a bound action's pending state changes.
101
+ const run = (): undefined => {
96
102
  if (disposed) {
97
- return;
103
+ return undefined;
98
104
  }
99
105
  if (wasConnected && !el.isConnected) {
106
+ // Element left the DOM without an explicit unbind — auto-dispose so we
107
+ // don't keep it (and the effect) alive. Defer the dispose: `dispose` is
108
+ // still unset during the effect's own first synchronous run.
100
109
  disposed = true;
101
- if (_unsubs) {
102
- for (const u of _unsubs) {
103
- u();
104
- }
105
- }
106
- return;
110
+ queueMicrotask(() => handle.dispose?.());
111
+ return undefined;
107
112
  }
108
113
  if (el.isConnected) {
109
114
  wasConnected = true;
@@ -125,6 +130,7 @@ export function bindLoadingState(
125
130
  setIdle();
126
131
  }
127
132
  wasPending = pending;
133
+ return undefined;
128
134
  };
129
135
 
130
136
  const restore = (): void => {
@@ -134,16 +140,11 @@ export function bindLoadingState(
134
140
  }
135
141
  };
136
142
 
137
- apply();
138
-
139
- const unsubs = names.map((name) => subscribeByName(name, apply));
140
- _unsubs = unsubs;
143
+ handle.dispose = effect(run);
141
144
 
142
145
  return () => {
143
146
  disposed = true;
144
147
  restore();
145
- for (const u of unsubs) {
146
- u();
147
- }
148
+ handle.dispose?.();
148
149
  };
149
150
  }
@@ -0,0 +1,167 @@
1
+ // pollUntil: poll-until-terminal primitive. Sibling to pollAction, but
2
+ // time-boxed rather than a forever periodic refresh: it repeatedly calls
3
+ // `step` on a wait-then-poll cadence until a terminal predicate matches, a
4
+ // max-attempts / wall-clock budget is exhausted, or an AbortSignal fires.
5
+ //
6
+ // Unlike pollAction it deliberately has NO pauseWhenHidden / refreshOnFocus —
7
+ // a device-flow / download-progress poll is a bounded flow the caller drives
8
+ // to completion, not a background refresh that should pause while the tab is
9
+ // hidden. That distinction is the whole reason pollAction does not fit.
10
+ // ---------------------------------------------------------------------------
11
+
12
+ /** Configuration for {@link pollUntil}. Controls cadence, the terminal
13
+ * predicate, optional attempt/time budgets, transient-failure backoff, and
14
+ * cancellation. */
15
+ export interface PollUntilOptions<T> {
16
+ /** Quiet window before each poll in ms. */
17
+ readonly intervalMs: number;
18
+ /** Terminal predicate: return true on a result that ends the poll. */
19
+ readonly until: (result: T) => boolean;
20
+ /** Stop after this many poll attempts. 0/undefined = unlimited. */
21
+ readonly maxAttempts?: number;
22
+ /** Overall wall-clock deadline in ms, measured from the call. */
23
+ readonly timeoutMs?: number;
24
+ /** Exponential backoff applied to the wait after consecutive transient
25
+ * failures (a null result or a throw). Reset on the next good poll. */
26
+ readonly backoff?: { readonly factor: number; readonly maxMs: number };
27
+ /** Called for a non-terminal successful poll (a non-null result that does
28
+ * not satisfy `until`). */
29
+ readonly onPoll?: (result: T) => void;
30
+ /** Called when a poll is a transient failure (step returned null or threw). */
31
+ readonly onTransientError?: () => void;
32
+ /** Abort the poll early. A pre-aborted signal resolves `aborted` without
33
+ * ever calling `step`. */
34
+ readonly signal?: AbortSignal;
35
+ }
36
+
37
+ /** Terminal outcome of {@link pollUntil}. */
38
+ export type PollUntilOutcome<T> =
39
+ | { readonly status: "done"; readonly result: T }
40
+ | { readonly status: "timeout" }
41
+ | { readonly status: "aborted" };
42
+
43
+ /** Sleep for `ms`, resolving early (never rejecting) if `signal` fires. The
44
+ * caller re-checks `signal.aborted` after waking to decide what to do. */
45
+ function sleepWithSignal(ms: number, signal: AbortSignal): Promise<void> {
46
+ if (signal.aborted) {
47
+ return Promise.resolve();
48
+ }
49
+ return new Promise<void>((resolve) => {
50
+ const ac = new AbortController();
51
+ const t = setTimeout(() => {
52
+ ac.abort();
53
+ resolve();
54
+ }, ms);
55
+ signal.addEventListener(
56
+ "abort",
57
+ () => {
58
+ clearTimeout(t);
59
+ ac.abort();
60
+ resolve();
61
+ },
62
+ { signal: ac.signal },
63
+ );
64
+ });
65
+ }
66
+
67
+ /**
68
+ * Repeatedly call `step` on a wait-then-poll cadence until it yields a
69
+ * terminal result, a budget is exhausted, or the signal aborts.
70
+ *
71
+ * Each iteration waits the current delay (abortable), then polls. A null
72
+ * result or a throw is a transient failure — `onTransientError` fires and,
73
+ * when `backoff` is set, the next wait grows. A non-null result resets the
74
+ * backoff; if it satisfies `until` the poll resolves `done`, otherwise
75
+ * `onPoll` fires and polling continues.
76
+ *
77
+ * @example
78
+ * ```ts
79
+ * const outcome = await pollUntil(
80
+ * (signal) => apiPoll("/api/device", signal),
81
+ * {
82
+ * intervalMs: 5000,
83
+ * until: (r) => r.status !== "pending",
84
+ * maxAttempts: 60,
85
+ * backoff: { factor: 2, maxMs: 60_000 },
86
+ * signal,
87
+ * },
88
+ * );
89
+ * ```
90
+ */
91
+ export async function pollUntil<T>(
92
+ step: (signal: AbortSignal) => Promise<T | null>,
93
+ opts: PollUntilOptions<T>,
94
+ ): Promise<PollUntilOutcome<T>> {
95
+ const { intervalMs, until, maxAttempts, timeoutMs, backoff, onPoll, onTransientError } = opts;
96
+ const signal = opts.signal ?? new AbortController().signal;
97
+
98
+ // (1) Pre-aborted: bail before any wait or step call.
99
+ if (signal.aborted) {
100
+ return { status: "aborted" };
101
+ }
102
+
103
+ const start = Date.now();
104
+ let attempts = 0;
105
+ let failures = 0;
106
+
107
+ for (;;) {
108
+ // (2) Wait the (possibly backed-off) delay, aborting early on signal.
109
+ const delay =
110
+ backoff !== undefined && failures > 0
111
+ ? Math.min(intervalMs * Math.pow(backoff.factor, failures), backoff.maxMs)
112
+ : intervalMs;
113
+ await sleepWithSignal(delay, signal);
114
+
115
+ // (3) Post-wait gates: abort, then attempt / time budgets.
116
+ // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- aborted flips during the awaited sleep
117
+ if (signal.aborted) {
118
+ return { status: "aborted" };
119
+ }
120
+ attempts += 1;
121
+ if (maxAttempts !== undefined && maxAttempts > 0 && attempts > maxAttempts) {
122
+ return { status: "timeout" };
123
+ }
124
+ if (timeoutMs !== undefined && Date.now() - start >= timeoutMs) {
125
+ return { status: "timeout" };
126
+ }
127
+
128
+ // (4) Poll. A throw or null result is transient: back off and retry.
129
+ let result: T | null;
130
+ try {
131
+ result = await step(signal);
132
+ } catch {
133
+ result = null;
134
+ }
135
+
136
+ // Abort that fired during step() wins over treating the (likely null)
137
+ // result as a transient failure — no spurious onTransientError on teardown.
138
+ // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- aborted flips during the awaited step
139
+ if (signal.aborted) {
140
+ return { status: "aborted" };
141
+ }
142
+
143
+ if (result === null) {
144
+ if (onTransientError !== undefined) {
145
+ try {
146
+ onTransientError();
147
+ } catch (e) {
148
+ console.error("[pollUntil] onTransientError threw", e);
149
+ }
150
+ }
151
+ failures += 1;
152
+ continue;
153
+ }
154
+
155
+ failures = 0;
156
+ if (until(result)) {
157
+ return { status: "done", result };
158
+ }
159
+ if (onPoll !== undefined) {
160
+ try {
161
+ onPoll(result);
162
+ } catch (e) {
163
+ console.error("[pollUntil] onPoll threw", e);
164
+ }
165
+ }
166
+ }
167
+ }
package/src/registry.ts CHANGED
@@ -4,8 +4,17 @@
4
4
  //
5
5
  // Performance: eviction uses a head-pointer + tombstones instead of
6
6
  // splice + O(n) index re-computation. record() is O(1) amortized.
7
+ //
8
+ // Pending state (isPending / pendingCount) is mirrored into reactive signals,
9
+ // so it can be read inside an effect — bindLoadingState is a plain effect over
10
+ // these, not a bespoke subscription. The pendingByName Set stays the source of
11
+ // truth for which ids are in flight; the signals expose the derived counts.
12
+ // The lifecycle fan-out below (record → listeners) is a discrete event stream
13
+ // and stays a plain emitter — events are not reactive state.
7
14
  // ---------------------------------------------------------------------------
8
15
 
16
+ import { signal, batch, SignalMap } from "@cplieger/reactive";
17
+
9
18
  import type { ActionInstance, RegistryListener } from "./types.js";
10
19
 
11
20
  const MAX_LOG_SIZE = 200;
@@ -24,6 +33,46 @@ let _pendingTotal = 0;
24
33
  let _liveCount = 0;
25
34
  let _head = 0;
26
35
 
36
+ // Reactive mirrors of the pending state. pendingByName remains the source of
37
+ // truth; these signals expose the derived counts so isPending/pendingCount can
38
+ // be read reactively (e.g. by bindLoadingState's effect).
39
+ const pendingSigs = new SignalMap<number>();
40
+ const pendingTotalSig = signal(0);
41
+
42
+ function addPending(name: string, id: string): void {
43
+ let s = pendingByName.get(name);
44
+ if (s === undefined) {
45
+ s = new Set();
46
+ pendingByName.set(name, s);
47
+ }
48
+ if (s.has(id)) {
49
+ return;
50
+ }
51
+ s.add(id);
52
+ _pendingTotal++;
53
+ const size = s.size;
54
+ batch(() => {
55
+ pendingSigs.ensure(name, 0).value = size;
56
+ pendingTotalSig.value = _pendingTotal;
57
+ });
58
+ }
59
+
60
+ function removePending(name: string, id: string): void {
61
+ const s = pendingByName.get(name);
62
+ if (!s?.delete(id)) {
63
+ return;
64
+ }
65
+ _pendingTotal--;
66
+ const size = s.size;
67
+ if (size === 0) {
68
+ pendingByName.delete(name);
69
+ }
70
+ batch(() => {
71
+ pendingSigs.ensure(name, 0).value = size;
72
+ pendingTotalSig.value = _pendingTotal;
73
+ });
74
+ }
75
+
27
76
  function compact(): void {
28
77
  while (_head < log.length && log[_head] === null) {
29
78
  _head++;
@@ -43,22 +92,9 @@ export function record(instance: ActionInstance): void {
43
92
  if (existing !== undefined) {
44
93
  const prev = existing.instance;
45
94
  if (prev.status === "pending" && instance.status !== "pending") {
46
- _pendingTotal--;
47
- const s = pendingByName.get(prev.name);
48
- if (s !== undefined) {
49
- s.delete(instance.id);
50
- if (s.size === 0) {
51
- pendingByName.delete(prev.name);
52
- }
53
- }
95
+ removePending(prev.name, instance.id);
54
96
  } else if (prev.status !== "pending" && instance.status === "pending") {
55
- _pendingTotal++;
56
- let s = pendingByName.get(instance.name);
57
- if (s === undefined) {
58
- s = new Set();
59
- pendingByName.set(instance.name, s);
60
- }
61
- s.add(instance.id);
97
+ addPending(instance.name, instance.id);
62
98
  }
63
99
  log[existing.index] = instance;
64
100
  existing.instance = instance;
@@ -87,13 +123,7 @@ export function record(instance: ActionInstance): void {
87
123
  idMap.set(instance.id, { instance, index: idx });
88
124
  _liveCount++;
89
125
  if (instance.status === "pending") {
90
- _pendingTotal++;
91
- let s = pendingByName.get(instance.name);
92
- if (s === undefined) {
93
- s = new Set();
94
- pendingByName.set(instance.name, s);
95
- }
96
- s.add(instance.id);
126
+ addPending(instance.name, instance.id);
97
127
  }
98
128
  if (_liveCount > MAX_LOG_SIZE) {
99
129
  for (let i = _head; i < log.length; i++) {
@@ -111,14 +141,7 @@ export function record(instance: ActionInstance): void {
111
141
  const entry = log[i];
112
142
  if (entry !== null && entry !== undefined) {
113
143
  if (entry.status === "pending") {
114
- _pendingTotal--;
115
- const s = pendingByName.get(entry.name);
116
- if (s !== undefined) {
117
- s.delete(entry.id);
118
- if (s.size === 0) {
119
- pendingByName.delete(entry.name);
120
- }
121
- }
144
+ removePending(entry.name, entry.id);
122
145
  }
123
146
  idMap.delete(entry.id);
124
147
  log[i] = null;
@@ -132,6 +155,7 @@ export function record(instance: ActionInstance): void {
132
155
  if (_pendingTotal < 0) {
133
156
  console.warn("[actions] _pendingTotal went negative — invariant violation; clamping to 0");
134
157
  _pendingTotal = 0;
158
+ pendingTotalSig.value = 0;
135
159
  }
136
160
  for (const fn of listeners) {
137
161
  try {
@@ -191,23 +215,20 @@ export function recentLog(): readonly ActionInstance[] {
191
215
  * debugging panels. Returns a snapshot of all live entries. */
192
216
  export const getActionLog = recentLog;
193
217
 
194
- /** O(1) check: true if at least one instance of the named action is pending. */
218
+ /** O(1) check: true if at least one instance of the named action is pending.
219
+ * Reactive — reading inside an effect tracks the name's pending signal. */
195
220
  export function isPending(name: string): boolean {
196
- const s = pendingByName.get(name);
197
- return s !== undefined && s.size > 0;
221
+ return pendingSigs.ensure(name, 0).value > 0;
198
222
  }
199
223
 
200
- /** Pending count for action(s). */
224
+ /** Pending count for action(s). Reactive — reads track the relevant signals. */
201
225
  export function pendingCount(names?: readonly string[]): number {
202
226
  if (names === undefined) {
203
- return _pendingTotal;
227
+ return pendingTotalSig.value;
204
228
  }
205
229
  let total = 0;
206
230
  for (const name of names) {
207
- const s = pendingByName.get(name);
208
- if (s !== undefined) {
209
- total += s.size;
210
- }
231
+ total += pendingSigs.ensure(name, 0).value;
211
232
  }
212
233
  return total;
213
234
  }
@@ -222,4 +243,6 @@ export function _resetForTest(): void {
222
243
  listeners.clear();
223
244
  namedListeners.clear();
224
245
  _pendingTotal = 0;
246
+ pendingSigs.clearAll();
247
+ pendingTotalSig.value = 0;
225
248
  }
package/src/testing.ts ADDED
@@ -0,0 +1,32 @@
1
+ /**
2
+ * Test-only utilities.
3
+ *
4
+ * Public subpath: `import { resetActionFramework } from "@cplieger/actions/testing"`.
5
+ *
6
+ * Replaces the previously-undocumented internal deep-imports
7
+ * (`_resetForTest` / `_resetApiConfigForTest` / `_resetTransportForTest` /
8
+ * `_resetNotifierForTest`) from `src/{define,registry,cleanup,api,transport,notifier}.ts`.
9
+ *
10
+ * Intended for consumer test suites that need to clear all framework state
11
+ * between tests. NOT for use in production code.
12
+ */
13
+
14
+ import { _resetForTest as resetDefine } from "./define.js";
15
+ import { _resetForTest as resetRegistry } from "./registry.js";
16
+ import { _resetForTest as resetCleanup } from "./cleanup.js";
17
+ import { _resetApiConfigForTest as resetApi } from "./api.js";
18
+ import { _resetTransportForTest as resetTransport } from "./transport.js";
19
+ import { _resetNotifierForTest as resetNotifier } from "./notifier.js";
20
+
21
+ /**
22
+ * Reset every framework state slot — define, registry, cleanup, api,
23
+ * transport, notifier. Call from `beforeEach()` in test suites.
24
+ */
25
+ export function resetActionFramework(): void {
26
+ resetDefine();
27
+ resetRegistry();
28
+ resetCleanup();
29
+ resetApi();
30
+ resetTransport();
31
+ resetNotifier();
32
+ }