@cplieger/actions 1.1.6 → 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 +31 -6
- package/jsr.json +3 -2
- package/package.json +6 -3
- package/src/async-feedback.ts +240 -0
- package/src/index.ts +8 -0
- package/src/loading.ts +23 -22
- package/src/poll-until.ts +167 -0
- package/src/registry.ts +62 -39
- package/src/testing.ts +32 -0
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.
|
|
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
|
-
- `
|
|
104
|
-
- `
|
|
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?)` —
|
|
107
|
-
- `isPending(name)` —
|
|
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
package/package.json
CHANGED
|
@@ -1,24 +1,27 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cplieger/actions",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "2.0.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"sideEffects": false,
|
|
6
6
|
"exports": {
|
|
7
7
|
".": "./src/index.ts",
|
|
8
|
-
"./
|
|
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
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.
|
|
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
|
|
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
|
-
//
|
|
10
|
-
//
|
|
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 {
|
|
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
|
-
//
|
|
93
|
-
|
|
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
|
-
|
|
102
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
227
|
+
return pendingTotalSig.value;
|
|
204
228
|
}
|
|
205
229
|
let total = 0;
|
|
206
230
|
for (const name of names) {
|
|
207
|
-
|
|
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
|
+
}
|