@cplieger/actions 1.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.
Files changed (50) hide show
  1. package/.editorconfig +19 -0
  2. package/.htmlvalidate.json +28 -0
  3. package/.stylelintrc.json +67 -0
  4. package/LICENSE +692 -0
  5. package/README.md +176 -0
  6. package/dist/src/api.d.ts +56 -0
  7. package/dist/src/api.js +158 -0
  8. package/dist/src/cleanup.d.ts +11 -0
  9. package/dist/src/cleanup.js +63 -0
  10. package/dist/src/debounce.d.ts +28 -0
  11. package/dist/src/debounce.js +91 -0
  12. package/dist/src/define-helpers.d.ts +20 -0
  13. package/dist/src/define-helpers.js +83 -0
  14. package/dist/src/define.d.ts +14 -0
  15. package/dist/src/define.js +627 -0
  16. package/dist/src/error.d.ts +42 -0
  17. package/dist/src/error.js +174 -0
  18. package/dist/src/index.d.ts +17 -0
  19. package/dist/src/index.js +22 -0
  20. package/dist/src/loading.d.ts +15 -0
  21. package/dist/src/loading.js +114 -0
  22. package/dist/src/notifier.d.ts +21 -0
  23. package/dist/src/notifier.js +21 -0
  24. package/dist/src/poll.d.ts +23 -0
  25. package/dist/src/poll.js +120 -0
  26. package/dist/src/registry.d.ts +18 -0
  27. package/dist/src/registry.js +210 -0
  28. package/dist/src/retry.d.ts +9 -0
  29. package/dist/src/retry.js +78 -0
  30. package/dist/src/transport.d.ts +46 -0
  31. package/dist/src/transport.js +70 -0
  32. package/dist/src/types.d.ts +157 -0
  33. package/dist/src/types.js +7 -0
  34. package/eslint.config.base.mjs +186 -0
  35. package/jsr.json +22 -0
  36. package/package.json +41 -0
  37. package/src/api.ts +209 -0
  38. package/src/cleanup.ts +76 -0
  39. package/src/debounce.ts +128 -0
  40. package/src/define-helpers.ts +97 -0
  41. package/src/define.ts +699 -0
  42. package/src/error.ts +184 -0
  43. package/src/index.ts +60 -0
  44. package/src/loading.ts +149 -0
  45. package/src/notifier.ts +41 -0
  46. package/src/poll.ts +150 -0
  47. package/src/registry.ts +225 -0
  48. package/src/retry.ts +80 -0
  49. package/src/transport.ts +112 -0
  50. package/src/types.ts +198 -0
@@ -0,0 +1,186 @@
1
+ // Strict typed-linting config.
2
+ // References:
3
+ // https://typescript-eslint.io/users/configs/#strict-type-checked
4
+ // https://typescript-eslint.io/users/configs/#stylistic-type-checked
5
+ // https://typescript-eslint.io/getting-started/typed-linting
6
+
7
+ import js from "@eslint/js";
8
+ import tseslint from "typescript-eslint";
9
+
10
+ export default [
11
+ // 1. Ignore generated/build outputs and configs that don't need linting.
12
+ {
13
+ ignores: [
14
+ // Dependencies (any depth)
15
+ "**/node_modules/**",
16
+ // Build output / generated bundles (TS->JS, CSS bundles, etc.)
17
+ "**/static/**",
18
+ "**/dist/**",
19
+ "**/build/**",
20
+ "**/.next/**",
21
+ "**/.cache/**",
22
+ "**/coverage/**",
23
+ // Minified / generated source
24
+ "**/*.min.*",
25
+ "**/*.gen.ts",
26
+ "**/*.gen.js",
27
+ "**/wire/*.gen.ts",
28
+ // Test fixtures that aren't real code
29
+ "**/test-stubs/**",
30
+ "**/__mocks__/**",
31
+ ],
32
+ },
33
+ // 2. Strictest official preset combination (typed linting required).
34
+ js.configs.recommended,
35
+ ...tseslint.configs.strictTypeChecked,
36
+ ...tseslint.configs.stylisticTypeChecked,
37
+
38
+ // 3. Project setup — projectService auto-discovers tsconfig per file.
39
+ {
40
+ languageOptions: {
41
+ ecmaVersion: 2024,
42
+ sourceType: "module",
43
+ parserOptions: {
44
+ projectService: {
45
+ allowDefaultProject: ["*.test.ts", "*.property.test.ts", "fc-strict-setup.ts"],
46
+ maximumDefaultProjectFileMatchCount_THIS_WILL_SLOW_DOWN_LINTING: 20,
47
+ },
48
+ tsconfigRootDir: import.meta.dirname,
49
+ },
50
+ globals: {
51
+ // Browser
52
+ window: "readonly",
53
+ document: "readonly",
54
+ navigator: "readonly",
55
+ console: "readonly",
56
+ fetch: "readonly",
57
+ URL: "readonly",
58
+ URLSearchParams: "readonly",
59
+ setTimeout: "readonly",
60
+ clearTimeout: "readonly",
61
+ setInterval: "readonly",
62
+ clearInterval: "readonly",
63
+ requestAnimationFrame: "readonly",
64
+ cancelAnimationFrame: "readonly",
65
+ queueMicrotask: "readonly",
66
+ WebSocket: "readonly",
67
+ EventSource: "readonly",
68
+ AbortController: "readonly",
69
+ Headers: "readonly",
70
+ Request: "readonly",
71
+ Response: "readonly",
72
+ FormData: "readonly",
73
+ Blob: "readonly",
74
+ File: "readonly",
75
+ FileReader: "readonly",
76
+ IntersectionObserver: "readonly",
77
+ MutationObserver: "readonly",
78
+ ResizeObserver: "readonly",
79
+ CustomEvent: "readonly",
80
+ Event: "readonly",
81
+ EventTarget: "readonly",
82
+ TextEncoder: "readonly",
83
+ TextDecoder: "readonly",
84
+ crypto: "readonly",
85
+ btoa: "readonly",
86
+ atob: "readonly",
87
+ // Service Worker
88
+ self: "readonly",
89
+ ServiceWorkerGlobalScope: "readonly",
90
+ clients: "readonly",
91
+ // Test runner globals (vitest auto-injected)
92
+ describe: "readonly",
93
+ it: "readonly",
94
+ test: "readonly",
95
+ expect: "readonly",
96
+ beforeAll: "readonly",
97
+ beforeEach: "readonly",
98
+ afterAll: "readonly",
99
+ afterEach: "readonly",
100
+ vi: "readonly",
101
+ },
102
+ },
103
+ rules: {
104
+ // Allow `_`-prefixed unused names.
105
+ "@typescript-eslint/no-unused-vars": [
106
+ "error",
107
+ {
108
+ argsIgnorePattern: "^_",
109
+ varsIgnorePattern: "^_",
110
+ caughtErrorsIgnorePattern: "^_",
111
+ destructuredArrayIgnorePattern: "^_",
112
+ ignoreRestSiblings: true,
113
+ },
114
+ ],
115
+ // Enforce `import type {...}` for types.
116
+ "@typescript-eslint/consistent-type-imports": [
117
+ "error",
118
+ { prefer: "type-imports", fixStyle: "inline-type-imports" },
119
+ ],
120
+ // Discourage `any`, prefer `unknown`.
121
+ "@typescript-eslint/no-explicit-any": "error",
122
+ // Avoid silent fall-through bugs in async event handlers.
123
+ "@typescript-eslint/no-misused-promises": [
124
+ "error",
125
+ { checksVoidReturn: { attributes: false } },
126
+ ],
127
+ // Prefer literal numeric/string template parts (catches accidental coercion).
128
+ "@typescript-eslint/restrict-template-expressions": [
129
+ "error",
130
+ { allowNumber: true, allowBoolean: true, allowNullish: false },
131
+ ],
132
+ // Console policy.
133
+ "no-console": ["warn", { allow: ["warn", "error"] }],
134
+ // Equality: enforce strict ===.
135
+ eqeqeq: ["error", "always", { null: "ignore" }],
136
+ curly: ["error", "all"],
137
+ "no-var": "error",
138
+ "prefer-const": "error",
139
+ "no-throw-literal": "error",
140
+ },
141
+ },
142
+
143
+ // 4. Tests: typed but with relaxed rules (tests deliberately break invariants).
144
+ {
145
+ files: [
146
+ "**/*.test.ts",
147
+ "**/*.fuzz.test.ts",
148
+ "**/*.property.test.ts",
149
+ "**/test-helpers/**",
150
+ "**/__mocks__/**",
151
+ ],
152
+ rules: {
153
+ "@typescript-eslint/no-non-null-assertion": "off",
154
+ "@typescript-eslint/no-explicit-any": "off",
155
+ "@typescript-eslint/no-unsafe-assignment": "off",
156
+ "@typescript-eslint/no-unsafe-member-access": "off",
157
+ "@typescript-eslint/no-unsafe-call": "off",
158
+ "@typescript-eslint/no-unsafe-argument": "off",
159
+ "@typescript-eslint/no-unsafe-return": "off",
160
+ "@typescript-eslint/no-extraneous-class": "off",
161
+ "@typescript-eslint/no-unnecessary-condition": "off",
162
+ "@typescript-eslint/no-misused-spread": "off",
163
+ "@typescript-eslint/unbound-method": "off",
164
+ "@typescript-eslint/require-await": "off",
165
+ "no-console": "off",
166
+ },
167
+ },
168
+
169
+ // 5. Generated and config files + tests: drop type-checked rules.
170
+ {
171
+ files: [
172
+ "**/*.gen.ts",
173
+ "**/wire/*.ts",
174
+ "vitest.config.ts",
175
+ "*.config.ts",
176
+ "*.config.mjs",
177
+ "*.config.js",
178
+ "**/*.test.ts",
179
+ "**/*.fuzz.test.ts",
180
+ "**/*.property.test.ts",
181
+ "fc-strict-setup.ts",
182
+ "test-stubs/**",
183
+ ],
184
+ ...tseslint.configs.disableTypeChecked,
185
+ },
186
+ ];
package/jsr.json ADDED
@@ -0,0 +1,22 @@
1
+ {
2
+ "name": "@cplieger/actions",
3
+ "version": "0.1.0",
4
+ "exports": {
5
+ ".": "./src/index.ts"
6
+ },
7
+ "publish": {
8
+ "exclude": [
9
+ "**/*.test.ts",
10
+ "**/__test-helpers__",
11
+ "**/fc-strict-setup.ts",
12
+ "eslint.config.mjs",
13
+ "tsconfig.json",
14
+ "tsconfig.test.json",
15
+ "tsconfig.tests.json",
16
+ "vitest.config.ts",
17
+ "renovate.json",
18
+ "package-lock.json",
19
+ ".prettierrc.json"
20
+ ]
21
+ }
22
+ }
package/package.json ADDED
@@ -0,0 +1,41 @@
1
+ {
2
+ "name": "@cplieger/actions",
3
+ "version": "1.0.0",
4
+ "type": "module",
5
+ "sideEffects": false,
6
+ "exports": {
7
+ ".": {
8
+ "types": "./dist/src/index.d.ts",
9
+ "import": "./dist/src/index.js"
10
+ }
11
+ },
12
+ "scripts": {
13
+ "build": "tsc -p tsconfig.json",
14
+ "prepublishOnly": "npm run build",
15
+ "typecheck": "tsgo -project tsconfig.json",
16
+ "typecheck:tests": "tsgo -project tsconfig.test.json",
17
+ "test": "vitest --run"
18
+ },
19
+ "devDependencies": {
20
+ "@eslint/js": "10.0.1",
21
+ "@types/node": "24.12.4",
22
+ "@vitest/coverage-v8": "4.1.7",
23
+ "eslint": "10.4.1",
24
+ "fast-check": "4.8.0",
25
+ "happy-dom": "20.9.0",
26
+ "prettier": "3.8.3",
27
+ "typescript": "6.0.3",
28
+ "typescript-eslint": "8.60.0",
29
+ "vitest": "4.1.7"
30
+ },
31
+ "description": "Declarative async UI-action framework for TypeScript",
32
+ "license": "GPL-3.0-or-later",
33
+ "types": "./src/index.ts",
34
+ "publishConfig": {
35
+ "access": "public"
36
+ },
37
+ "repository": {
38
+ "type": "git",
39
+ "url": "git+https://github.com/cplieger/actions.git"
40
+ }
41
+ }
package/src/api.ts ADDED
@@ -0,0 +1,209 @@
1
+ // apiAction: factory for HTTP-backed actions. Wraps fetch so the run()
2
+ // implementation is just the request descriptor.
3
+ // ---------------------------------------------------------------------------
4
+
5
+ import { defineAction, IDEMPOTENCY_HEADER } from "./define.js";
6
+ import { ActionError, classifyFetchError, hasErrorString } from "./error.js";
7
+ import type { Action, ActionContext, ActionDefinition, RequestSpec } from "./types.js";
8
+
9
+ /** Default request timeout in milliseconds. */
10
+ export const API_TIMEOUT_MS = 30_000;
11
+
12
+ /**
13
+ * Compose an optional caller signal with a fresh timeout signal.
14
+ * If the caller provides an existing signal, the result aborts when
15
+ * either the caller signal or the timeout fires — whichever comes first.
16
+ *
17
+ * @param signal - Existing signal to compose with (may be undefined).
18
+ * @param ms - Timeout in milliseconds.
19
+ * @returns A composed AbortSignal.
20
+ */
21
+ export function withTimeout(signal: AbortSignal | undefined, ms: number): AbortSignal {
22
+ return signal !== undefined
23
+ ? AbortSignal.any([signal, AbortSignal.timeout(ms)])
24
+ : AbortSignal.timeout(ms);
25
+ }
26
+
27
+ const JSON_CT = "application/json";
28
+
29
+ // ---------------------------------------------------------------------------
30
+ // HTTP customization seam (mirrors RTK fetchBaseQuery pattern)
31
+ // ---------------------------------------------------------------------------
32
+
33
+ /** Configuration for the global API fetch layer. Set via `configureApi()`. */
34
+ export interface ApiConfig {
35
+ /** Base URL prepended to every RequestSpec.path (e.g. "https://api.example.com/v1"). */
36
+ readonly baseUrl?: string;
37
+ /** Inject headers on every request. Receives current headers + the request spec.
38
+ * Mutate and/or return the headers object. May be async (e.g. to read a token store). */
39
+ readonly prepareHeaders?: (
40
+ headers: Headers,
41
+ context: { spec: RequestSpec },
42
+ ) => Headers | undefined | Promise<Headers | undefined>;
43
+ /** RequestInit.credentials mode applied to every request (e.g. "include" for cookies). */
44
+ readonly credentials?: RequestCredentials;
45
+ /** Custom fetch implementation. Useful for SSR (isomorphic-fetch) or testing. */
46
+ readonly fetchFn?: typeof fetch;
47
+ }
48
+
49
+ let _apiConfig: ApiConfig = {};
50
+
51
+ /**
52
+ * Configure the global HTTP layer used by all `apiAction` instances.
53
+ * Call once at app boot. Subsequent calls replace the previous config.
54
+ *
55
+ * @example
56
+ * ```ts
57
+ * configureApi({
58
+ * baseUrl: "https://api.example.com",
59
+ * credentials: "include",
60
+ * prepareHeaders: (headers) => {
61
+ * headers.set("Authorization", `Bearer ${getToken()}`);
62
+ * },
63
+ * });
64
+ * ```
65
+ */
66
+ export function configureApi(config: ApiConfig): void {
67
+ _apiConfig = config;
68
+ }
69
+
70
+ /** Reset API config. @internal Test-only. */
71
+ export function _resetApiConfigForTest(): void {
72
+ _apiConfig = {};
73
+ }
74
+
75
+ // ---------------------------------------------------------------------------
76
+
77
+ /** Caller-facing shape of an apiAction definition. Replaces `run` with
78
+ * a `request` function that returns an HTTP {@link RequestSpec}. */
79
+ export interface ApiActionDefinition<TArgs, TResult, TOp = unknown> extends Omit<
80
+ ActionDefinition<TArgs, TResult, TOp>,
81
+ "run"
82
+ > {
83
+ request: (args: TArgs) => RequestSpec;
84
+ }
85
+
86
+ /**
87
+ * Build an Action from an HTTP request descriptor.
88
+ * Wraps `defineAction` with a generated `run()` that calls `fetch`
89
+ * via the global {@link ApiConfig} layer configured with {@link configureApi}.
90
+ */
91
+ export function apiAction<TArgs, TResult = unknown, TOp = unknown>(
92
+ def: ApiActionDefinition<TArgs, TResult, TOp>,
93
+ ): Action<TArgs, TResult> {
94
+ const { request, ...rest } = def;
95
+ return defineAction<TArgs, TResult, TOp>({
96
+ ...rest,
97
+ run: async (args, signal, ctx) => {
98
+ const spec = request(args);
99
+ return executeRequest<TResult>(spec, signal, ctx);
100
+ },
101
+ });
102
+ }
103
+
104
+ async function executeRequest<T>(
105
+ spec: RequestSpec,
106
+ signal: AbortSignal,
107
+ ctx?: ActionContext,
108
+ ): Promise<T> {
109
+ const cfg = _apiConfig;
110
+ const init: RequestInit = { method: spec.method };
111
+
112
+ // Build headers via Headers API for prepareHeaders compatibility
113
+ const headers = new Headers();
114
+ if (spec.method !== "GET" && spec.body !== undefined) {
115
+ headers.set("Content-Type", JSON_CT);
116
+ init.body = JSON.stringify(spec.body);
117
+ }
118
+ if (ctx?.idempotencyKey !== undefined) {
119
+ headers.set(IDEMPOTENCY_HEADER, ctx.idempotencyKey);
120
+ }
121
+ // Per-request headers from RequestSpec
122
+ if (spec.headers !== undefined) {
123
+ for (const [k, v] of Object.entries(spec.headers)) {
124
+ headers.set(k, v);
125
+ }
126
+ }
127
+ // Global prepareHeaders hook
128
+ if (cfg.prepareHeaders !== undefined) {
129
+ await cfg.prepareHeaders(headers, { spec });
130
+ }
131
+ // Convert Headers to plain object for RequestInit
132
+ const headerObj: Record<string, string> = {};
133
+ headers.forEach((v, k) => {
134
+ headerObj[k.toLowerCase()] = v;
135
+ });
136
+ if (Object.keys(headerObj).length > 0) {
137
+ init.headers = headerObj;
138
+ }
139
+
140
+ // Credentials
141
+ if (cfg.credentials !== undefined) {
142
+ init.credentials = cfg.credentials;
143
+ }
144
+
145
+ init.signal = withTimeout(signal, API_TIMEOUT_MS);
146
+
147
+ // Resolve URL: prepend baseUrl if configured, normalizing double slashes at the join
148
+ let url: string;
149
+ if (cfg.baseUrl !== undefined) {
150
+ const base = cfg.baseUrl.endsWith("/") ? cfg.baseUrl.slice(0, -1) : cfg.baseUrl;
151
+ const path = spec.path.startsWith("/") ? spec.path : `/${spec.path}`;
152
+ url = `${base}${path}`;
153
+ } else {
154
+ url = spec.path;
155
+ }
156
+
157
+ // Use custom fetchFn or global fetch
158
+ const fetchImpl = cfg.fetchFn ?? fetch;
159
+
160
+ let r: Response;
161
+ try {
162
+ r = await fetchImpl(url, init);
163
+ } catch (e) {
164
+ throw classifyFetchError(e, signal);
165
+ }
166
+ if (!r.ok) {
167
+ let serverError = "";
168
+ let serverCode: string | undefined;
169
+ try {
170
+ const body: unknown = await r.json();
171
+ if (hasErrorString(body)) {
172
+ serverError = body.error;
173
+ }
174
+ if (typeof body === "object" && body !== null && "code" in body) {
175
+ const code = (body as Record<"code", unknown>).code;
176
+ if (typeof code === "string") {
177
+ serverCode = code;
178
+ }
179
+ }
180
+ } catch {
181
+ // Body wasn't JSON — leave serverError empty.
182
+ }
183
+ const opts: { status: number; code?: string } = { status: r.status };
184
+ if (serverCode !== undefined) {
185
+ opts.code = serverCode;
186
+ }
187
+ throw new ActionError(serverError !== "" ? serverError : `HTTP ${String(r.status)}`, opts);
188
+ }
189
+ if (r.status === 204) {
190
+ return undefined as T;
191
+ }
192
+ const text = await r.text();
193
+ if (text === "") {
194
+ if (spec.method !== "DELETE") {
195
+ console.warn(
196
+ `[actions] ${spec.method} ${spec.path} returned empty body — callers expecting data will receive undefined`,
197
+ );
198
+ }
199
+ return undefined as T;
200
+ }
201
+ try {
202
+ return JSON.parse(text) as T;
203
+ } catch (e) {
204
+ throw new ActionError(`response not JSON: ${e instanceof Error ? e.message : String(e)}`, {
205
+ status: r.status,
206
+ cause: e,
207
+ });
208
+ }
209
+ }
package/src/cleanup.ts ADDED
@@ -0,0 +1,76 @@
1
+ // Global cleanup: cancel all in-flight actions + run registered
2
+ // cleanup hooks. Wired to window.beforeunload so navigation away
3
+ // from the page aborts everything cleanly.
4
+ //
5
+ // Cleanup hooks must be idempotent. Cancellation is allowed to fire
6
+ // multiple times (e.g., cancelled navigation followed by confirmed).
7
+ // ---------------------------------------------------------------------------
8
+
9
+ import type { Action } from "./types.js";
10
+
11
+ /** Minimal shape needed for cleanup — avoids variance-unsafe casts. */
12
+ interface Cancellable {
13
+ readonly name: string;
14
+ cancel(): void;
15
+ }
16
+
17
+ const trackedActions = new Set<Cancellable>();
18
+ const cleanupHooks = new Set<() => void>();
19
+ let beforeunloadInstalled = false;
20
+
21
+ /** Internal: register an Action so cancelAllPending() can iterate it. */
22
+ export function _registerAction<TArgs, TResult>(action: Action<TArgs, TResult>): void {
23
+ trackedActions.add(action);
24
+ installBeforeunloadOnce();
25
+ }
26
+
27
+ /**
28
+ * Register a cleanup function to run on page unload (or test invoke).
29
+ */
30
+ export function registerCleanup(fn: () => void): () => void {
31
+ cleanupHooks.add(fn);
32
+ installBeforeunloadOnce();
33
+ return () => cleanupHooks.delete(fn);
34
+ }
35
+
36
+ function cancelAllPending(): void {
37
+ for (const action of trackedActions) {
38
+ try {
39
+ action.cancel();
40
+ } catch (e) {
41
+ console.error(`[actions] cancel for ${action.name} threw`, e);
42
+ }
43
+ }
44
+ for (const fn of [...cleanupHooks]) {
45
+ try {
46
+ fn();
47
+ } catch (e) {
48
+ console.error("[actions] cleanup hook threw", e);
49
+ }
50
+ }
51
+ }
52
+
53
+ function installBeforeunloadOnce(): void {
54
+ if (beforeunloadInstalled) {
55
+ return;
56
+ }
57
+ beforeunloadInstalled = true;
58
+ if (typeof window !== "undefined") {
59
+ window.addEventListener("beforeunload", cancelAllPending);
60
+ }
61
+ }
62
+
63
+ /** Test-only: invoke the same cleanup logic that beforeunload runs. */
64
+ export function _cancelAllForTest(): void {
65
+ cancelAllPending();
66
+ }
67
+
68
+ /** Test-only: clear both registries + uninstall the listener. */
69
+ export function _resetForTest(): void {
70
+ trackedActions.clear();
71
+ cleanupHooks.clear();
72
+ if (beforeunloadInstalled && typeof window !== "undefined") {
73
+ window.removeEventListener("beforeunload", cancelAllPending);
74
+ }
75
+ beforeunloadInstalled = false;
76
+ }
@@ -0,0 +1,128 @@
1
+ // debouncedDispatch: wrap an Action so that rapid calls coalesce into
2
+ // a single dispatch after a quiet window. Replaces ad-hoc setTimeout
3
+ // + clearTimeout chains with a single helper that adds flush/cancel.
4
+ // ---------------------------------------------------------------------------
5
+
6
+ import type { Action } from "./types.js";
7
+
8
+ /** A debounced action dispatcher. Callable to schedule a dispatch,
9
+ * with `flush`, `cancel`, and `isPending` control methods. */
10
+ export interface DebouncedDispatch<TArgs> {
11
+ /** Schedule a dispatch with the given args. Replaces any pending
12
+ * dispatch's args. */
13
+ (args: TArgs): void;
14
+
15
+ /** Fire immediately with the most-recent args (or args supplied
16
+ * here, overriding the pending). No-op if nothing is pending and
17
+ * no args supplied. */
18
+ flush(args?: TArgs): Promise<unknown> | undefined;
19
+
20
+ /** Discard any pending dispatch without firing. */
21
+ cancel(): void;
22
+
23
+ /** True if there's a scheduled dispatch waiting for the timer. */
24
+ isPending(): boolean;
25
+ }
26
+
27
+ interface DebounceOptions {
28
+ /** Quiet window in ms. */
29
+ readonly wait: number;
30
+ /** Fire on the leading edge instead of the trailing edge. Default false. */
31
+ readonly leading?: boolean;
32
+ }
33
+
34
+ /**
35
+ * Wrap an action with a debounce timer so rapid calls coalesce into a
36
+ * single dispatch after a quiet window.
37
+ */
38
+ export function debouncedDispatch<TArgs, TResult>(
39
+ action: Action<TArgs, TResult>,
40
+ opts: DebounceOptions,
41
+ ): DebouncedDispatch<TArgs> {
42
+ let timer: ReturnType<typeof setTimeout> | undefined;
43
+ let lastArgs: TArgs | undefined;
44
+ let pending = false;
45
+ let lastFiredAt = 0;
46
+
47
+ const fn = ((args: TArgs): void => {
48
+ if (opts.leading === true) {
49
+ const now = Date.now();
50
+ if (now - lastFiredAt < opts.wait) {
51
+ lastArgs = args;
52
+ pending = true;
53
+ if (timer === undefined) {
54
+ const remaining = Math.max(0, opts.wait - (now - lastFiredAt));
55
+ timer = setTimeout(fireTrailing, remaining);
56
+ }
57
+ return;
58
+ }
59
+ void action.dispatch(args);
60
+ lastFiredAt = now;
61
+ lastArgs = undefined;
62
+ pending = true;
63
+ if (timer !== undefined) {
64
+ clearTimeout(timer);
65
+ }
66
+ timer = setTimeout(fireTrailing, opts.wait);
67
+ return;
68
+ }
69
+ lastArgs = args;
70
+ pending = true;
71
+ if (timer !== undefined) {
72
+ clearTimeout(timer);
73
+ }
74
+ timer = setTimeout(() => {
75
+ timer = undefined;
76
+ pending = false;
77
+ const a = lastArgs;
78
+ lastArgs = undefined;
79
+ if (a !== undefined) {
80
+ void action.dispatch(a);
81
+ }
82
+ }, opts.wait);
83
+ }) as DebouncedDispatch<TArgs>;
84
+
85
+ function fireTrailing(): void {
86
+ timer = undefined;
87
+ const a = lastArgs;
88
+ lastArgs = undefined;
89
+ if (a !== undefined) {
90
+ lastFiredAt = Date.now();
91
+ pending = true;
92
+ timer = setTimeout(fireTrailing, opts.wait);
93
+ void action.dispatch(a);
94
+ } else {
95
+ pending = false;
96
+ }
97
+ }
98
+
99
+ fn.flush = (args?: TArgs): Promise<unknown> | undefined => {
100
+ if (timer !== undefined) {
101
+ clearTimeout(timer);
102
+ timer = undefined;
103
+ }
104
+ const a = args ?? lastArgs;
105
+ lastArgs = undefined;
106
+ pending = false;
107
+ if (a !== undefined) {
108
+ if (opts.leading === true) {
109
+ lastFiredAt = Date.now();
110
+ }
111
+ return action.dispatch(a);
112
+ }
113
+ return undefined;
114
+ };
115
+
116
+ fn.cancel = (): void => {
117
+ if (timer !== undefined) {
118
+ clearTimeout(timer);
119
+ timer = undefined;
120
+ }
121
+ lastArgs = undefined;
122
+ pending = false;
123
+ };
124
+
125
+ fn.isPending = (): boolean => pending;
126
+
127
+ return fn;
128
+ }