@boxcustodia/library 2.0.0-alpha.19 → 2.0.0-alpha.20

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 (265) hide show
  1. package/dist/components/button/button.cjs.js +1 -1
  2. package/dist/components/button/button.es.js +19 -18
  3. package/dist/components/button/components/base-button.cjs.js +1 -1
  4. package/dist/components/button/components/base-button.es.js +20 -20
  5. package/dist/components/calendar/calendar.cjs.js +1 -1
  6. package/dist/components/calendar/calendar.es.js +1 -0
  7. package/dist/components/date-picker/date-input.cjs.js +1 -1
  8. package/dist/components/date-picker/date-input.es.js +92 -75
  9. package/dist/components/date-picker/date-picker.cjs.js +1 -1
  10. package/dist/components/date-picker/date-picker.es.js +104 -95
  11. package/dist/components/date-picker/date-picker.utils.cjs.js +1 -1
  12. package/dist/components/date-picker/date-picker.utils.es.js +51 -43
  13. package/dist/components/date-picker/use-hidden-field-value.cjs.js +1 -0
  14. package/dist/components/date-picker/use-hidden-field-value.es.js +11 -0
  15. package/dist/components/menu/menu.es.js +1 -9
  16. package/dist/components/otp/otp.cjs.js +2 -0
  17. package/dist/components/otp/otp.es.js +93 -0
  18. package/dist/components/password/password.cjs.js +1 -1
  19. package/dist/components/password/password.es.js +2 -2
  20. package/dist/components/select/select.cjs.js +1 -1
  21. package/dist/components/select/select.es.js +68 -60
  22. package/dist/hooks/internal/is-apple-device.cjs.js +1 -0
  23. package/dist/hooks/internal/is-apple-device.es.js +9 -0
  24. package/dist/hooks/internal/use-latest-ref.cjs.js +1 -0
  25. package/dist/hooks/internal/use-latest-ref.es.js +11 -0
  26. package/dist/hooks/use-array/use-array.cjs.js +1 -1
  27. package/dist/hooks/use-array/use-array.es.js +54 -42
  28. package/dist/hooks/use-async/use-async.cjs.js +1 -1
  29. package/dist/hooks/use-async/use-async.es.js +53 -20
  30. package/dist/hooks/use-boolean/use-boolean.cjs.js +1 -0
  31. package/dist/hooks/use-boolean/use-boolean.es.js +25 -0
  32. package/dist/hooks/use-click-outside/use-click-outside.cjs.js +1 -1
  33. package/dist/hooks/use-click-outside/use-click-outside.es.js +26 -12
  34. package/dist/hooks/use-debounce-callback/use-debounced-callback.cjs.js +1 -1
  35. package/dist/hooks/use-debounce-callback/use-debounced-callback.es.js +27 -10
  36. package/dist/hooks/use-debounce-value/use-debounced-value.cjs.js +1 -1
  37. package/dist/hooks/use-debounce-value/use-debounced-value.es.js +7 -9
  38. package/dist/hooks/use-disclosure/use-disclosure.cjs.js +1 -1
  39. package/dist/hooks/use-disclosure/use-disclosure.es.js +21 -11
  40. package/dist/hooks/use-document-title/use-document-title.cjs.js +1 -1
  41. package/dist/hooks/use-document-title/use-document-title.es.js +14 -12
  42. package/dist/hooks/use-event-listener/use-event-listener.cjs.js +1 -1
  43. package/dist/hooks/use-event-listener/use-event-listener.es.js +17 -9
  44. package/dist/hooks/use-hotkey/use-hotkey.cjs.js +1 -1
  45. package/dist/hooks/use-hotkey/use-hotkey.es.js +30 -14
  46. package/dist/hooks/use-hotkey/utils/is-input-field.cjs.js +1 -1
  47. package/dist/hooks/use-hotkey/utils/is-input-field.es.js +4 -2
  48. package/dist/hooks/use-hotkey/utils/match-and-run.cjs.js +1 -0
  49. package/dist/hooks/use-hotkey/utils/match-and-run.es.js +12 -0
  50. package/dist/hooks/use-hotkey/utils/match-key-modifiers.cjs.js +1 -1
  51. package/dist/hooks/use-hotkey/utils/match-key-modifiers.es.js +13 -12
  52. package/dist/hooks/use-hover/use-hover.cjs.js +1 -1
  53. package/dist/hooks/use-hover/use-hover.es.js +32 -17
  54. package/dist/hooks/use-is-visible/use-is-visible.cjs.js +1 -1
  55. package/dist/hooks/use-is-visible/use-is-visible.es.js +31 -27
  56. package/dist/hooks/use-local-storage/use-local-storage.cjs.js +1 -1
  57. package/dist/hooks/use-local-storage/use-local-storage.es.js +52 -20
  58. package/dist/hooks/use-media-query/use-media-query.cjs.js +1 -1
  59. package/dist/hooks/use-media-query/use-media-query.es.js +21 -11
  60. package/dist/hooks/use-mutation/use-mutation.cjs.js +1 -1
  61. package/dist/hooks/use-mutation/use-mutation.es.js +36 -22
  62. package/dist/hooks/use-object/use-object.cjs.js +1 -1
  63. package/dist/hooks/use-object/use-object.es.js +26 -22
  64. package/dist/hooks/use-prevent-page-close/use-prevent-page-close.cjs.js +1 -0
  65. package/dist/hooks/use-prevent-page-close/use-prevent-page-close.es.js +14 -0
  66. package/dist/hooks/use-step/use-step.cjs.js +1 -1
  67. package/dist/hooks/use-step/use-step.es.js +25 -24
  68. package/dist/index.cjs.js +1 -1
  69. package/dist/index.es.js +308 -300
  70. package/dist/src/components/date-picker/date-picker.utils.d.ts +17 -0
  71. package/dist/src/components/date-picker/use-hidden-field-value.d.ts +12 -0
  72. package/dist/src/components/index.d.ts +1 -0
  73. package/dist/src/hooks/index.d.ts +2 -2
  74. package/dist/src/hooks/internal/index.d.ts +2 -0
  75. package/dist/src/hooks/internal/is-apple-device.d.ts +12 -0
  76. package/dist/src/hooks/internal/use-latest-ref.d.ts +12 -0
  77. package/dist/src/hooks/use-array/use-array.d.ts +24 -11
  78. package/dist/src/hooks/use-async/use-async.d.ts +16 -13
  79. package/dist/src/hooks/use-boolean/index.d.ts +1 -0
  80. package/dist/src/hooks/use-boolean/use-boolean.d.ts +15 -0
  81. package/dist/src/hooks/use-boolean/use-boolean.test.d.ts +1 -0
  82. package/dist/src/hooks/use-click-outside/use-click-outside.d.ts +23 -1
  83. package/dist/src/hooks/use-debounce-callback/use-debounced-callback.d.ts +19 -1
  84. package/dist/src/hooks/use-debounce-value/use-debounced-value.d.ts +10 -1
  85. package/dist/src/hooks/use-disclosure/use-disclosure.d.ts +17 -8
  86. package/dist/src/hooks/use-document-title/use-document-title.d.ts +11 -0
  87. package/dist/src/hooks/use-event-listener/use-event-listener.d.ts +18 -1
  88. package/dist/src/hooks/use-hotkey/index.d.ts +2 -1
  89. package/dist/src/hooks/use-hotkey/use-hotkey.d.ts +62 -5
  90. package/dist/src/hooks/use-hotkey/utils/index.d.ts +4 -3
  91. package/dist/src/hooks/use-hotkey/utils/is-input-field.d.ts +12 -2
  92. package/dist/src/hooks/use-hotkey/utils/is-input-field.test.d.ts +1 -0
  93. package/dist/src/hooks/use-hotkey/utils/match-and-run.d.ts +36 -0
  94. package/dist/src/hooks/use-hotkey/utils/match-and-run.test.d.ts +1 -0
  95. package/dist/src/hooks/use-hotkey/utils/match-key-modifiers.d.ts +20 -6
  96. package/dist/src/hooks/use-hotkey/utils/match-key-modifiers.test.d.ts +1 -0
  97. package/dist/src/hooks/use-hover/use-hover.d.ts +8 -4
  98. package/dist/src/hooks/use-is-visible/use-is-visible.d.ts +28 -4
  99. package/dist/src/hooks/use-local-storage/use-local-storage.d.ts +13 -2
  100. package/dist/src/hooks/use-media-query/use-media-query.d.ts +10 -1
  101. package/dist/src/hooks/use-media-query/use-media-query.test.d.ts +1 -0
  102. package/dist/src/hooks/use-mutation/use-mutation.d.ts +18 -11
  103. package/dist/src/hooks/use-object/use-object.d.ts +15 -6
  104. package/dist/src/hooks/use-prevent-page-close/index.d.ts +1 -0
  105. package/dist/src/hooks/use-prevent-page-close/use-prevent-page-close.d.ts +10 -0
  106. package/dist/src/hooks/use-prevent-page-close/use-prevent-page-close.test.d.ts +1 -0
  107. package/dist/src/hooks/use-step/use-step.d.ts +18 -11
  108. package/dist/src/utils/form.d.ts +10 -0
  109. package/package.json +1 -1
  110. package/src/components/alert-dialog/alert-dialog.test.tsx +13 -9
  111. package/src/components/auto-complete/auto-complete.test.tsx +4 -14
  112. package/src/components/avatar/avatar.test.tsx +7 -12
  113. package/src/components/button/button.test.tsx +10 -15
  114. package/src/components/button/button.tsx +14 -9
  115. package/src/components/button/components/base-button.tsx +2 -4
  116. package/src/components/calendar/calendar.test.tsx +12 -19
  117. package/src/components/calendar/calendar.tsx +4 -0
  118. package/src/components/card/card.test.tsx +4 -6
  119. package/src/components/checkbox/checkbox.test.tsx +12 -8
  120. package/src/components/checkbox-group/checkbox-group.test.tsx +7 -8
  121. package/src/components/combobox/combobox.test.tsx +24 -21
  122. package/src/components/date-picker/date-input-form.test.tsx +77 -0
  123. package/src/components/date-picker/date-input.stories.tsx +30 -18
  124. package/src/components/date-picker/date-input.tsx +77 -44
  125. package/src/components/date-picker/date-picker.stories.tsx +31 -1
  126. package/src/components/date-picker/date-picker.test.tsx +3 -13
  127. package/src/components/date-picker/date-picker.tsx +35 -16
  128. package/src/components/date-picker/date-picker.utils.test.ts +32 -14
  129. package/src/components/date-picker/date-picker.utils.ts +33 -0
  130. package/src/components/date-picker/use-date-input-popover.test.ts +3 -1
  131. package/src/components/date-picker/use-hidden-field-value.ts +23 -0
  132. package/src/components/dialog/dialog.test.tsx +10 -8
  133. package/src/components/dropzone/dropzone.test.tsx +11 -13
  134. package/src/components/empty/empty.test.tsx +4 -3
  135. package/src/components/field/field.test.tsx +12 -13
  136. package/src/components/form/form.stories.tsx +16 -1
  137. package/src/components/index.ts +1 -0
  138. package/src/components/label/label.test.tsx +3 -3
  139. package/src/components/menu/menu.tsx +1 -5
  140. package/src/components/number-input/number-input.test.tsx +6 -2
  141. package/src/components/password/password.test.tsx +20 -6
  142. package/src/components/password/password.tsx +2 -2
  143. package/src/components/popover/popover.test.tsx +4 -4
  144. package/src/components/progress/progress.test.tsx +7 -8
  145. package/src/components/radio-group/radio-group.test.tsx +17 -11
  146. package/src/components/select/select.test.tsx +10 -10
  147. package/src/components/select/select.tsx +9 -1
  148. package/src/components/stepper/stepper.stories.tsx +11 -15
  149. package/src/components/stepper/stepper.test.tsx +6 -4
  150. package/src/components/switch/switch.test.tsx +3 -3
  151. package/src/components/table/table.test.tsx +9 -3
  152. package/src/components/tabs/tabs.test.tsx +6 -2
  153. package/src/components/tag/tag.test.tsx +1 -3
  154. package/src/components/textarea/textarea.test.tsx +4 -1
  155. package/src/components/timeline/timeline.test.tsx +10 -5
  156. package/src/components/toast/toast.test.tsx +11 -14
  157. package/src/components/tooltip/tooltip.test.tsx +1 -5
  158. package/src/components/tree/tree.test.tsx +3 -1
  159. package/src/hooks/index.ts +2 -2
  160. package/src/hooks/internal/index.ts +2 -0
  161. package/src/hooks/internal/is-apple-device.test.ts +41 -0
  162. package/src/hooks/internal/is-apple-device.ts +33 -0
  163. package/src/hooks/internal/use-isomorphic-layout-effect.ts +3 -1
  164. package/src/hooks/internal/use-latest-ref.ts +21 -0
  165. package/src/hooks/use-array/use-array.stories.tsx +435 -64
  166. package/src/hooks/use-array/use-array.test.tsx +398 -15
  167. package/src/hooks/use-array/use-array.ts +105 -66
  168. package/src/hooks/use-async/use-async.stories.tsx +255 -131
  169. package/src/hooks/use-async/use-async.test.ts +397 -0
  170. package/src/hooks/use-async/use-async.ts +117 -39
  171. package/src/hooks/use-boolean/index.ts +1 -0
  172. package/src/hooks/use-boolean/use-boolean.stories.tsx +377 -0
  173. package/src/hooks/use-boolean/use-boolean.test.tsx +177 -0
  174. package/src/hooks/use-boolean/use-boolean.ts +50 -0
  175. package/src/hooks/use-click-outside/use-click-outside.stories.tsx +188 -18
  176. package/src/hooks/use-click-outside/use-click-outside.test.tsx +89 -10
  177. package/src/hooks/use-click-outside/use-click-outside.ts +62 -16
  178. package/src/hooks/use-debounce-callback/use-debounced-callback.stories.tsx +141 -41
  179. package/src/hooks/use-debounce-callback/use-debounced-callback.test.ts +217 -9
  180. package/src/hooks/use-debounce-callback/use-debounced-callback.ts +71 -11
  181. package/src/hooks/use-debounce-value/use-debounced-value.stories.tsx +247 -47
  182. package/src/hooks/use-debounce-value/use-debounced-value.test.ts +105 -10
  183. package/src/hooks/use-debounce-value/use-debounced-value.ts +19 -10
  184. package/src/hooks/use-disclosure/use-disclosure.stories.tsx +305 -14
  185. package/src/hooks/use-disclosure/use-disclosure.test.ts +198 -50
  186. package/src/hooks/use-disclosure/use-disclosure.ts +49 -29
  187. package/src/hooks/use-document-title/use-document-title.stories.tsx +54 -0
  188. package/src/hooks/use-document-title/use-document-title.test.tsx +26 -0
  189. package/src/hooks/use-document-title/{use-document-title.tsx → use-document-title.ts} +17 -3
  190. package/src/hooks/use-event-listener/use-event-listener.stories.tsx +105 -9
  191. package/src/hooks/use-event-listener/use-event-listener.test.tsx +77 -10
  192. package/src/hooks/use-event-listener/use-event-listener.ts +71 -11
  193. package/src/hooks/use-focus-trap/use-focus-trap.test.ts +31 -6
  194. package/src/hooks/use-focus-trap/use-focus-trap.ts +3 -2
  195. package/src/hooks/use-hotkey/index.ts +9 -1
  196. package/src/hooks/use-hotkey/use-hotkey.stories.tsx +279 -74
  197. package/src/hooks/use-hotkey/use-hotkey.test.tsx +286 -34
  198. package/src/hooks/use-hotkey/use-hotkey.ts +141 -17
  199. package/src/hooks/use-hotkey/utils/index.ts +8 -3
  200. package/src/hooks/use-hotkey/utils/is-input-field.test.ts +78 -0
  201. package/src/hooks/use-hotkey/utils/is-input-field.ts +31 -10
  202. package/src/hooks/use-hotkey/utils/match-and-run.test.ts +203 -0
  203. package/src/hooks/use-hotkey/utils/match-and-run.ts +62 -0
  204. package/src/hooks/use-hotkey/utils/match-key-modifiers.test.ts +65 -0
  205. package/src/hooks/use-hotkey/utils/match-key-modifiers.ts +39 -12
  206. package/src/hooks/use-hover/use-hover.stories.tsx +258 -80
  207. package/src/hooks/use-hover/use-hover.test.tsx +266 -26
  208. package/src/hooks/use-hover/use-hover.tsx +93 -28
  209. package/src/hooks/use-is-visible/use-is-visible.stories.tsx +193 -46
  210. package/src/hooks/use-is-visible/use-is-visible.test.tsx +235 -7
  211. package/src/hooks/use-is-visible/use-is-visible.ts +114 -0
  212. package/src/hooks/use-local-storage/use-local-storage.stories.tsx +129 -29
  213. package/src/hooks/use-local-storage/use-local-storage.test.ts +106 -41
  214. package/src/hooks/use-local-storage/use-local-storage.ts +100 -31
  215. package/src/hooks/use-media-query/use-media-query.stories.tsx +86 -26
  216. package/src/hooks/use-media-query/use-media-query.test.ts +132 -0
  217. package/src/hooks/use-media-query/use-media-query.ts +39 -14
  218. package/src/hooks/use-memoized-fn/use-memoized-fn.ts +0 -1
  219. package/src/hooks/use-mutation/use-mutation.stories.tsx +260 -94
  220. package/src/hooks/use-mutation/use-mutation.test.ts +359 -0
  221. package/src/hooks/use-mutation/use-mutation.ts +97 -0
  222. package/src/hooks/use-object/use-object.stories.tsx +310 -79
  223. package/src/hooks/use-object/use-object.test.tsx +235 -56
  224. package/src/hooks/use-object/use-object.ts +59 -0
  225. package/src/hooks/use-pagination/use-pagination.tsx +0 -1
  226. package/src/hooks/use-prevent-page-close/index.ts +1 -0
  227. package/src/hooks/use-prevent-page-close/use-prevent-page-close.stories.tsx +39 -0
  228. package/src/hooks/use-prevent-page-close/use-prevent-page-close.test.ts +89 -0
  229. package/src/hooks/use-prevent-page-close/use-prevent-page-close.ts +27 -0
  230. package/src/hooks/use-range-pagination/use-range-pagination.test.tsx +1 -1
  231. package/src/hooks/use-range-pagination/use-range-pagination.tsx +1 -1
  232. package/src/hooks/use-selection/use-selection.ts +0 -1
  233. package/src/hooks/use-step/use-step.stories.tsx +178 -65
  234. package/src/hooks/use-step/use-step.test.ts +178 -53
  235. package/src/hooks/use-step/use-step.ts +57 -49
  236. package/src/utils/form.test.tsx +13 -8
  237. package/src/utils/form.tsx +10 -0
  238. package/src/utils/functions/getFormData.test.ts +1 -1
  239. package/dist/hooks/use-hotkey/utils/create-hotkey-listener.cjs.js +0 -1
  240. package/dist/hooks/use-hotkey/utils/create-hotkey-listener.es.js +0 -10
  241. package/dist/hooks/use-prevent-close-window/use-prevent-close-window.cjs.js +0 -1
  242. package/dist/hooks/use-prevent-close-window/use-prevent-close-window.es.js +0 -15
  243. package/dist/hooks/use-toggle/use-toggle.cjs.js +0 -1
  244. package/dist/hooks/use-toggle/use-toggle.es.js +0 -10
  245. package/dist/src/hooks/use-hotkey/utils/create-hotkey-listener.d.ts +0 -1
  246. package/dist/src/hooks/use-prevent-close-window/index.d.ts +0 -1
  247. package/dist/src/hooks/use-prevent-close-window/use-prevent-close-window.d.ts +0 -13
  248. package/dist/src/hooks/use-toggle/index.d.ts +0 -1
  249. package/dist/src/hooks/use-toggle/use-toggle.d.ts +0 -3
  250. package/src/hooks/use-async/use-async.test.tsx +0 -68
  251. package/src/hooks/use-hotkey/utils/create-hotkey-listener.ts +0 -25
  252. package/src/hooks/use-is-visible/use-is-visible.tsx +0 -49
  253. package/src/hooks/use-mutation/use-mutation.test.tsx +0 -83
  254. package/src/hooks/use-mutation/use-mutation.tsx +0 -59
  255. package/src/hooks/use-object/use-object.tsx +0 -46
  256. package/src/hooks/use-prevent-close-window/index.ts +0 -1
  257. package/src/hooks/use-prevent-close-window/use-prevent-close-window.stories.tsx +0 -32
  258. package/src/hooks/use-prevent-close-window/use-prevent-close-window.test.ts +0 -79
  259. package/src/hooks/use-prevent-close-window/use-prevent-close-window.ts +0 -33
  260. package/src/hooks/use-toggle/index.ts +0 -1
  261. package/src/hooks/use-toggle/use-toggle.stories.tsx +0 -25
  262. package/src/hooks/use-toggle/use-toggle.test.tsx +0 -64
  263. package/src/hooks/use-toggle/use-toggle.ts +0 -14
  264. /package/dist/src/{hooks/use-prevent-close-window/use-prevent-close-window.test.d.ts → components/date-picker/date-input-form.test.d.ts} +0 -0
  265. /package/dist/src/hooks/{use-toggle/use-toggle.test.d.ts → internal/is-apple-device.test.d.ts} +0 -0
@@ -0,0 +1,397 @@
1
+ /// <reference types="vitest/globals" />
2
+ import { act, renderHook, waitFor } from "@testing-library/react";
3
+ import { describe, expect, it, vi } from "vitest";
4
+ import { useAsync } from "./use-async";
5
+
6
+ // ---------------------------------------------------------------------------
7
+ // Helpers
8
+ // ---------------------------------------------------------------------------
9
+
10
+ /** Creates a promise that can be resolved/rejected externally. */
11
+ function deferred<T>() {
12
+ let resolve!: (value: T) => void;
13
+ let reject!: (reason?: unknown) => void;
14
+ const promise = new Promise<T>((res, rej) => {
15
+ resolve = res;
16
+ reject = rej;
17
+ });
18
+ return { promise, resolve, reject };
19
+ }
20
+
21
+ // ---------------------------------------------------------------------------
22
+ // Test 1 — enabled: false → idle initial state, fn never called
23
+ // ---------------------------------------------------------------------------
24
+ describe("useAsync", () => {
25
+ it("test 1: enabled: false → idle state, fn never called", () => {
26
+ const fn = vi.fn().mockResolvedValue("data");
27
+ const { result } = renderHook(() => useAsync({ fn, enabled: false }));
28
+
29
+ expect(result.current.status).toBe("idle");
30
+ expect(result.current.loading).toBe(false);
31
+ expect(result.current.data).toBeNull();
32
+ expect(result.current.error).toBeNull();
33
+ expect(fn).not.toHaveBeenCalled();
34
+ });
35
+
36
+ // -------------------------------------------------------------------------
37
+ // Test 2 — enabled: true (default) → initial status "pending", loading true
38
+ // -------------------------------------------------------------------------
39
+ it("test 2: enabled: true → initial status pending, loading true", () => {
40
+ const { promise } = deferred<string>();
41
+ const fn = vi.fn().mockReturnValue(promise);
42
+ const { result } = renderHook(() => useAsync({ fn }));
43
+
44
+ expect(result.current.status).toBe("pending");
45
+ expect(result.current.loading).toBe(true);
46
+ });
47
+
48
+ // -------------------------------------------------------------------------
49
+ // Test 3 — success path
50
+ // -------------------------------------------------------------------------
51
+ it("test 3: success path — data set, status success, loading false", async () => {
52
+ const fn = vi.fn().mockResolvedValue(42);
53
+ const { result } = renderHook(() => useAsync({ fn }));
54
+
55
+ await waitFor(() => expect(result.current.status).toBe("success"));
56
+ expect(result.current.data).toBe(42);
57
+ expect(result.current.error).toBeNull();
58
+ expect(result.current.loading).toBe(false);
59
+ });
60
+
61
+ // -------------------------------------------------------------------------
62
+ // Test 4 — error path
63
+ // -------------------------------------------------------------------------
64
+ it("test 4: error path — error set, status error, loading false", async () => {
65
+ const fn = vi.fn().mockRejectedValue(new Error("fail"));
66
+ const { result } = renderHook(() => useAsync({ fn }));
67
+
68
+ await waitFor(() => expect(result.current.status).toBe("error"));
69
+ expect(result.current.error?.message).toBe("fail");
70
+ expect(result.current.data).toBeNull();
71
+ expect(result.current.loading).toBe(false);
72
+ });
73
+
74
+ // -------------------------------------------------------------------------
75
+ // Test 5 — onSuccess fires with resolved data
76
+ // -------------------------------------------------------------------------
77
+ it("test 5: onSuccess callback fires with resolved data", async () => {
78
+ const onSuccess = vi.fn();
79
+ const fn = vi.fn().mockResolvedValue("hello");
80
+ renderHook(() => useAsync({ fn, onSuccess }));
81
+
82
+ await waitFor(() => expect(onSuccess).toHaveBeenCalledWith("hello"));
83
+ });
84
+
85
+ // -------------------------------------------------------------------------
86
+ // Test 6 — onError fires with rejection reason
87
+ // -------------------------------------------------------------------------
88
+ it("test 6: onError callback fires with rejection reason", async () => {
89
+ const onError = vi.fn();
90
+ const fn = vi.fn().mockRejectedValue(new Error("boom"));
91
+ renderHook(() => useAsync({ fn, onError }));
92
+
93
+ await waitFor(() =>
94
+ expect(onError).toHaveBeenCalledWith(expect.any(Error)),
95
+ );
96
+ expect(onError.mock.calls[0][0].message).toBe("boom");
97
+ });
98
+
99
+ // -------------------------------------------------------------------------
100
+ // Test 7 — onSettled fires on success
101
+ // -------------------------------------------------------------------------
102
+ it("test 7: onSettled(data, null) fires on success", async () => {
103
+ const onSettled = vi.fn();
104
+ const fn = vi.fn().mockResolvedValue("result");
105
+ renderHook(() => useAsync({ fn, onSettled }));
106
+
107
+ await waitFor(() => expect(onSettled).toHaveBeenCalledWith("result", null));
108
+ });
109
+
110
+ // -------------------------------------------------------------------------
111
+ // Test 8 — onSettled fires on error
112
+ // -------------------------------------------------------------------------
113
+ it("test 8: onSettled(null, error) fires on error", async () => {
114
+ const onSettled = vi.fn();
115
+ const fn = vi.fn().mockRejectedValue(new Error("err"));
116
+ renderHook(() => useAsync({ fn, onSettled }));
117
+
118
+ await waitFor(() =>
119
+ expect(onSettled).toHaveBeenCalledWith(null, expect.any(Error)),
120
+ );
121
+ });
122
+
123
+ // -------------------------------------------------------------------------
124
+ // Test 9 — latest callback ref: change onSuccess between renders
125
+ // -------------------------------------------------------------------------
126
+ it("test 9: callback identity — latest onSuccess called, not captured", async () => {
127
+ const first = vi.fn();
128
+ const second = vi.fn();
129
+ const d = deferred<string>();
130
+ const fn = vi.fn().mockReturnValue(d.promise);
131
+
132
+ const { rerender } = renderHook(
133
+ ({ onSuccess }: { onSuccess: (data: string) => void }) =>
134
+ useAsync({ fn, onSuccess }),
135
+ { initialProps: { onSuccess: first } },
136
+ );
137
+
138
+ // Change the callback before the promise resolves
139
+ rerender({ onSuccess: second });
140
+
141
+ await act(async () => {
142
+ d.resolve("data");
143
+ });
144
+
145
+ await waitFor(() => expect(second).toHaveBeenCalledWith("data"));
146
+ expect(first).not.toHaveBeenCalled();
147
+ });
148
+
149
+ // -------------------------------------------------------------------------
150
+ // Test 10 — refetch triggers new execution
151
+ // -------------------------------------------------------------------------
152
+ it("test 10: refetch transitions to pending and calls fn again", async () => {
153
+ const fn = vi.fn().mockResolvedValue("value");
154
+ const { result } = renderHook(() => useAsync({ fn }));
155
+
156
+ await waitFor(() => expect(result.current.status).toBe("success"));
157
+ expect(fn).toHaveBeenCalledTimes(1);
158
+
159
+ act(() => result.current.refetch());
160
+
161
+ await waitFor(() => expect(fn).toHaveBeenCalledTimes(2));
162
+ await waitFor(() => expect(result.current.status).toBe("success"));
163
+ });
164
+
165
+ // -------------------------------------------------------------------------
166
+ // Test 11 — race condition: second call result wins
167
+ // -------------------------------------------------------------------------
168
+ it("test 11: race condition — second call result wins, first discarded", async () => {
169
+ const first = deferred<string>();
170
+ const second = deferred<string>();
171
+ let callCount = 0;
172
+ const fn = vi.fn().mockImplementation((_signal: AbortSignal) => {
173
+ callCount++;
174
+ return callCount === 1 ? first.promise : second.promise;
175
+ });
176
+
177
+ const { result } = renderHook(
178
+ ({ deps }: { deps: number[] }) => useAsync({ fn, deps }),
179
+ { initialProps: { deps: [1] } },
180
+ );
181
+
182
+ // Wait until fn is called (first call in flight)
183
+ await waitFor(() => expect(fn).toHaveBeenCalledTimes(1));
184
+
185
+ // Trigger second call by changing deps
186
+ act(() => {
187
+ // rerender with new deps to trigger second call
188
+ });
189
+
190
+ // Instead use refetch for the second call
191
+ act(() => result.current.refetch());
192
+ await waitFor(() => expect(fn).toHaveBeenCalledTimes(2));
193
+
194
+ // Resolve second first, then first — first result should be discarded
195
+ await act(async () => {
196
+ second.resolve("second-result");
197
+ });
198
+ await waitFor(() => expect(result.current.status).toBe("success"));
199
+ expect(result.current.data).toBe("second-result");
200
+
201
+ // Now resolve the stale first call — state should NOT change
202
+ await act(async () => {
203
+ first.resolve("stale-result");
204
+ });
205
+ // State must remain second-result
206
+ expect(result.current.data).toBe("second-result");
207
+ });
208
+
209
+ // -------------------------------------------------------------------------
210
+ // Test 12 — unmount during pending: no setState after unmount
211
+ // -------------------------------------------------------------------------
212
+ it("test 12: unmount during pending — no setState after unmount", async () => {
213
+ const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {});
214
+ const d = deferred<string>();
215
+ const fn = vi.fn().mockReturnValue(d.promise);
216
+
217
+ const { unmount } = renderHook(() => useAsync({ fn }));
218
+
219
+ // Unmount before the promise resolves
220
+ unmount();
221
+
222
+ // Resolve after unmount
223
+ await act(async () => {
224
+ d.resolve("late");
225
+ });
226
+
227
+ // No React "can't perform state update on unmounted component" error
228
+ expect(consoleSpy).not.toHaveBeenCalledWith(
229
+ expect.stringContaining("unmounted"),
230
+ );
231
+ consoleSpy.mockRestore();
232
+ });
233
+
234
+ // -------------------------------------------------------------------------
235
+ // Test 13 — AbortSignal passed to fn
236
+ // -------------------------------------------------------------------------
237
+ it("test 13: AbortSignal instance passed to fn", () => {
238
+ let receivedSignal: unknown;
239
+ const fn = vi.fn().mockImplementation((signal: AbortSignal) => {
240
+ receivedSignal = signal;
241
+ return new Promise(() => {}); // never resolves
242
+ });
243
+
244
+ renderHook(() => useAsync({ fn }));
245
+
246
+ expect(receivedSignal).toBeInstanceOf(AbortSignal);
247
+ });
248
+
249
+ // -------------------------------------------------------------------------
250
+ // Test 14 — keepPreviousData: true — data preserved during re-fetch
251
+ // -------------------------------------------------------------------------
252
+ it("test 14: keepPreviousData true — previous data preserved while pending", async () => {
253
+ const first = deferred<string>();
254
+ let callCount = 0;
255
+ const fn = vi.fn().mockImplementation(() => {
256
+ callCount++;
257
+ if (callCount === 1) return first.promise;
258
+ return new Promise(() => {}); // second call never resolves
259
+ });
260
+
261
+ const { result } = renderHook(() =>
262
+ useAsync({ fn, keepPreviousData: true }),
263
+ );
264
+
265
+ // Resolve first call
266
+ await act(async () => {
267
+ first.resolve("first-data");
268
+ });
269
+ await waitFor(() => expect(result.current.status).toBe("success"));
270
+ expect(result.current.data).toBe("first-data");
271
+
272
+ // Trigger re-fetch
273
+ act(() => result.current.refetch());
274
+
275
+ await waitFor(() => expect(result.current.status).toBe("pending"));
276
+ // Previous data must still be present
277
+ expect(result.current.data).toBe("first-data");
278
+ });
279
+
280
+ // -------------------------------------------------------------------------
281
+ // Test 15 — keepPreviousData: false — data nulled on re-fetch start
282
+ // -------------------------------------------------------------------------
283
+ it("test 15: keepPreviousData false — data null immediately on re-fetch", async () => {
284
+ const first = deferred<string>();
285
+ let callCount = 0;
286
+ const fn = vi.fn().mockImplementation(() => {
287
+ callCount++;
288
+ if (callCount === 1) return first.promise;
289
+ return new Promise(() => {}); // second call never resolves
290
+ });
291
+
292
+ const { result } = renderHook(() =>
293
+ useAsync({ fn, keepPreviousData: false }),
294
+ );
295
+
296
+ await act(async () => {
297
+ first.resolve("first-data");
298
+ });
299
+ await waitFor(() => expect(result.current.status).toBe("success"));
300
+ expect(result.current.data).toBe("first-data");
301
+
302
+ // Trigger re-fetch
303
+ act(() => result.current.refetch());
304
+
305
+ await waitFor(() => expect(result.current.status).toBe("pending"));
306
+ // Data must be null now
307
+ expect(result.current.data).toBeNull();
308
+ });
309
+
310
+ // -------------------------------------------------------------------------
311
+ // Test 16 — dep change triggers re-run
312
+ // -------------------------------------------------------------------------
313
+ it("test 16: dep change triggers abort of previous call and starts new one", async () => {
314
+ const receivedSignals: AbortSignal[] = [];
315
+ const fn = vi.fn().mockImplementation((signal: AbortSignal) => {
316
+ receivedSignals.push(signal);
317
+ return new Promise<string>((resolve) => {
318
+ // Resolve after a tick so there's time for the signal to abort
319
+ setTimeout(() => resolve("data"), 10);
320
+ });
321
+ });
322
+
323
+ const { rerender } = renderHook(
324
+ ({ dep }: { dep: string }) => useAsync({ fn, deps: [dep] }),
325
+ { initialProps: { dep: "a" } },
326
+ );
327
+
328
+ await waitFor(() => expect(fn).toHaveBeenCalledTimes(1));
329
+
330
+ // Change dep — should trigger second call
331
+ rerender({ dep: "b" });
332
+
333
+ await waitFor(() => expect(fn).toHaveBeenCalledTimes(2));
334
+
335
+ // First signal must be aborted
336
+ expect(receivedSignals[0].aborted).toBe(true);
337
+ // Second signal is still active
338
+ expect(receivedSignals[1].aborted).toBe(false);
339
+ });
340
+
341
+ // -------------------------------------------------------------------------
342
+ // Test 17 — enabled: false → true transition
343
+ // -------------------------------------------------------------------------
344
+ it("test 17: enabled false → true transitions to pending and calls fn", async () => {
345
+ const fn = vi.fn().mockResolvedValue("ok");
346
+ const { result, rerender } = renderHook(
347
+ ({ enabled }: { enabled: boolean }) => useAsync({ fn, enabled }),
348
+ { initialProps: { enabled: false } },
349
+ );
350
+
351
+ expect(result.current.status).toBe("idle");
352
+ expect(fn).not.toHaveBeenCalled();
353
+
354
+ rerender({ enabled: true });
355
+
356
+ await waitFor(() => expect(fn).toHaveBeenCalledTimes(1));
357
+ await waitFor(() => expect(result.current.status).toBe("success"));
358
+ });
359
+
360
+ // -------------------------------------------------------------------------
361
+ // Test 18 — refetch identity stable across re-renders
362
+ // -------------------------------------------------------------------------
363
+ it("test 18: refetch reference is stable across re-renders", async () => {
364
+ const fn = vi.fn().mockResolvedValue("val");
365
+ const { result, rerender } = renderHook(() => useAsync({ fn }));
366
+
367
+ await waitFor(() => expect(result.current.status).toBe("success"));
368
+
369
+ const first = result.current.refetch;
370
+ rerender();
371
+ const second = result.current.refetch;
372
+
373
+ expect(first).toBe(second);
374
+ });
375
+
376
+ // -------------------------------------------------------------------------
377
+ // Test 19 — SSR snapshot: hook initial return is idle (effect doesn't run)
378
+ // -------------------------------------------------------------------------
379
+ it("test 19: SSR snapshot — initial return shape with enabled false", () => {
380
+ // Simulate SSR: the hook returns idle state on first render
381
+ // when enabled is false (effect-free path)
382
+ const fn = vi.fn();
383
+ const { result } = renderHook(() => useAsync({ fn, enabled: false }));
384
+
385
+ // Immediate synchronous check before any async work
386
+ expect(result.current).toMatchObject({
387
+ status: "idle",
388
+ loading: false,
389
+ data: null,
390
+ error: null,
391
+ });
392
+ expect(typeof result.current.refetch).toBe("function");
393
+ expect(typeof result.current.isIdle).toBe("boolean");
394
+ expect(typeof result.current.isSuccess).toBe("boolean");
395
+ expect(typeof result.current.isError).toBe("boolean");
396
+ });
397
+ });
@@ -1,57 +1,135 @@
1
- import { DependencyList, useCallback, useEffect, useState } from "react";
1
+ import { useCallback, useEffect, useRef, useState } from "react";
2
+ import { useLatestRef } from "../internal/use-latest-ref";
2
3
 
3
- interface UseAsyncOptions<T> {
4
- onError?: (error: Error) => void;
4
+ export type AsyncStatus = "idle" | "pending" | "success" | "error";
5
+
6
+ export interface UseAsyncOptions<T> {
7
+ fn: (signal: AbortSignal) => Promise<T>;
8
+ deps?: readonly unknown[];
9
+ enabled?: boolean;
10
+ keepPreviousData?: boolean;
5
11
  onSuccess?: (data: T) => void;
6
- onFinish?: () => void;
12
+ onError?: (error: Error) => void;
13
+ onSettled?: (data: T | null, error: Error | null) => void;
7
14
  }
8
15
 
9
- interface AsyncState<T> {
10
- loading: boolean;
11
- error: Error | null;
16
+ export interface UseAsyncReturn<T> {
12
17
  data: T | null;
18
+ error: Error | null;
19
+ status: AsyncStatus;
20
+ loading: boolean;
21
+ isIdle: boolean;
22
+ isSuccess: boolean;
23
+ isError: boolean;
13
24
  refetch: () => void;
14
25
  }
15
26
 
16
- interface Props<T> extends UseAsyncOptions<T> {
17
- fn: () => Promise<T>;
18
- dependencies?: DependencyList;
19
- }
20
-
21
27
  export function useAsync<T>({
22
28
  fn,
23
- dependencies = [],
24
- onError,
29
+ deps = [],
30
+ enabled = true,
31
+ keepPreviousData = false,
25
32
  onSuccess,
26
- onFinish,
27
- }: Props<T>): AsyncState<T> {
28
- const [loading, setLoading] = useState<boolean>(true);
29
- const [error, setError] = useState<Error | null>(null);
33
+ onError,
34
+ onSettled,
35
+ }: UseAsyncOptions<T>): UseAsyncReturn<T> {
36
+ const [status, setStatus] = useState<AsyncStatus>(
37
+ enabled ? "pending" : "idle",
38
+ );
30
39
  const [data, setData] = useState<T | null>(null);
40
+ const [error, setError] = useState<Error | null>(null);
31
41
 
32
- const fnMemoized = useCallback(() => {
33
- setLoading(true);
34
- setError(null);
35
- setData(null);
36
- fn()
37
- .then((result) => {
38
- onSuccess?.(result);
42
+ // Stable callback refs never cause effect re-subscriptions
43
+ const fnRef = useLatestRef(fn);
44
+ const onSuccessRef = useLatestRef(onSuccess);
45
+ const onErrorRef = useLatestRef(onError);
46
+ const onSettledRef = useLatestRef(onSettled);
47
+
48
+ // Monotonically incrementing request ID — guards stale setState
49
+ const requestIdRef = useRef(0);
50
+
51
+ // Tick cell — bumping this from refetch() forces the effect to re-run
52
+ // without creating a new execute function identity
53
+ const [refetchTick, setRefetchTick] = useState(0);
54
+
55
+ // Stable execute — never changes identity; receives AbortSignal + request id
56
+ const execute = useCallback(
57
+ async (signal: AbortSignal, id: number): Promise<void> => {
58
+ let settled: T | null = null;
59
+ let settledError: Error | null = null;
60
+
61
+ try {
62
+ const result = await fnRef.current(signal);
63
+
64
+ // Stale-skip: abort or superseded by a newer request
65
+ if (id !== requestIdRef.current || signal.aborted) return;
66
+
67
+ settled = result;
39
68
  setData(result);
40
- })
41
- .catch((err: Error) => {
42
- onError?.(err);
43
- setError(err);
44
- })
45
- .finally(() => {
46
- onFinish?.();
47
- setLoading(false);
48
- });
49
- }, dependencies);
69
+ setStatus("success");
70
+ onSuccessRef.current?.(result);
71
+ } catch (err) {
72
+ // Abort errors are swallowed — not surfaced to error state or onError
73
+ if (signal.aborted) return;
74
+
75
+ // Stale-skip for non-abort errors too
76
+ if (id !== requestIdRef.current) return;
77
+
78
+ const coerced = err instanceof Error ? err : new Error(String(err));
79
+ settledError = coerced;
80
+ setError(coerced);
81
+ setStatus("error");
82
+ onErrorRef.current?.(coerced);
83
+ } finally {
84
+ // Only call onSettled if this is still the current request and not aborted
85
+ if (id === requestIdRef.current && !signal.aborted) {
86
+ onSettledRef.current?.(settled, settledError);
87
+ }
88
+ }
89
+ },
90
+ // All deps are refs — this callback is effectively stable
91
+ [fnRef, onSuccessRef, onErrorRef, onSettledRef],
92
+ );
93
+
94
+ // Stable refetch — bumps tick to trigger the effect
95
+ const refetch = useCallback(() => {
96
+ setRefetchTick((n) => n + 1);
97
+ }, []);
50
98
 
51
99
  useEffect(() => {
52
- fnMemoized();
53
- }, [fnMemoized]);
100
+ if (!enabled) {
101
+ setStatus("idle");
102
+ return;
103
+ }
54
104
 
55
- return { loading, error, data, refetch: fnMemoized };
56
- }
105
+ // Bump request ID and capture local copy for stale-skip checks
106
+ requestIdRef.current += 1;
107
+ const id = requestIdRef.current;
108
+
109
+ const controller = new AbortController();
110
+
111
+ setStatus("pending");
112
+ setError(null);
113
+ if (!keepPreviousData) {
114
+ setData(null);
115
+ }
57
116
 
117
+ execute(controller.signal, id);
118
+
119
+ return () => {
120
+ controller.abort();
121
+ };
122
+ // eslint-disable-next-line react-hooks/exhaustive-deps
123
+ }, [...deps, enabled, execute, refetchTick]);
124
+
125
+ return {
126
+ data,
127
+ error,
128
+ status,
129
+ loading: status === "pending",
130
+ isIdle: status === "idle",
131
+ isSuccess: status === "success",
132
+ isError: status === "error",
133
+ refetch,
134
+ };
135
+ }
@@ -0,0 +1 @@
1
+ export * from "./use-boolean";