@cplieger/ui 0.0.0-stage → 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.
- package/LICENSE +202 -0
- package/NOTICE +3 -0
- package/README.md +157 -2
- package/THIRD_PARTY_NOTICES.md +8 -0
- package/css/ui-primitives.css +344 -0
- package/jsr.json +44 -0
- package/package.json +105 -4
- package/src/actions/api.ts +268 -0
- package/src/actions/async-feedback.ts +217 -0
- package/src/actions/cleanup.ts +66 -0
- package/src/actions/debounce.ts +150 -0
- package/src/actions/define-helpers.ts +87 -0
- package/src/actions/define.ts +851 -0
- package/src/actions/error.ts +161 -0
- package/src/actions/index.ts +60 -0
- package/src/actions/loading.ts +131 -0
- package/src/actions/notifier.ts +61 -0
- package/src/actions/poll-until.ts +133 -0
- package/src/actions/poll.ts +156 -0
- package/src/actions/registry.ts +259 -0
- package/src/actions/retry.ts +79 -0
- package/src/actions/testing.ts +29 -0
- package/src/actions/transport.ts +104 -0
- package/src/actions/types.ts +249 -0
- package/src/dom/bind-list.ts +103 -0
- package/src/dom/el.ts +67 -0
- package/src/dom/index.ts +9 -0
- package/src/dom/reconcile-tree.ts +268 -0
- package/src/dom/reconcile.ts +76 -0
- package/src/fetch/index.ts +18 -0
- package/src/fetch/instance.ts +37 -0
- package/src/fetch/request.ts +437 -0
- package/src/fetch/timeout.ts +67 -0
- package/src/fetch/types.ts +206 -0
- package/src/fetch/verbs.ts +103 -0
- package/src/index.ts +4 -0
- package/src/primitives/announce.ts +134 -0
- package/src/primitives/ask.ts +406 -0
- package/src/primitives/dialog-engine.ts +500 -0
- package/src/primitives/dialog.ts +263 -0
- package/src/primitives/disclosure.ts +287 -0
- package/src/primitives/focus-trap.ts +157 -0
- package/src/primitives/fx.ts +204 -0
- package/src/primitives/live-regions.ts +83 -0
- package/src/primitives/modal-host.ts +214 -0
- package/src/primitives/modal.ts +247 -0
- package/src/primitives/owner.ts +322 -0
- package/src/primitives/popover.ts +500 -0
- package/src/primitives/popup-core.ts +603 -0
- package/src/primitives/popup.ts +23 -0
- package/src/primitives/roving-focus.ts +198 -0
- package/src/primitives/scroll-lock.ts +119 -0
- package/src/primitives/skeleton.ts +142 -0
- package/src/primitives/theme.ts +202 -0
- package/src/primitives/toast/engine.ts +327 -0
- package/src/primitives/toast/index.ts +139 -0
- package/src/primitives/toast/view.ts +244 -0
- package/src/primitives/tooltip.ts +377 -0
- package/src/primitives/transition.ts +119 -0
- package/src/primitives/view-transition.ts +57 -0
- package/src/signal/bus.ts +126 -0
- package/src/signal/collection.ts +182 -0
- package/src/signal/index.ts +34 -0
- package/src/signal/resource.ts +102 -0
- package/src/signal/signal-map.ts +46 -0
- package/src/signal/signal.ts +1002 -0
- package/src/signal/store.ts +84 -0
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
import type { ActionErrorLike } from "./types.js";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Structured error thrown from an action's `run()` to signal a typed failure.
|
|
5
|
+
*
|
|
6
|
+
* @example
|
|
7
|
+
* ```ts
|
|
8
|
+
* throw new ActionError("Server rejected", { status: 409, code: "conflict" });
|
|
9
|
+
* ```
|
|
10
|
+
*/
|
|
11
|
+
export class ActionError extends Error implements ActionErrorLike {
|
|
12
|
+
readonly status?: number;
|
|
13
|
+
readonly code?: string;
|
|
14
|
+
override readonly cause?: unknown;
|
|
15
|
+
|
|
16
|
+
constructor(message: string, opts?: { status?: number; code?: string; cause?: unknown }) {
|
|
17
|
+
super(message);
|
|
18
|
+
this.name = "ActionError";
|
|
19
|
+
if (opts?.status !== undefined) {
|
|
20
|
+
this.status = opts.status;
|
|
21
|
+
}
|
|
22
|
+
if (opts?.code !== undefined) {
|
|
23
|
+
this.code = opts.code;
|
|
24
|
+
}
|
|
25
|
+
if (opts?.cause !== undefined) {
|
|
26
|
+
this.cause = opts.cause;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** True when `v` is a non-null object with a string `error` property. */
|
|
32
|
+
export function hasErrorString(v: unknown): v is { error: string } {
|
|
33
|
+
if (typeof v !== "object" || v === null || !("error" in v)) {
|
|
34
|
+
return false;
|
|
35
|
+
}
|
|
36
|
+
return typeof v.error === "string";
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Coerce any thrown value into an ActionErrorLike snapshot. */
|
|
40
|
+
export function toActionError(e: unknown): ActionErrorLike {
|
|
41
|
+
if (e instanceof ActionError) {
|
|
42
|
+
const r: { message: string; status?: number; code?: string; cause?: unknown } = {
|
|
43
|
+
message: e.message,
|
|
44
|
+
};
|
|
45
|
+
if (e.status !== undefined) {
|
|
46
|
+
r.status = e.status;
|
|
47
|
+
}
|
|
48
|
+
if (e.code !== undefined) {
|
|
49
|
+
r.code = e.code;
|
|
50
|
+
}
|
|
51
|
+
if (e.cause !== undefined) {
|
|
52
|
+
r.cause = e.cause;
|
|
53
|
+
}
|
|
54
|
+
return r;
|
|
55
|
+
}
|
|
56
|
+
if (e instanceof DOMException) {
|
|
57
|
+
const code =
|
|
58
|
+
e.name === "TimeoutError"
|
|
59
|
+
? "timeout"
|
|
60
|
+
: e.name === "AbortError"
|
|
61
|
+
? "cancelled"
|
|
62
|
+
: e.name === "NetworkError"
|
|
63
|
+
? "network"
|
|
64
|
+
: e.name.toLowerCase();
|
|
65
|
+
const isNetLayer = code === "network" || code === "timeout";
|
|
66
|
+
return { message: e.message, code, ...(isNetLayer && { status: 0 }), cause: e };
|
|
67
|
+
}
|
|
68
|
+
if (e instanceof AggregateError) {
|
|
69
|
+
const first: unknown = e.errors[0];
|
|
70
|
+
const inner = first instanceof Error ? first.message : e.message;
|
|
71
|
+
return { message: inner || e.message, code: "aggregate", cause: e };
|
|
72
|
+
}
|
|
73
|
+
if (e instanceof Error) {
|
|
74
|
+
const rawStatus = "status" in e ? (e as { status: unknown }).status : undefined;
|
|
75
|
+
const status = typeof rawStatus === "number" ? rawStatus : undefined;
|
|
76
|
+
const rawCode = "code" in e ? (e as { code: unknown }).code : undefined;
|
|
77
|
+
const code = typeof rawCode === "string" ? rawCode : undefined;
|
|
78
|
+
const r: { message: string; status?: number; code?: string; cause: unknown } = {
|
|
79
|
+
message: e.message,
|
|
80
|
+
cause: e,
|
|
81
|
+
};
|
|
82
|
+
if (status !== undefined) {
|
|
83
|
+
r.status = status;
|
|
84
|
+
}
|
|
85
|
+
if (code !== undefined) {
|
|
86
|
+
r.code = code;
|
|
87
|
+
}
|
|
88
|
+
return r;
|
|
89
|
+
}
|
|
90
|
+
if (typeof e === "object" && e !== null && "message" in e) {
|
|
91
|
+
const obj = e as { message: unknown; status?: unknown; code?: unknown };
|
|
92
|
+
const message = typeof obj.message === "string" ? obj.message : String(obj.message);
|
|
93
|
+
const status = typeof obj.status === "number" ? obj.status : undefined;
|
|
94
|
+
const code = typeof obj.code === "string" ? obj.code : undefined;
|
|
95
|
+
const r: { message: string; status?: number; code?: string; cause: unknown } = {
|
|
96
|
+
message,
|
|
97
|
+
cause: e,
|
|
98
|
+
};
|
|
99
|
+
if (status !== undefined) {
|
|
100
|
+
r.status = status;
|
|
101
|
+
}
|
|
102
|
+
if (code !== undefined) {
|
|
103
|
+
r.code = code;
|
|
104
|
+
}
|
|
105
|
+
return r;
|
|
106
|
+
}
|
|
107
|
+
if (e === null) {
|
|
108
|
+
return { message: "Unknown error. The thrown value was null.", code: "unknown" };
|
|
109
|
+
}
|
|
110
|
+
if (e === undefined) {
|
|
111
|
+
return { message: "Unknown error. The thrown value was undefined.", code: "unknown" };
|
|
112
|
+
}
|
|
113
|
+
// eslint-disable-next-line @typescript-eslint/no-base-to-string -- intentional coercion of unknown thrown value
|
|
114
|
+
const msg = String(e);
|
|
115
|
+
return {
|
|
116
|
+
message: msg !== "" ? msg : "Unknown error. The thrown value was empty.",
|
|
117
|
+
code: "unknown",
|
|
118
|
+
cause: e,
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Classify a caught fetch error into an ActionError with a canonical code:
|
|
124
|
+
* aborted signal -> "cancelled", DOMException Timeout/AbortError -> "timeout",
|
|
125
|
+
* else -> "network".
|
|
126
|
+
*/
|
|
127
|
+
export function classifyFetchError(e: unknown, signal: AbortSignal): ActionError {
|
|
128
|
+
if (signal.aborted) {
|
|
129
|
+
return new ActionError("Request cancelled", { code: "cancelled", cause: e });
|
|
130
|
+
}
|
|
131
|
+
if (e instanceof DOMException) {
|
|
132
|
+
if (e.name === "TimeoutError" || e.name === "AbortError") {
|
|
133
|
+
return new ActionError("Request timed out", { status: 0, code: "timeout", cause: e });
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
if (e instanceof TypeError) {
|
|
137
|
+
return new ActionError(e.message, { status: 0, code: "network", cause: e });
|
|
138
|
+
}
|
|
139
|
+
const msg = e instanceof Error ? e.message : "network error";
|
|
140
|
+
return new ActionError(msg, { status: 0, code: "network", cause: e });
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** HTTP statuses that represent transient server-side conditions. */
|
|
144
|
+
const TRANSIENT_STATUSES = new Set([408, 429, 502, 503, 504]);
|
|
145
|
+
|
|
146
|
+
/** Retry classifier preset: network/timeout failures and transient HTTP statuses. */
|
|
147
|
+
export function retryNetwork(err: ActionErrorLike): boolean {
|
|
148
|
+
if (err.code === "cancelled") {
|
|
149
|
+
return false;
|
|
150
|
+
}
|
|
151
|
+
if (err.code === "network" || err.code === "timeout") {
|
|
152
|
+
return true;
|
|
153
|
+
}
|
|
154
|
+
if (err.status === 0) {
|
|
155
|
+
return true;
|
|
156
|
+
}
|
|
157
|
+
if (err.status !== undefined && TRANSIENT_STATUSES.has(err.status)) {
|
|
158
|
+
return true;
|
|
159
|
+
}
|
|
160
|
+
return false;
|
|
161
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/** Server actions with pending state, retries, optimistic rollback, polling and debouncing. @module */
|
|
2
|
+
|
|
3
|
+
export { configure } from "./notifier.js";
|
|
4
|
+
export type { Notifier, NotifierRetry } from "./notifier.js";
|
|
5
|
+
|
|
6
|
+
export { configureTransport, transportAction, IDEMPOTENCY_COMMAND_FIELD } from "./transport.js";
|
|
7
|
+
export type { TransportSendResult, TransportCommand, TransportSendFn } from "./transport.js";
|
|
8
|
+
|
|
9
|
+
export { defineAction, IDEMPOTENCY_HEADER } from "./define.js";
|
|
10
|
+
export { apiAction, configureApi } from "./api.js";
|
|
11
|
+
export type {
|
|
12
|
+
ApiConfig,
|
|
13
|
+
ApiActionDefinition,
|
|
14
|
+
ApiDecodeContext,
|
|
15
|
+
ApiErrorInfo,
|
|
16
|
+
ApiErrorDecision,
|
|
17
|
+
} from "./api.js";
|
|
18
|
+
|
|
19
|
+
export { ActionError, hasErrorString, classifyFetchError, retryNetwork } from "./error.js";
|
|
20
|
+
|
|
21
|
+
export {
|
|
22
|
+
subscribe as subscribeToActions,
|
|
23
|
+
subscribeByName,
|
|
24
|
+
getActionLog,
|
|
25
|
+
pendingCount,
|
|
26
|
+
isPending,
|
|
27
|
+
} from "./registry.js";
|
|
28
|
+
|
|
29
|
+
export { bindLoadingState } from "./loading.js";
|
|
30
|
+
|
|
31
|
+
export { withAsyncFeedback } from "./async-feedback.js";
|
|
32
|
+
export type { AsyncFeedbackOptions } from "./async-feedback.js";
|
|
33
|
+
|
|
34
|
+
export { registerCleanup } from "./cleanup.js";
|
|
35
|
+
|
|
36
|
+
export { debouncedDispatch } from "./debounce.js";
|
|
37
|
+
export type { DebouncedDispatch } from "./debounce.js";
|
|
38
|
+
|
|
39
|
+
export { pollAction } from "./poll.js";
|
|
40
|
+
export type { PollOptions } from "./poll.js";
|
|
41
|
+
|
|
42
|
+
export { pollUntil } from "./poll-until.js";
|
|
43
|
+
export type { PollUntilOptions, PollUntilOutcome } from "./poll-until.js";
|
|
44
|
+
|
|
45
|
+
export type {
|
|
46
|
+
Action,
|
|
47
|
+
ActionContext,
|
|
48
|
+
ActionDefinition,
|
|
49
|
+
ActionErrorLike,
|
|
50
|
+
ActionInstance,
|
|
51
|
+
ActionLifecycleStatus,
|
|
52
|
+
ActionOutcome,
|
|
53
|
+
DispatchHandle,
|
|
54
|
+
DispatchOptions,
|
|
55
|
+
NotificationSpec,
|
|
56
|
+
RegistryListener,
|
|
57
|
+
RequestSpec,
|
|
58
|
+
RetryConfig,
|
|
59
|
+
} from "./types.js";
|
|
60
|
+
export { RETRY_STANDARD } from "./types.js";
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
import { createRoot, effect } from "../signal/signal.js";
|
|
2
|
+
|
|
3
|
+
import { isPending, pendingCount } from "./registry.js";
|
|
4
|
+
|
|
5
|
+
/** Element types that have a `.disabled` writable boolean. */
|
|
6
|
+
type DisableableElement =
|
|
7
|
+
HTMLButtonElement | HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement;
|
|
8
|
+
|
|
9
|
+
interface BindLoadingOptions {
|
|
10
|
+
ariaBusy?: boolean;
|
|
11
|
+
preserveAriaBusy?: boolean;
|
|
12
|
+
pendingClass?: string;
|
|
13
|
+
preserveDisabled?: boolean;
|
|
14
|
+
disabledFn?: () => boolean;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** Bind a button/input element's disabled / aria-busy state to one or more named actions.
|
|
18
|
+
* The binding ends, restoring the element, when the returned function is called
|
|
19
|
+
* or when the `@cplieger/ui/signal` owner it was created under is disposed. */
|
|
20
|
+
export function bindLoadingState(
|
|
21
|
+
actionName: string | readonly string[],
|
|
22
|
+
el: DisableableElement,
|
|
23
|
+
opts: BindLoadingOptions = {},
|
|
24
|
+
): () => void {
|
|
25
|
+
const names: readonly string[] = typeof actionName === "string" ? [actionName] : actionName;
|
|
26
|
+
|
|
27
|
+
const {
|
|
28
|
+
ariaBusy = true,
|
|
29
|
+
preserveAriaBusy = false,
|
|
30
|
+
pendingClass,
|
|
31
|
+
preserveDisabled = false,
|
|
32
|
+
disabledFn,
|
|
33
|
+
} = opts;
|
|
34
|
+
const manageAriaBusy = ariaBusy && !preserveAriaBusy;
|
|
35
|
+
let wasPending = false;
|
|
36
|
+
let baseDisabled = el.disabled;
|
|
37
|
+
let hadFocus = false;
|
|
38
|
+
let wasConnected = el.isConnected;
|
|
39
|
+
|
|
40
|
+
const resolveBase = (): boolean => {
|
|
41
|
+
if (disabledFn !== undefined) {
|
|
42
|
+
try {
|
|
43
|
+
return disabledFn();
|
|
44
|
+
} catch {
|
|
45
|
+
return false;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
return preserveDisabled ? baseDisabled : false;
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
const setIdle = (): void => {
|
|
52
|
+
el.disabled = resolveBase();
|
|
53
|
+
if (manageAriaBusy) {
|
|
54
|
+
el.removeAttribute("aria-busy");
|
|
55
|
+
}
|
|
56
|
+
if (pendingClass) {
|
|
57
|
+
el.classList.remove(pendingClass);
|
|
58
|
+
}
|
|
59
|
+
if (hadFocus && el.isConnected && !el.disabled) {
|
|
60
|
+
const active = document.activeElement;
|
|
61
|
+
if (active === null || active === document.body) {
|
|
62
|
+
el.focus();
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
hadFocus = false;
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
// An empty name list sums nothing, so this stays false for the binding's
|
|
69
|
+
// whole life and the effect below never touches the element.
|
|
70
|
+
const readPending = (): boolean =>
|
|
71
|
+
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- the length-1 branch proves index 0
|
|
72
|
+
names.length === 1 ? isPending(names[0]!) : pendingCount(names) > 0;
|
|
73
|
+
|
|
74
|
+
const run = (dispose: () => void): void => {
|
|
75
|
+
if (wasConnected && !el.isConnected) {
|
|
76
|
+
dispose();
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
if (el.isConnected) {
|
|
80
|
+
wasConnected = true;
|
|
81
|
+
}
|
|
82
|
+
const pending = readPending();
|
|
83
|
+
if (pending && !wasPending) {
|
|
84
|
+
baseDisabled = el.disabled;
|
|
85
|
+
hadFocus = document.activeElement === el;
|
|
86
|
+
}
|
|
87
|
+
if (pending) {
|
|
88
|
+
el.disabled = true;
|
|
89
|
+
if (manageAriaBusy) {
|
|
90
|
+
el.setAttribute("aria-busy", "true");
|
|
91
|
+
}
|
|
92
|
+
if (pendingClass) {
|
|
93
|
+
el.classList.add(pendingClass);
|
|
94
|
+
}
|
|
95
|
+
} else if (wasPending) {
|
|
96
|
+
setIdle();
|
|
97
|
+
}
|
|
98
|
+
wasPending = pending;
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
const restore = (): void => {
|
|
102
|
+
if (wasPending) {
|
|
103
|
+
setIdle();
|
|
104
|
+
wasPending = false;
|
|
105
|
+
}
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
let restoreOnDispose = true;
|
|
109
|
+
return createRoot((dispose, owner) => {
|
|
110
|
+
// Runs after the effect is released, so a signal write from restore's
|
|
111
|
+
// focus() handler cannot re-run it. Self-release and the returned function
|
|
112
|
+
// clear the flag: the first leaves the element as it is, the second
|
|
113
|
+
// restores in the caller's context instead of this closing owner's.
|
|
114
|
+
owner.onDispose(() => {
|
|
115
|
+
if (restoreOnDispose) {
|
|
116
|
+
restore();
|
|
117
|
+
}
|
|
118
|
+
});
|
|
119
|
+
effect(() => {
|
|
120
|
+
run(() => {
|
|
121
|
+
restoreOnDispose = false;
|
|
122
|
+
dispose();
|
|
123
|
+
});
|
|
124
|
+
});
|
|
125
|
+
return () => {
|
|
126
|
+
restoreOnDispose = false;
|
|
127
|
+
dispose();
|
|
128
|
+
restore();
|
|
129
|
+
};
|
|
130
|
+
});
|
|
131
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/** Retry button descriptor passed to error notifications. */
|
|
2
|
+
export interface NotifierRetry {
|
|
3
|
+
readonly onClick: () => void;
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
/** Consumer-provided notification adapter, wired via `configure()`. Both methods
|
|
7
|
+
* optional; unset ones drop silently.
|
|
8
|
+
*
|
|
9
|
+
* SECURITY: `message` may carry server-controlled text (e.g. an HTTP error
|
|
10
|
+
* body's `error` field). Render as TEXT, never innerHTML — reflected XSS risk. */
|
|
11
|
+
export interface Notifier {
|
|
12
|
+
success?(message: string): void;
|
|
13
|
+
error?(message: string, retry?: NotifierRetry): void;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
let _notifier: Notifier = {};
|
|
17
|
+
let _configured = false;
|
|
18
|
+
let _warnedUnconfigured = false;
|
|
19
|
+
|
|
20
|
+
/** Configure the global notifier adapter. `configure({})` is the explicit
|
|
21
|
+
* headless opt-in — silent, no unconfigured-drop warning. */
|
|
22
|
+
export function configure(notifier: Notifier): void {
|
|
23
|
+
_notifier = notifier;
|
|
24
|
+
_configured = true;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Warn once on the first notification dropped because configure() was never called. */
|
|
28
|
+
function warnUnconfiguredDrop(kind: string): void {
|
|
29
|
+
if (_configured || _warnedUnconfigured) {
|
|
30
|
+
return;
|
|
31
|
+
}
|
|
32
|
+
_warnedUnconfigured = true;
|
|
33
|
+
console.warn(
|
|
34
|
+
`[actions] ${kind} notification dropped — configure() was never called. Wire a notifier at boot, or call configure({}) for intentional headless silence. This warning fires once.`,
|
|
35
|
+
);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** @internal Emit a success notification. */
|
|
39
|
+
export function notifySuccess(message: string): void {
|
|
40
|
+
if (_notifier.success === undefined) {
|
|
41
|
+
warnUnconfiguredDrop("success");
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
44
|
+
_notifier.success(message);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** @internal Emit an error notification. */
|
|
48
|
+
export function notifyError(message: string, retry?: NotifierRetry): void {
|
|
49
|
+
if (_notifier.error === undefined) {
|
|
50
|
+
warnUnconfiguredDrop("error");
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
_notifier.error(message, retry);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** @internal Test-only: reset the notifier to the default no-op. */
|
|
57
|
+
export function _resetNotifierForTest(): void {
|
|
58
|
+
_notifier = {};
|
|
59
|
+
_configured = false;
|
|
60
|
+
_warnedUnconfigured = false;
|
|
61
|
+
}
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
// Unlike pollAction, no pauseWhenHidden/refreshOnFocus: this is a bounded
|
|
2
|
+
// flow the caller drives to completion, not a background refresh.
|
|
3
|
+
|
|
4
|
+
/** Options for {@link pollUntil}. The loop waits before it polls, so
|
|
5
|
+
* `intervalMs` delays the first `step` call as well as the later ones.
|
|
6
|
+
* `maxAttempts` and `timeoutMs` are independent budgets, both checked after
|
|
7
|
+
* that wait and before `step` runs, so either one expiring yields `timeout`
|
|
8
|
+
* without a final poll. */
|
|
9
|
+
export interface PollUntilOptions<T> {
|
|
10
|
+
readonly intervalMs: number;
|
|
11
|
+
/** Returns true on a result that ends the poll. */
|
|
12
|
+
readonly until: (result: T) => boolean;
|
|
13
|
+
/** 0/undefined = unlimited. */
|
|
14
|
+
readonly maxAttempts?: number;
|
|
15
|
+
readonly timeoutMs?: number;
|
|
16
|
+
/** Applied to the wait after consecutive transient failures (null result or throw). */
|
|
17
|
+
readonly backoff?: { readonly factor: number; readonly maxMs: number };
|
|
18
|
+
/** Fires for a non-terminal successful poll. */
|
|
19
|
+
readonly onPoll?: (result: T) => void;
|
|
20
|
+
/** Fires on a transient failure (step returned null or threw). */
|
|
21
|
+
readonly onTransientError?: () => void;
|
|
22
|
+
/** A pre-aborted signal resolves `aborted` without calling `step`. */
|
|
23
|
+
readonly signal?: AbortSignal;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Terminal result of {@link pollUntil}, which resolves in every case and never
|
|
27
|
+
* rejects. `done` carries the first result `until` accepted; `timeout` means a
|
|
28
|
+
* budget expired, drawing no distinction between the attempt and the time
|
|
29
|
+
* budget; `aborted` means the signal fired, which wins over a transient
|
|
30
|
+
* failure observed in the same iteration. A `step` that throws is transient,
|
|
31
|
+
* never terminal, so no member here reports it. */
|
|
32
|
+
export type PollUntilOutcome<T> =
|
|
33
|
+
| { readonly status: "done"; readonly result: T }
|
|
34
|
+
| { readonly status: "timeout" }
|
|
35
|
+
| { readonly status: "aborted" };
|
|
36
|
+
|
|
37
|
+
/** Resolves early (never rejects) if `signal` fires; caller re-checks `signal.aborted`. */
|
|
38
|
+
function sleepWithSignal(ms: number, signal: AbortSignal): Promise<void> {
|
|
39
|
+
if (signal.aborted) {
|
|
40
|
+
return Promise.resolve();
|
|
41
|
+
}
|
|
42
|
+
return new Promise<void>((resolve) => {
|
|
43
|
+
const ac = new AbortController();
|
|
44
|
+
const t = setTimeout(() => {
|
|
45
|
+
ac.abort();
|
|
46
|
+
resolve();
|
|
47
|
+
}, ms);
|
|
48
|
+
signal.addEventListener(
|
|
49
|
+
"abort",
|
|
50
|
+
() => {
|
|
51
|
+
clearTimeout(t);
|
|
52
|
+
ac.abort();
|
|
53
|
+
resolve();
|
|
54
|
+
},
|
|
55
|
+
{ signal: ac.signal },
|
|
56
|
+
);
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** A null result or a throw is a transient failure (backs off if `backoff` is set). */
|
|
61
|
+
export async function pollUntil<T>(
|
|
62
|
+
step: (signal: AbortSignal) => Promise<T | null>,
|
|
63
|
+
opts: PollUntilOptions<T>,
|
|
64
|
+
): Promise<PollUntilOutcome<T>> {
|
|
65
|
+
const { intervalMs, until, maxAttempts, timeoutMs, backoff, onPoll, onTransientError } = opts;
|
|
66
|
+
const signal = opts.signal ?? new AbortController().signal;
|
|
67
|
+
|
|
68
|
+
if (signal.aborted) {
|
|
69
|
+
return { status: "aborted" };
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const start = Date.now();
|
|
73
|
+
let attempts = 0;
|
|
74
|
+
let failures = 0;
|
|
75
|
+
|
|
76
|
+
for (;;) {
|
|
77
|
+
const delay =
|
|
78
|
+
backoff !== undefined && failures > 0
|
|
79
|
+
? Math.min(intervalMs * Math.pow(backoff.factor, failures), backoff.maxMs)
|
|
80
|
+
: intervalMs;
|
|
81
|
+
await sleepWithSignal(delay, signal);
|
|
82
|
+
|
|
83
|
+
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- aborted flips during the awaited sleep
|
|
84
|
+
if (signal.aborted) {
|
|
85
|
+
return { status: "aborted" };
|
|
86
|
+
}
|
|
87
|
+
attempts += 1;
|
|
88
|
+
if (maxAttempts !== undefined && maxAttempts > 0 && attempts > maxAttempts) {
|
|
89
|
+
return { status: "timeout" };
|
|
90
|
+
}
|
|
91
|
+
if (timeoutMs !== undefined && Date.now() - start >= timeoutMs) {
|
|
92
|
+
return { status: "timeout" };
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
let result: T | null;
|
|
96
|
+
try {
|
|
97
|
+
result = await step(signal);
|
|
98
|
+
} catch {
|
|
99
|
+
result = null;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// Abort that fired during step() wins over treating the (likely null)
|
|
103
|
+
// result as a transient failure — no spurious onTransientError on teardown.
|
|
104
|
+
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- aborted flips during the awaited step
|
|
105
|
+
if (signal.aborted) {
|
|
106
|
+
return { status: "aborted" };
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
if (result === null) {
|
|
110
|
+
if (onTransientError !== undefined) {
|
|
111
|
+
try {
|
|
112
|
+
onTransientError();
|
|
113
|
+
} catch (e) {
|
|
114
|
+
console.error("[pollUntil] onTransientError threw", e);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
failures += 1;
|
|
118
|
+
continue;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
failures = 0;
|
|
122
|
+
if (until(result)) {
|
|
123
|
+
return { status: "done", result };
|
|
124
|
+
}
|
|
125
|
+
if (onPoll !== undefined) {
|
|
126
|
+
try {
|
|
127
|
+
onPoll(result);
|
|
128
|
+
} catch (e) {
|
|
129
|
+
console.error("[pollUntil] onPoll threw", e);
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
}
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
import { createRoot } from "../signal/signal.js";
|
|
2
|
+
|
|
3
|
+
import { registerCleanup } from "./cleanup.js";
|
|
4
|
+
import type { Action } from "./types.js";
|
|
5
|
+
|
|
6
|
+
/** Options for {@link pollAction}. `interval` is the gap between one poll
|
|
7
|
+
* settling and the next starting rather than a fixed cadence, so a slow action
|
|
8
|
+
* cannot queue polls behind itself. `backoffOnError` stretches that gap by
|
|
9
|
+
* `factor` per consecutive failure, capped at `max`; a failure means the
|
|
10
|
+
* dispatch resolved `null`, which is the only failure signal a poller gets
|
|
11
|
+
* because the action has already absorbed the error itself. `onSuccess` fires
|
|
12
|
+
* only for a non-null result, and a throw from it is logged rather than
|
|
13
|
+
* stopping the loop. */
|
|
14
|
+
export interface PollOptions<TResult = unknown> {
|
|
15
|
+
readonly interval: number;
|
|
16
|
+
/** Default true: pause while document.hidden. */
|
|
17
|
+
readonly pauseWhenHidden?: boolean;
|
|
18
|
+
/** Default true: fire an immediate poll on window focus. */
|
|
19
|
+
readonly refreshOnFocus?: boolean;
|
|
20
|
+
readonly backoffOnError?: { readonly factor: number; readonly max: number };
|
|
21
|
+
readonly onSuccess?: (result: TResult) => void;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Start polling an action in the background and return its stop function.
|
|
25
|
+
* Single-flight: a tick, focus-triggered ones included, that lands during a
|
|
26
|
+
* dispatch is dropped. The first poll runs at once unless the document is
|
|
27
|
+
* hidden, then waits until it is shown. `stop()` is idempotent and also runs
|
|
28
|
+
* on page unload. Without `document` or `window` the loop is a plain
|
|
29
|
+
* interval. */
|
|
30
|
+
export function pollAction<TArgs, TResult>(
|
|
31
|
+
action: Action<TArgs, TResult>,
|
|
32
|
+
args: TArgs,
|
|
33
|
+
opts: PollOptions<TResult>,
|
|
34
|
+
): () => void {
|
|
35
|
+
const pauseWhenHidden = opts.pauseWhenHidden !== false;
|
|
36
|
+
const refreshOnFocus = opts.refreshOnFocus !== false;
|
|
37
|
+
const baseInterval = opts.interval;
|
|
38
|
+
const backoff = opts.backoffOnError;
|
|
39
|
+
const onSuccess = opts.onSuccess;
|
|
40
|
+
|
|
41
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
42
|
+
let stopped = false;
|
|
43
|
+
let paused = false;
|
|
44
|
+
let inFlight = false;
|
|
45
|
+
let failures = 0;
|
|
46
|
+
|
|
47
|
+
// First, so an owner already being disposed throws before anything starts.
|
|
48
|
+
const releaseOwner = createRoot((dispose, owner) => {
|
|
49
|
+
owner.onDispose(stop);
|
|
50
|
+
return dispose;
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
function nextDelay(): number {
|
|
54
|
+
if (backoff === undefined || failures === 0) {
|
|
55
|
+
return baseInterval;
|
|
56
|
+
}
|
|
57
|
+
return Math.min(baseInterval * Math.pow(backoff.factor, failures), backoff.max);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function schedule(): void {
|
|
61
|
+
if (stopped || paused) {
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
if (timer !== undefined) {
|
|
65
|
+
clearTimeout(timer);
|
|
66
|
+
}
|
|
67
|
+
timer = setTimeout(() => {
|
|
68
|
+
void tick();
|
|
69
|
+
}, nextDelay());
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
async function tick(): Promise<void> {
|
|
73
|
+
if (stopped || paused) {
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
if (inFlight) {
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
inFlight = true;
|
|
80
|
+
try {
|
|
81
|
+
const result = await action.dispatch(args);
|
|
82
|
+
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- state changes during async
|
|
83
|
+
if (stopped) {
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
if (result === null) {
|
|
87
|
+
failures += 1;
|
|
88
|
+
} else {
|
|
89
|
+
failures = 0;
|
|
90
|
+
if (onSuccess !== undefined) {
|
|
91
|
+
try {
|
|
92
|
+
onSuccess(result);
|
|
93
|
+
} catch (e) {
|
|
94
|
+
console.error("[pollAction] onSuccess threw", e);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
} finally {
|
|
99
|
+
inFlight = false;
|
|
100
|
+
}
|
|
101
|
+
schedule();
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const onVisibility = (): void => {
|
|
105
|
+
if (document.hidden) {
|
|
106
|
+
paused = true;
|
|
107
|
+
if (timer !== undefined) {
|
|
108
|
+
clearTimeout(timer);
|
|
109
|
+
timer = undefined;
|
|
110
|
+
}
|
|
111
|
+
} else if (paused) {
|
|
112
|
+
paused = false;
|
|
113
|
+
void tick();
|
|
114
|
+
}
|
|
115
|
+
};
|
|
116
|
+
|
|
117
|
+
const onFocus = (): void => {
|
|
118
|
+
// No guard here: `tick` drops a poll that is stopped, paused or already in
|
|
119
|
+
// flight, so re-checking would make its own guard unreachable.
|
|
120
|
+
void tick();
|
|
121
|
+
};
|
|
122
|
+
|
|
123
|
+
function stop(): void {
|
|
124
|
+
if (stopped) {
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
127
|
+
stopped = true;
|
|
128
|
+
if (timer !== undefined) {
|
|
129
|
+
clearTimeout(timer);
|
|
130
|
+
timer = undefined;
|
|
131
|
+
}
|
|
132
|
+
listenerCtrl.abort();
|
|
133
|
+
unregisterCleanup();
|
|
134
|
+
releaseOwner();
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const listenerCtrl = new AbortController();
|
|
138
|
+
|
|
139
|
+
if (pauseWhenHidden && typeof document !== "undefined") {
|
|
140
|
+
document.addEventListener("visibilitychange", onVisibility, { signal: listenerCtrl.signal });
|
|
141
|
+
if (document.hidden) {
|
|
142
|
+
paused = true;
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
if (refreshOnFocus && typeof window !== "undefined") {
|
|
146
|
+
window.addEventListener("focus", onFocus, { signal: listenerCtrl.signal });
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
const unregisterCleanup = registerCleanup(stop);
|
|
150
|
+
|
|
151
|
+
if (!paused) {
|
|
152
|
+
void tick();
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
return stop;
|
|
156
|
+
}
|