atomirx 0.0.7 → 0.1.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 (138) hide show
  1. package/README.md +198 -2234
  2. package/bin/cli.js +90 -0
  3. package/dist/core/derived.d.ts +2 -2
  4. package/dist/core/effect.d.ts +3 -2
  5. package/dist/core/onCreateHook.d.ts +15 -2
  6. package/dist/core/onErrorHook.d.ts +4 -1
  7. package/dist/core/pool.d.ts +78 -0
  8. package/dist/core/pool.test.d.ts +1 -0
  9. package/dist/core/select-boolean.test.d.ts +1 -0
  10. package/dist/core/select-pool.test.d.ts +1 -0
  11. package/dist/core/select.d.ts +278 -86
  12. package/dist/core/types.d.ts +233 -1
  13. package/dist/core/withAbort.d.ts +95 -0
  14. package/dist/core/withReady.d.ts +3 -3
  15. package/dist/devtools/constants.d.ts +41 -0
  16. package/dist/devtools/index.cjs +1 -0
  17. package/dist/devtools/index.d.ts +29 -0
  18. package/dist/devtools/index.js +429 -0
  19. package/dist/devtools/registry.d.ts +98 -0
  20. package/dist/devtools/registry.test.d.ts +1 -0
  21. package/dist/devtools/setup.d.ts +61 -0
  22. package/dist/devtools/types.d.ts +311 -0
  23. package/dist/index-BZEnfIcB.cjs +1 -0
  24. package/dist/index-BbPZhsDl.js +1653 -0
  25. package/dist/index.cjs +1 -1
  26. package/dist/index.d.ts +4 -3
  27. package/dist/index.js +18 -14
  28. package/dist/onDispatchHook-C8yLzr-o.cjs +1 -0
  29. package/dist/onDispatchHook-SKbiIUaJ.js +5 -0
  30. package/dist/onErrorHook-BGGy3tqK.js +38 -0
  31. package/dist/onErrorHook-DHBASmYw.cjs +1 -0
  32. package/dist/react/index.cjs +1 -30
  33. package/dist/react/index.js +206 -791
  34. package/dist/react/onDispatchHook.d.ts +106 -0
  35. package/dist/react/useAction.d.ts +4 -1
  36. package/dist/react-devtools/DevToolsPanel.d.ts +93 -0
  37. package/dist/react-devtools/EntityDetails.d.ts +10 -0
  38. package/dist/react-devtools/EntityList.d.ts +15 -0
  39. package/dist/react-devtools/LogList.d.ts +12 -0
  40. package/dist/react-devtools/hooks.d.ts +50 -0
  41. package/dist/react-devtools/index.cjs +1 -0
  42. package/dist/react-devtools/index.d.ts +31 -0
  43. package/dist/react-devtools/index.js +1589 -0
  44. package/dist/react-devtools/styles.d.ts +148 -0
  45. package/package.json +26 -2
  46. package/skills/atomirx/SKILL.md +456 -0
  47. package/skills/atomirx/references/async-patterns.md +188 -0
  48. package/skills/atomirx/references/atom-patterns.md +238 -0
  49. package/skills/atomirx/references/deferred-loading.md +191 -0
  50. package/skills/atomirx/references/derived-patterns.md +428 -0
  51. package/skills/atomirx/references/effect-patterns.md +426 -0
  52. package/skills/atomirx/references/error-handling.md +140 -0
  53. package/skills/atomirx/references/hooks.md +322 -0
  54. package/skills/atomirx/references/pool-patterns.md +229 -0
  55. package/skills/atomirx/references/react-integration.md +411 -0
  56. package/skills/atomirx/references/rules.md +407 -0
  57. package/skills/atomirx/references/select-context.md +309 -0
  58. package/skills/atomirx/references/service-template.md +172 -0
  59. package/skills/atomirx/references/store-template.md +205 -0
  60. package/skills/atomirx/references/testing-patterns.md +431 -0
  61. package/coverage/base.css +0 -224
  62. package/coverage/block-navigation.js +0 -87
  63. package/coverage/clover.xml +0 -1440
  64. package/coverage/coverage-final.json +0 -14
  65. package/coverage/favicon.png +0 -0
  66. package/coverage/index.html +0 -131
  67. package/coverage/prettify.css +0 -1
  68. package/coverage/prettify.js +0 -2
  69. package/coverage/sort-arrow-sprite.png +0 -0
  70. package/coverage/sorter.js +0 -210
  71. package/coverage/src/core/atom.ts.html +0 -889
  72. package/coverage/src/core/batch.ts.html +0 -223
  73. package/coverage/src/core/define.ts.html +0 -805
  74. package/coverage/src/core/emitter.ts.html +0 -919
  75. package/coverage/src/core/equality.ts.html +0 -631
  76. package/coverage/src/core/hook.ts.html +0 -460
  77. package/coverage/src/core/index.html +0 -281
  78. package/coverage/src/core/isAtom.ts.html +0 -100
  79. package/coverage/src/core/isPromiseLike.ts.html +0 -133
  80. package/coverage/src/core/onCreateHook.ts.html +0 -138
  81. package/coverage/src/core/scheduleNotifyHook.ts.html +0 -94
  82. package/coverage/src/core/types.ts.html +0 -523
  83. package/coverage/src/core/withUse.ts.html +0 -253
  84. package/coverage/src/index.html +0 -116
  85. package/coverage/src/index.ts.html +0 -106
  86. package/dist/index-CBVj1kSj.js +0 -1350
  87. package/dist/index-Cxk9v0um.cjs +0 -1
  88. package/scripts/publish.js +0 -198
  89. package/src/core/atom.test.ts +0 -633
  90. package/src/core/atom.ts +0 -311
  91. package/src/core/atomState.test.ts +0 -342
  92. package/src/core/atomState.ts +0 -256
  93. package/src/core/batch.test.ts +0 -257
  94. package/src/core/batch.ts +0 -172
  95. package/src/core/define.test.ts +0 -343
  96. package/src/core/define.ts +0 -243
  97. package/src/core/derived.test.ts +0 -1215
  98. package/src/core/derived.ts +0 -450
  99. package/src/core/effect.test.ts +0 -802
  100. package/src/core/effect.ts +0 -188
  101. package/src/core/emitter.test.ts +0 -364
  102. package/src/core/emitter.ts +0 -392
  103. package/src/core/equality.test.ts +0 -392
  104. package/src/core/equality.ts +0 -182
  105. package/src/core/getAtomState.ts +0 -69
  106. package/src/core/hook.test.ts +0 -227
  107. package/src/core/hook.ts +0 -177
  108. package/src/core/isAtom.ts +0 -27
  109. package/src/core/isPromiseLike.test.ts +0 -72
  110. package/src/core/isPromiseLike.ts +0 -16
  111. package/src/core/onCreateHook.ts +0 -107
  112. package/src/core/onErrorHook.test.ts +0 -350
  113. package/src/core/onErrorHook.ts +0 -52
  114. package/src/core/promiseCache.test.ts +0 -241
  115. package/src/core/promiseCache.ts +0 -284
  116. package/src/core/scheduleNotifyHook.ts +0 -53
  117. package/src/core/select.ts +0 -729
  118. package/src/core/selector.test.ts +0 -799
  119. package/src/core/types.ts +0 -389
  120. package/src/core/withReady.test.ts +0 -534
  121. package/src/core/withReady.ts +0 -191
  122. package/src/core/withUse.test.ts +0 -249
  123. package/src/core/withUse.ts +0 -56
  124. package/src/index.test.ts +0 -80
  125. package/src/index.ts +0 -65
  126. package/src/react/index.ts +0 -21
  127. package/src/react/rx.test.tsx +0 -571
  128. package/src/react/rx.tsx +0 -531
  129. package/src/react/strictModeTest.tsx +0 -71
  130. package/src/react/useAction.test.ts +0 -987
  131. package/src/react/useAction.ts +0 -607
  132. package/src/react/useSelector.test.ts +0 -182
  133. package/src/react/useSelector.ts +0 -292
  134. package/src/react/useStable.test.ts +0 -553
  135. package/src/react/useStable.ts +0 -288
  136. package/tsconfig.json +0 -9
  137. package/v2.md +0 -725
  138. package/vite.config.ts +0 -39
@@ -1,607 +0,0 @@
1
- import { useReducer, useCallback, useRef, useEffect } from "react";
2
- import { isPromiseLike } from "../core/isPromiseLike";
3
- import { useSelector } from "./useSelector";
4
- import { isAtom } from "../core/isAtom";
5
- import { Pipeable } from "../core/types";
6
- import { withUse } from "../core/withUse";
7
-
8
- /**
9
- * State for an action that hasn't been dispatched yet.
10
- */
11
- export type ActionIdleState = {
12
- readonly status: "idle";
13
- readonly result: undefined;
14
- readonly error: undefined;
15
- };
16
-
17
- /**
18
- * State for an action that is currently executing.
19
- */
20
- export type ActionLoadingState = {
21
- readonly status: "loading";
22
- readonly result: undefined;
23
- readonly error: undefined;
24
- };
25
-
26
- /**
27
- * State for an action that completed successfully.
28
- */
29
- export type ActionSuccessState<T> = {
30
- readonly status: "success";
31
- readonly result: T;
32
- readonly error: undefined;
33
- };
34
-
35
- /**
36
- * State for an action that failed with an error.
37
- */
38
- export type ActionErrorState = {
39
- readonly status: "error";
40
- readonly result: undefined;
41
- readonly error: unknown;
42
- };
43
-
44
- /**
45
- * Union of all possible action states.
46
- */
47
- export type ActionState<T> =
48
- | ActionIdleState
49
- | ActionLoadingState
50
- | ActionSuccessState<T>
51
- | ActionErrorState;
52
-
53
- /**
54
- * Action state without idle (used when lazy is false).
55
- */
56
- export type ActionStateWithoutIdle<T> = Exclude<
57
- ActionState<T>,
58
- ActionIdleState
59
- >;
60
-
61
- /**
62
- * A promise with an abort method for manual cancellation.
63
- */
64
- export type AbortablePromise<T> = PromiseLike<T> & { abort: () => void };
65
-
66
- /**
67
- * Options for useAction hook.
68
- */
69
- export interface UseActionOptions {
70
- /**
71
- * If true, only one request runs at a time - previous requests are aborted.
72
- * Also aborts on unmount and reset().
73
- * - `exclusive: true` (default) - Aborts previous request on re-call, unmount, deps change, reset()
74
- * - `exclusive: false` - Allows concurrent requests, manual abort only via abort() or promise.abort()
75
- * @default true
76
- */
77
- exclusive?: boolean;
78
- /**
79
- * Dependencies array. When lazy is false, re-executes when deps change.
80
- * - Regular values: compared by reference (like useEffect deps)
81
- * - Atoms: automatically tracked via useSelector, re-executes when atom values change
82
- * @default []
83
- */
84
- deps?: unknown[];
85
- }
86
-
87
- /**
88
- * Context passed to the action function.
89
- */
90
- export interface ActionContext extends Pipeable {
91
- /** AbortSignal for cancellation. New signal per dispatch. */
92
- signal: AbortSignal;
93
- }
94
-
95
- /**
96
- * API methods for controlling the action.
97
- */
98
- export type ActionApi = {
99
- /** Abort the current in-flight request. */
100
- abort: () => void;
101
- /** Reset state back to idle. Respects exclusive setting. */
102
- reset: () => void;
103
- };
104
-
105
- /**
106
- * Dispatch function type - callable and returns AbortablePromise.
107
- */
108
- export type ActionDispatch<T> = () => AbortablePromise<
109
- T extends PromiseLike<infer U> ? U : T
110
- >;
111
-
112
- /**
113
- * Return type for useAction - a callable dispatch function with state and API attached.
114
- *
115
- * @example
116
- * ```ts
117
- * const fetchPosts = useAction(async () => api.getPosts());
118
- *
119
- * // Call it like a function
120
- * const posts = await fetchPosts();
121
- *
122
- * // Access state via properties
123
- * fetchPosts.loading // boolean
124
- * fetchPosts.status // "idle" | "loading" | "success" | "error"
125
- * fetchPosts.result // Post[] | undefined
126
- * fetchPosts.error // unknown
127
- * fetchPosts.abort() // cancel current request
128
- * fetchPosts.reset() // reset state to idle
129
- * ```
130
- */
131
- export type Action<
132
- TResult,
133
- TLazy extends boolean = true,
134
- > = ActionDispatch<TResult> &
135
- (TLazy extends true
136
- ? ActionState<Awaited<TResult>>
137
- : ActionStateWithoutIdle<Awaited<TResult>>) &
138
- ActionApi;
139
-
140
- // Reducer action types
141
- type ReducerAction<T> =
142
- | { type: "START" }
143
- | { type: "SUCCESS"; result: T }
144
- | { type: "ERROR"; error: unknown }
145
- | { type: "RESET" };
146
-
147
- const IDLE_STATE: ActionIdleState = {
148
- status: "idle",
149
- result: undefined,
150
- error: undefined,
151
- };
152
-
153
- const LOADING_STATE: ActionLoadingState = {
154
- status: "loading",
155
- result: undefined,
156
- error: undefined,
157
- };
158
-
159
- function reducer<T>(
160
- state: ActionState<T>,
161
- action: ReducerAction<T>
162
- ): ActionState<T> {
163
- switch (action.type) {
164
- case "START":
165
- return LOADING_STATE;
166
- case "SUCCESS":
167
- return {
168
- status: "success",
169
- result: action.result,
170
- error: undefined,
171
- };
172
- case "ERROR":
173
- return {
174
- status: "error",
175
- result: undefined,
176
- error: action.error,
177
- };
178
- case "RESET":
179
- return IDLE_STATE;
180
- default:
181
- return state;
182
- }
183
- }
184
-
185
- /**
186
- * React hook for handling async actions with loading/error states and abort support.
187
- *
188
- * `useAction` provides a complete solution for managing async operations in React,
189
- * with automatic state tracking, cancellation support, and race condition handling.
190
- *
191
- * Returns a callable function with state properties attached, making it easy to
192
- * manage multiple actions in a single component:
193
- *
194
- * ```tsx
195
- * const fetchUser = useAction(() => api.getUser(id));
196
- * const updateUser = useAction(() => api.updateUser(id, data));
197
- * const deleteUser = useAction(() => api.deleteUser(id));
198
- *
199
- * // Call directly - no need to destructure
200
- * await fetchUser();
201
- *
202
- * // Access state via properties
203
- * fetchUser.loading // boolean
204
- * fetchUser.result // User | undefined
205
- * fetchUser.error // unknown
206
- * fetchUser.status // "idle" | "loading" | "success" | "error"
207
- * fetchUser.abort() // cancel request
208
- * fetchUser.reset() // reset to idle state
209
- * ```
210
- *
211
- * ## Key Features
212
- *
213
- * 1. **Automatic state management**: Tracks idle → loading → success/error transitions
214
- * 2. **AbortSignal support**: Built-in cancellation via AbortController
215
- * 3. **Exclusive mode**: Only one request at a time - previous aborted automatically (configurable)
216
- * 4. **Lazy/eager execution**: Wait for manual call or execute on mount (configurable)
217
- * 5. **Stale closure prevention**: Ignores outdated results from cancelled requests
218
- * 6. **Atom deps support**: Atoms in deps array are reactively tracked
219
- *
220
- * ## State Machine
221
- *
222
- * ```
223
- * ┌──────┐ dispatch() ┌─────────┐ success ┌─────────┐
224
- * │ idle │ ───────────► │ loading │ ────────► │ success │
225
- * └──────┘ └─────────┘ └─────────┘
226
- * │
227
- * │ error
228
- * ▼
229
- * ┌─────────┐
230
- * │ error │
231
- * └─────────┘
232
- * ```
233
- *
234
- * ## Exclusive Mode (exclusive option)
235
- *
236
- * The `exclusive` option controls whether only one request can run at a time:
237
- *
238
- * | Trigger | exclusive: true (default) | exclusive: false |
239
- * |---------|---------------------------|------------------|
240
- * | Call action again | ✅ Aborts previous | ❌ No abort |
241
- * | Component unmounts | ✅ Aborts current | ❌ No abort |
242
- * | Deps change (lazy: false) | ✅ Aborts previous | ❌ No abort |
243
- * | `reset()` called | ✅ Aborts current | ❌ No abort |
244
- * | `abort()` called | ✅ Always aborts | ✅ Always aborts |
245
- * | `promise.abort()` called | ✅ Always aborts | ✅ Always aborts |
246
- *
247
- * ## Reset Behavior
248
- *
249
- * `reset()` clears the state back to idle and respects `exclusive`:
250
- * - **exclusive: true**: Aborts any in-flight request, then resets to idle
251
- * - **exclusive: false**: Only resets state (request continues in background)
252
- *
253
- * ## Race Condition Handling
254
- *
255
- * When a new dispatch starts before the previous completes:
256
- * - Previous request's result is ignored (even if it resolves)
257
- * - Only the latest request's result updates state
258
- * - This prevents stale data from overwriting fresh data
259
- *
260
- * @template TResult - The return type of the action function
261
- * @param fn - Action function receiving `{ signal: AbortSignal }`. Can be sync or async.
262
- * @param options - Configuration options
263
- * @param options.lazy - If true (default), waits for manual call. If false, executes on mount.
264
- * @param options.exclusive - If true (default), aborts previous request on re-call/unmount.
265
- * @param options.deps - Dependencies for lazy: false mode. Atoms are reactively tracked.
266
- * @returns A callable dispatch function with state and API properties attached
267
- *
268
- * @example Basic usage - manual dispatch
269
- * ```tsx
270
- * function UserProfile({ userId }) {
271
- * const fetchUser = useAction(async ({ signal }) => {
272
- * const response = await fetch(`/api/users/${userId}`, { signal });
273
- * if (!response.ok) throw new Error('Failed to fetch');
274
- * return response.json();
275
- * });
276
- *
277
- * return (
278
- * <div>
279
- * {fetchUser.status === 'idle' && <button onClick={fetchUser}>Load User</button>}
280
- * {fetchUser.status === 'loading' && <Spinner />}
281
- * {fetchUser.status === 'success' && <div>{fetchUser.result.name}</div>}
282
- * {fetchUser.status === 'error' && <div>Error: {fetchUser.error.message}</div>}
283
- * </div>
284
- * );
285
- * }
286
- * ```
287
- *
288
- * @example Eager execution on mount and deps change
289
- * ```tsx
290
- * function UserProfile({ userId }) {
291
- * const fetchUser = useAction(
292
- * async ({ signal }) => {
293
- * const response = await fetch(`/api/users/${userId}`, { signal });
294
- * return response.json();
295
- * },
296
- * { lazy: false, deps: [userId] }
297
- * );
298
- * // Fetches automatically on mount
299
- * // Re-fetches when userId changes
300
- * // Previous request is aborted when userId changes
301
- * }
302
- * ```
303
- *
304
- * @example Eager execution with atom deps
305
- * ```tsx
306
- * const userIdAtom = atom(1);
307
- *
308
- * function UserProfile() {
309
- * const fetchUser = useAction(
310
- * async ({ signal }) => fetchUserApi(userIdAtom.get(), { signal }),
311
- * { lazy: false, deps: [userIdAtom] }
312
- * );
313
- * // Automatically re-fetches when userIdAtom changes
314
- * // Atoms in deps are tracked reactively via useSelector
315
- * }
316
- * ```
317
- *
318
- * @example Allow concurrent requests (non-exclusive)
319
- * ```tsx
320
- * function SearchResults() {
321
- * const search = useAction(
322
- * async ({ signal }) => searchAPI(query, { signal }),
323
- * { exclusive: false }
324
- * );
325
- *
326
- * return (
327
- * <div>
328
- * <button onClick={search}>Search</button>
329
- * <button onClick={search.abort} disabled={search.status !== 'loading'}>
330
- * Cancel
331
- * </button>
332
- * </div>
333
- * );
334
- * }
335
- * ```
336
- *
337
- * @example Abort via returned promise
338
- * ```tsx
339
- * const longTask = useAction(async ({ signal }) => longRunningTask({ signal }));
340
- *
341
- * const handleClick = () => {
342
- * const promise = longTask();
343
- *
344
- * // Abort after 5 seconds
345
- * setTimeout(() => promise.abort(), 5000);
346
- *
347
- * // Or await the result
348
- * try {
349
- * const result = await promise;
350
- * } catch (error) {
351
- * if (error.name === 'AbortError') {
352
- * console.log('Request was cancelled');
353
- * }
354
- * }
355
- * };
356
- * ```
357
- *
358
- * @example Chaining multiple actions
359
- * ```tsx
360
- * function CreateUserForm() {
361
- * const createUser = useAction(({ signal }) => api.createUser(data, { signal }));
362
- * const sendWelcomeEmail = useAction(({ signal }) => api.sendEmail(email, { signal }));
363
- *
364
- * const handleSubmit = async () => {
365
- * try {
366
- * const user = await createUser();
367
- * await sendWelcomeEmail();
368
- * toast.success('User created and email sent!');
369
- * } catch (error) {
370
- * toast.error('Operation failed');
371
- * }
372
- * };
373
- *
374
- * const isLoading = createUser.status === 'loading' || sendWelcomeEmail.status === 'loading';
375
- *
376
- * return <button onClick={handleSubmit} disabled={isLoading}>Create User</button>;
377
- * }
378
- * ```
379
- *
380
- * @example Sync action (non-async function)
381
- * ```tsx
382
- * const compute = useAction(({ signal }) => {
383
- * // Sync computation - still works!
384
- * return computeExpensiveValue(data);
385
- * });
386
- * // compute() returns a promise that resolves immediately
387
- * ```
388
- *
389
- * @example Form submission with validation
390
- * ```tsx
391
- * function ContactForm() {
392
- * const [formData, setFormData] = useState({ name: '', email: '' });
393
- *
394
- * const submit = useAction(async ({ signal }) => {
395
- * // Validate
396
- * if (!formData.name) throw new Error('Name required');
397
- *
398
- * // Submit
399
- * const response = await fetch('/api/contact', {
400
- * method: 'POST',
401
- * body: JSON.stringify(formData),
402
- * signal,
403
- * });
404
- *
405
- * if (!response.ok) throw new Error('Submission failed');
406
- * return response.json();
407
- * });
408
- *
409
- * return (
410
- * <form onSubmit={(e) => { e.preventDefault(); submit(); }}>
411
- * <input value={formData.name} onChange={...} />
412
- * <input value={formData.email} onChange={...} />
413
- * <button disabled={submit.status === 'loading'}>
414
- * {submit.status === 'loading' ? 'Submitting...' : 'Submit'}
415
- * </button>
416
- * {submit.status === 'error' && <p className="error">{submit.error.message}</p>}
417
- * {submit.status === 'success' && <p className="success">Submitted!</p>}
418
- * </form>
419
- * );
420
- * }
421
- * ```
422
- *
423
- * @example Reset after success or error
424
- * ```tsx
425
- * function SubmitForm() {
426
- * const submit = useAction(async () => api.submit(data));
427
- *
428
- * if (submit.status === 'success') {
429
- * return (
430
- * <div>
431
- * <p>Success!</p>
432
- * <button onClick={submit.reset}>Submit Another</button>
433
- * </div>
434
- * );
435
- * }
436
- *
437
- * if (submit.status === 'error') {
438
- * return (
439
- * <div>
440
- * <p>Error: {submit.error.message}</p>
441
- * <button onClick={submit.reset}>Dismiss</button>
442
- * <button onClick={submit}>Retry</button>
443
- * </div>
444
- * );
445
- * }
446
- *
447
- * return <button onClick={submit} disabled={submit.status === 'loading'}>Submit</button>;
448
- * }
449
- * ```
450
- */
451
- export function useAction<TResult, TLazy extends boolean = true>(
452
- fn: (context: ActionContext) => TResult,
453
- options: UseActionOptions & {
454
- /**
455
- * If true, waits for manual call to execute. If false, executes on mount and when deps change.
456
- * - `lazy: true` (default) - Action starts in "idle" state, waits for you to call it
457
- * - `lazy: false` - Action executes immediately on mount and re-executes when deps change
458
- * @default true
459
- */
460
- lazy?: TLazy;
461
- } = {}
462
- ): Action<TResult, TLazy> {
463
- const { lazy = true, exclusive = true, deps = [] } = options;
464
-
465
- // Use loading as initial state when lazy is false (eager execution)
466
- const initialState = lazy ? IDLE_STATE : LOADING_STATE;
467
- const [state, dispatchAction] = useReducer(
468
- reducer<Awaited<TResult>>,
469
- initialState
470
- );
471
-
472
- // Track current abort controller for auto-abort and stale result detection
473
- const currentAbortControllerRef = useRef<AbortController | null>(null);
474
- // Store fn ref to avoid stale closures in callbacks
475
- const fnRef = useRef(fn);
476
- fnRef.current = fn;
477
-
478
- // Abort current request - returns true if there was something to abort
479
- const abortCurrent = useCallback(() => {
480
- const controller = currentAbortControllerRef.current;
481
- if (controller) {
482
- controller.abort();
483
- currentAbortControllerRef.current = null;
484
- return true;
485
- }
486
- return false;
487
- }, []);
488
-
489
- // Get atoms from deps for reactive tracking
490
- const atomDeps = (lazy ? [] : (deps ?? [])).filter(isAtom);
491
-
492
- // Use useSelector to track atom deps and get their values for effect deps comparison
493
- const atomValues = useSelector(({ read }) => {
494
- return atomDeps.map((atom) => read(atom));
495
- });
496
-
497
- const dispatch = useCallback((): AbortablePromise<Awaited<TResult>> => {
498
- // Abort previous if exclusive mode
499
- if (exclusive) {
500
- abortCurrent();
501
- }
502
-
503
- // Create new abort controller for this dispatch
504
- const abortController = new AbortController();
505
- currentAbortControllerRef.current = abortController;
506
-
507
- dispatchAction({ type: "START" });
508
-
509
- let result: TResult;
510
- try {
511
- result = fnRef.current(withUse({ signal: abortController.signal }));
512
- } catch (error) {
513
- // Sync error - update state and return rejected promise
514
- dispatchAction({ type: "ERROR", error });
515
- return Object.assign(Promise.reject(error), {
516
- abort: () => abortController.abort(),
517
- });
518
- }
519
-
520
- // Handle async result
521
- if (isPromiseLike(result)) {
522
- const promise = result as PromiseLike<Awaited<TResult>>;
523
-
524
- promise.then(
525
- (value) => {
526
- // Ignore stale results (a new dispatch has started)
527
- if (currentAbortControllerRef.current !== abortController) return;
528
- dispatchAction({ type: "SUCCESS", result: value });
529
- },
530
- (error) => {
531
- // Check if this was an abort error
532
- const isAbortError =
533
- error instanceof DOMException && error.name === "AbortError";
534
-
535
- // If aborted, always dispatch the error to exit loading state
536
- if (isAbortError) {
537
- // Only dispatch if this abort controller was the current one
538
- // (i.e., it was manually aborted, not replaced by a new dispatch)
539
- if (
540
- currentAbortControllerRef.current === null ||
541
- currentAbortControllerRef.current === abortController
542
- ) {
543
- dispatchAction({ type: "ERROR", error });
544
- }
545
- return;
546
- }
547
-
548
- // For non-abort errors, ignore stale results
549
- if (currentAbortControllerRef.current !== abortController) return;
550
- dispatchAction({ type: "ERROR", error });
551
- }
552
- );
553
-
554
- // Return abortable promise
555
- return Object.assign(promise, {
556
- abort: () => {
557
- abortController.abort();
558
- // Clear ref so we know it was manually aborted
559
- if (currentAbortControllerRef.current === abortController) {
560
- currentAbortControllerRef.current = null;
561
- }
562
- },
563
- }) as AbortablePromise<Awaited<TResult>>;
564
- }
565
-
566
- // Sync success - wrap in resolved promise
567
- dispatchAction({ type: "SUCCESS", result: result as Awaited<TResult> });
568
- return Object.assign(Promise.resolve(result as Awaited<TResult>), {
569
- abort: () => abortController.abort(),
570
- });
571
- }, [exclusive, abortCurrent]);
572
-
573
- // Get non-atom deps for effect comparison
574
- const nonAtomDeps = (deps ?? []).filter((dep) => !isAtom(dep));
575
-
576
- // Eager execution effect (when lazy is false)
577
- useEffect(() => {
578
- if (!lazy) {
579
- dispatch();
580
- }
581
- // eslint-disable-next-line react-hooks/exhaustive-deps
582
- }, [lazy, ...atomValues, ...nonAtomDeps]);
583
-
584
- // Cleanup on unmount
585
- useEffect(() => {
586
- return () => {
587
- if (exclusive) {
588
- abortCurrent();
589
- }
590
- };
591
- }, [exclusive, abortCurrent]);
592
-
593
- // Reset state to idle, respects exclusive setting
594
- const reset = useCallback(() => {
595
- if (exclusive) {
596
- abortCurrent();
597
- }
598
- dispatchAction({ type: "RESET" });
599
- }, [exclusive, abortCurrent]);
600
-
601
- // Combine dispatch function with state and API
602
- return Object.assign(dispatch, {
603
- ...state,
604
- abort: abortCurrent,
605
- reset,
606
- }) as Action<TResult, TLazy>;
607
- }