@pyreon/hooks 0.11.0 → 0.11.2

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 (64) hide show
  1. package/package.json +14 -12
  2. package/src/__tests__/useBreakpoint.test.ts +148 -0
  3. package/src/__tests__/useClickOutside.test.ts +144 -0
  4. package/src/__tests__/useColorScheme.test.ts +101 -0
  5. package/src/__tests__/useControllableState.test.ts +45 -0
  6. package/src/__tests__/useDebouncedCallback.test.ts +80 -0
  7. package/src/__tests__/useDebouncedValue.test.ts +112 -0
  8. package/src/__tests__/useElementSize.test.ts +190 -0
  9. package/src/__tests__/useFocus.test.ts +52 -0
  10. package/src/__tests__/useFocusTrap.test.ts +162 -0
  11. package/src/__tests__/useHover.test.ts +62 -0
  12. package/src/__tests__/useIntersection.test.ts +154 -0
  13. package/src/__tests__/useInterval.test.ts +48 -0
  14. package/src/__tests__/useIsomorphicLayoutEffect.test.ts +9 -0
  15. package/src/__tests__/useKeyboard.test.ts +144 -0
  16. package/src/__tests__/useLatest.test.ts +24 -0
  17. package/src/__tests__/useMediaQuery.test.ts +143 -0
  18. package/src/__tests__/useMergedRef.test.ts +48 -0
  19. package/src/__tests__/usePrevious.test.ts +70 -0
  20. package/src/__tests__/useReducedMotion.test.ts +96 -0
  21. package/src/__tests__/useRootSize.test.ts +27 -0
  22. package/src/__tests__/useScrollLock.test.ts +123 -0
  23. package/src/__tests__/useSpacing.test.ts +23 -0
  24. package/src/__tests__/useThemeValue.test.ts +12 -0
  25. package/src/__tests__/useThrottledCallback.test.ts +56 -0
  26. package/src/__tests__/useTimeout.test.ts +54 -0
  27. package/src/__tests__/useToggle.test.ts +82 -0
  28. package/src/__tests__/useUpdateEffect.test.ts +48 -0
  29. package/src/__tests__/useWindowResize.test.ts +139 -0
  30. package/src/index.ts +56 -0
  31. package/src/useBreakpoint.ts +52 -0
  32. package/src/useClickOutside.ts +23 -0
  33. package/src/useClipboard.ts +51 -0
  34. package/src/useColorScheme.ts +10 -0
  35. package/src/useControllableState.ts +39 -0
  36. package/src/useDebouncedCallback.ts +57 -0
  37. package/src/useDebouncedValue.ts +24 -0
  38. package/src/useDialog.ts +83 -0
  39. package/src/useElementSize.ts +38 -0
  40. package/src/useEventListener.ts +31 -0
  41. package/src/useFocus.ts +24 -0
  42. package/src/useFocusTrap.ts +42 -0
  43. package/src/useHover.ts +30 -0
  44. package/src/useInfiniteScroll.ts +115 -0
  45. package/src/useIntersection.ts +30 -0
  46. package/src/useInterval.ts +31 -0
  47. package/src/useIsomorphicLayoutEffect.ts +19 -0
  48. package/src/useKeyboard.ts +28 -0
  49. package/src/useLatest.ts +17 -0
  50. package/src/useMediaQuery.ts +27 -0
  51. package/src/useMergedRef.ts +24 -0
  52. package/src/useOnline.ts +31 -0
  53. package/src/usePrevious.ts +18 -0
  54. package/src/useReducedMotion.ts +8 -0
  55. package/src/useRootSize.ts +28 -0
  56. package/src/useScrollLock.ts +37 -0
  57. package/src/useSpacing.ts +26 -0
  58. package/src/useThemeValue.ts +21 -0
  59. package/src/useThrottledCallback.ts +30 -0
  60. package/src/useTimeAgo.ts +134 -0
  61. package/src/useTimeout.ts +42 -0
  62. package/src/useToggle.ts +22 -0
  63. package/src/useUpdateEffect.ts +24 -0
  64. package/src/useWindowResize.ts +39 -0
package/package.json CHANGED
@@ -1,9 +1,9 @@
1
1
  {
2
2
  "name": "@pyreon/hooks",
3
- "version": "0.11.0",
3
+ "version": "0.11.2",
4
4
  "repository": {
5
5
  "type": "git",
6
- "url": "https://github.com/pyreon/ui-system",
6
+ "url": "https://github.com/pyreon/pyreon",
7
7
  "directory": "packages/fundamentals/hooks"
8
8
  },
9
9
  "description": "Signal-based reactive utilities for Pyreon",
@@ -11,10 +11,11 @@
11
11
  "type": "module",
12
12
  "sideEffects": false,
13
13
  "exports": {
14
- "bun": "./src/index.ts",
15
- "source": "./src/index.ts",
16
- "import": "./lib/index.js",
17
- "types": "./lib/index.d.ts"
14
+ ".": {
15
+ "bun": "./src/index.ts",
16
+ "import": "./lib/index.js",
17
+ "types": "./lib/index.d.ts"
18
+ }
18
19
  },
19
20
  "types": "./lib/index.d.ts",
20
21
  "main": "./lib/index.js",
@@ -23,7 +24,8 @@
23
24
  "!lib/**/*.map",
24
25
  "!lib/analysis",
25
26
  "README.md",
26
- "LICENSE"
27
+ "LICENSE",
28
+ "src"
27
29
  ],
28
30
  "engines": {
29
31
  "node": ">= 22"
@@ -42,13 +44,13 @@
42
44
  "typecheck": "tsc --noEmit"
43
45
  },
44
46
  "peerDependencies": {
45
- "@pyreon/core": "^0.11.0",
46
- "@pyreon/reactivity": "^0.11.0",
47
- "@pyreon/styler": "^0.11.0",
48
- "@pyreon/ui-core": "^0.11.0"
47
+ "@pyreon/core": "^0.11.2",
48
+ "@pyreon/reactivity": "^0.11.2",
49
+ "@pyreon/styler": "^0.11.2",
50
+ "@pyreon/ui-core": "^0.11.2"
49
51
  },
50
52
  "devDependencies": {
51
53
  "@vitus-labs/tools-rolldown": "^1.15.3",
52
- "@pyreon/typescript": "^0.11.0"
54
+ "@pyreon/typescript": "^0.11.2"
53
55
  }
54
56
  }
@@ -0,0 +1,148 @@
1
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"
2
+
3
+ let mountCallbacks: Array<() => unknown> = []
4
+ let unmountCallbacks: Array<() => void> = []
5
+
6
+ vi.mock("@pyreon/core", () => ({
7
+ onMount: (fn: () => unknown) => {
8
+ mountCallbacks.push(fn)
9
+ },
10
+ onUnmount: (fn: () => void) => {
11
+ unmountCallbacks.push(fn)
12
+ },
13
+ }))
14
+
15
+ import { useBreakpoint } from "../useBreakpoint"
16
+
17
+ describe("useBreakpoint", () => {
18
+ const originalInnerWidth = window.innerWidth
19
+ let rafCallback: FrameRequestCallback | undefined
20
+
21
+ beforeEach(() => {
22
+ mountCallbacks = []
23
+ unmountCallbacks = []
24
+ rafCallback = undefined
25
+
26
+ vi.spyOn(window, "requestAnimationFrame").mockImplementation((cb) => {
27
+ rafCallback = cb
28
+ return 1
29
+ })
30
+ vi.spyOn(window, "cancelAnimationFrame").mockImplementation(() => {
31
+ /* no-op */
32
+ })
33
+ })
34
+
35
+ afterEach(() => {
36
+ Object.defineProperty(window, "innerWidth", { writable: true, value: originalInnerWidth })
37
+ vi.restoreAllMocks()
38
+ })
39
+
40
+ it("returns the active breakpoint based on window width", () => {
41
+ Object.defineProperty(window, "innerWidth", { writable: true, value: 800 })
42
+ const bp = useBreakpoint()
43
+ expect(bp()).toBe("md") // 768 <= 800 < 992
44
+ })
45
+
46
+ it("uses default breakpoints", () => {
47
+ Object.defineProperty(window, "innerWidth", { writable: true, value: 400 })
48
+ const bp = useBreakpoint()
49
+ expect(bp()).toBe("xs") // 0 <= 400 < 576
50
+
51
+ Object.defineProperty(window, "innerWidth", { writable: true, value: 600 })
52
+ const bp2 = useBreakpoint()
53
+ expect(bp2()).toBe("sm") // 576 <= 600 < 768
54
+ })
55
+
56
+ it("supports custom breakpoints", () => {
57
+ Object.defineProperty(window, "innerWidth", { writable: true, value: 500 })
58
+ const bp = useBreakpoint({ mobile: 0, tablet: 600, desktop: 1024 })
59
+ expect(bp()).toBe("mobile")
60
+ })
61
+
62
+ it("returns largest matching breakpoint", () => {
63
+ Object.defineProperty(window, "innerWidth", { writable: true, value: 1200 })
64
+ const bp = useBreakpoint()
65
+ expect(bp()).toBe("xl")
66
+ })
67
+
68
+ it("returns first breakpoint for very small widths", () => {
69
+ Object.defineProperty(window, "innerWidth", { writable: true, value: 0 })
70
+ const bp = useBreakpoint()
71
+ expect(bp()).toBe("xs")
72
+ })
73
+
74
+ it("updates breakpoint on resize", () => {
75
+ Object.defineProperty(window, "innerWidth", { writable: true, value: 800 })
76
+ const bp = useBreakpoint()
77
+ mountCallbacks.forEach((cb) => {
78
+ cb()
79
+ })
80
+ expect(bp()).toBe("md")
81
+
82
+ // Simulate resize
83
+ Object.defineProperty(window, "innerWidth", { writable: true, value: 1200 })
84
+ window.dispatchEvent(new Event("resize"))
85
+
86
+ // Execute rAF callback
87
+ rafCallback?.(0)
88
+ expect(bp()).toBe("xl")
89
+ })
90
+
91
+ it("debounces resize with requestAnimationFrame", () => {
92
+ Object.defineProperty(window, "innerWidth", { writable: true, value: 800 })
93
+ const bp = useBreakpoint()
94
+ mountCallbacks.forEach((cb) => {
95
+ cb()
96
+ })
97
+
98
+ Object.defineProperty(window, "innerWidth", { writable: true, value: 1200 })
99
+ window.dispatchEvent(new Event("resize"))
100
+
101
+ // Before rAF fires, value should still be old
102
+ expect(bp()).toBe("md")
103
+
104
+ rafCallback?.(0)
105
+ expect(bp()).toBe("xl")
106
+ })
107
+
108
+ it("cancels pending rAF on new resize", () => {
109
+ Object.defineProperty(window, "innerWidth", { writable: true, value: 800 })
110
+ useBreakpoint()
111
+ mountCallbacks.forEach((cb) => {
112
+ cb()
113
+ })
114
+
115
+ window.dispatchEvent(new Event("resize"))
116
+ window.dispatchEvent(new Event("resize"))
117
+
118
+ expect(window.cancelAnimationFrame).toHaveBeenCalled()
119
+ })
120
+
121
+ it("cleans up on unmount", () => {
122
+ const removeSpy = vi.spyOn(window, "removeEventListener")
123
+ useBreakpoint()
124
+ mountCallbacks.forEach((cb) => {
125
+ cb()
126
+ })
127
+ unmountCallbacks.forEach((cb) => {
128
+ cb()
129
+ })
130
+
131
+ expect(removeSpy).toHaveBeenCalledWith("resize", expect.any(Function))
132
+ })
133
+
134
+ it("does not update when breakpoint has not changed", () => {
135
+ Object.defineProperty(window, "innerWidth", { writable: true, value: 800 })
136
+ const bp = useBreakpoint()
137
+ mountCallbacks.forEach((cb) => {
138
+ cb()
139
+ })
140
+
141
+ // Resize but stay within same breakpoint
142
+ Object.defineProperty(window, "innerWidth", { writable: true, value: 850 })
143
+ window.dispatchEvent(new Event("resize"))
144
+ rafCallback?.(0)
145
+
146
+ expect(bp()).toBe("md") // Still md (768-991)
147
+ })
148
+ })
@@ -0,0 +1,144 @@
1
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"
2
+
3
+ // Capture registered callbacks so we can invoke lifecycle manually
4
+ let mountCallbacks: Array<() => void> = []
5
+ let unmountCallbacks: Array<() => void> = []
6
+
7
+ vi.mock("@pyreon/core", () => ({
8
+ onMount: (fn: () => unknown) => {
9
+ mountCallbacks.push(fn as () => void)
10
+ },
11
+ onUnmount: (fn: () => void) => {
12
+ unmountCallbacks.push(fn)
13
+ },
14
+ }))
15
+
16
+ import { useClickOutside } from "../useClickOutside"
17
+
18
+ describe("useClickOutside", () => {
19
+ let container: HTMLDivElement
20
+
21
+ beforeEach(() => {
22
+ mountCallbacks = []
23
+ unmountCallbacks = []
24
+ container = document.createElement("div")
25
+ document.body.appendChild(container)
26
+ })
27
+
28
+ afterEach(() => {
29
+ document.body.removeChild(container)
30
+ })
31
+
32
+ it("calls handler when clicking outside the element", () => {
33
+ const handler = vi.fn()
34
+ useClickOutside(() => container, handler)
35
+
36
+ // Simulate mount
37
+ mountCallbacks.forEach((cb) => {
38
+ cb()
39
+ })
40
+
41
+ const outside = document.createElement("div")
42
+ document.body.appendChild(outside)
43
+
44
+ const event = new MouseEvent("mousedown", { bubbles: true })
45
+ Object.defineProperty(event, "target", { value: outside })
46
+ document.dispatchEvent(event)
47
+
48
+ expect(handler).toHaveBeenCalledTimes(1)
49
+ document.body.removeChild(outside)
50
+ })
51
+
52
+ it("does not call handler when clicking inside the element", () => {
53
+ const handler = vi.fn()
54
+ const child = document.createElement("span")
55
+ container.appendChild(child)
56
+
57
+ useClickOutside(() => container, handler)
58
+ mountCallbacks.forEach((cb) => {
59
+ cb()
60
+ })
61
+
62
+ const event = new MouseEvent("mousedown", { bubbles: true })
63
+ Object.defineProperty(event, "target", { value: child })
64
+ document.dispatchEvent(event)
65
+
66
+ expect(handler).not.toHaveBeenCalled()
67
+ })
68
+
69
+ it("does not call handler when clicking the element itself", () => {
70
+ const handler = vi.fn()
71
+ useClickOutside(() => container, handler)
72
+ mountCallbacks.forEach((cb) => {
73
+ cb()
74
+ })
75
+
76
+ const event = new MouseEvent("mousedown", { bubbles: true })
77
+ Object.defineProperty(event, "target", { value: container })
78
+ document.dispatchEvent(event)
79
+
80
+ expect(handler).not.toHaveBeenCalled()
81
+ })
82
+
83
+ it("does not call handler when element is null", () => {
84
+ const handler = vi.fn()
85
+ useClickOutside(() => null, handler)
86
+ mountCallbacks.forEach((cb) => {
87
+ cb()
88
+ })
89
+
90
+ const event = new MouseEvent("mousedown", { bubbles: true })
91
+ Object.defineProperty(event, "target", { value: document.body })
92
+ document.dispatchEvent(event)
93
+
94
+ expect(handler).not.toHaveBeenCalled()
95
+ })
96
+
97
+ it("handles touchstart events", () => {
98
+ const handler = vi.fn()
99
+ useClickOutside(() => container, handler)
100
+ mountCallbacks.forEach((cb) => {
101
+ cb()
102
+ })
103
+
104
+ const outside = document.createElement("div")
105
+ document.body.appendChild(outside)
106
+
107
+ const event = new Event("touchstart", { bubbles: true })
108
+ Object.defineProperty(event, "target", { value: outside })
109
+ document.dispatchEvent(event)
110
+
111
+ expect(handler).toHaveBeenCalledTimes(1)
112
+ document.body.removeChild(outside)
113
+ })
114
+
115
+ it("removes listeners on unmount", () => {
116
+ const handler = vi.fn()
117
+ useClickOutside(() => container, handler)
118
+ mountCallbacks.forEach((cb) => {
119
+ cb()
120
+ })
121
+
122
+ const removeSpy = vi.spyOn(document, "removeEventListener")
123
+ unmountCallbacks.forEach((cb) => {
124
+ cb()
125
+ })
126
+
127
+ expect(removeSpy).toHaveBeenCalledWith("mousedown", expect.any(Function), true)
128
+ expect(removeSpy).toHaveBeenCalledWith("touchstart", expect.any(Function), true)
129
+ removeSpy.mockRestore()
130
+ })
131
+
132
+ it("adds listeners with capture phase", () => {
133
+ const handler = vi.fn()
134
+ const addSpy = vi.spyOn(document, "addEventListener")
135
+ useClickOutside(() => container, handler)
136
+ mountCallbacks.forEach((cb) => {
137
+ cb()
138
+ })
139
+
140
+ expect(addSpy).toHaveBeenCalledWith("mousedown", expect.any(Function), true)
141
+ expect(addSpy).toHaveBeenCalledWith("touchstart", expect.any(Function), true)
142
+ addSpy.mockRestore()
143
+ })
144
+ })
@@ -0,0 +1,101 @@
1
+ import { beforeEach, describe, expect, it, vi } from "vitest"
2
+
3
+ let mountCallbacks: Array<() => unknown> = []
4
+ let unmountCallbacks: Array<() => void> = []
5
+
6
+ vi.mock("@pyreon/core", () => ({
7
+ onMount: (fn: () => unknown) => {
8
+ mountCallbacks.push(fn)
9
+ },
10
+ onUnmount: (fn: () => void) => {
11
+ unmountCallbacks.push(fn)
12
+ },
13
+ }))
14
+
15
+ import { useColorScheme } from "../useColorScheme"
16
+
17
+ describe("useColorScheme", () => {
18
+ let changeListeners: Map<string, (e: MediaQueryListEvent) => void>
19
+
20
+ beforeEach(() => {
21
+ mountCallbacks = []
22
+ unmountCallbacks = []
23
+ changeListeners = new Map()
24
+
25
+ Object.defineProperty(window, "matchMedia", {
26
+ writable: true,
27
+ value: vi.fn((query: string) => ({
28
+ matches: false,
29
+ media: query,
30
+ addEventListener: vi.fn((event: string, cb: (e: MediaQueryListEvent) => void) => {
31
+ if (event === "change") changeListeners.set(query, cb)
32
+ }),
33
+ removeEventListener: vi.fn(),
34
+ })),
35
+ })
36
+ })
37
+
38
+ it("returns light by default", () => {
39
+ const scheme = useColorScheme()
40
+ mountCallbacks.forEach((cb) => {
41
+ cb()
42
+ })
43
+ expect(scheme()).toBe("light")
44
+ })
45
+
46
+ it("returns dark when prefers-color-scheme is dark", () => {
47
+ Object.defineProperty(window, "matchMedia", {
48
+ writable: true,
49
+ value: vi.fn((query: string) => ({
50
+ matches: true,
51
+ media: query,
52
+ addEventListener: vi.fn((event: string, cb: (e: MediaQueryListEvent) => void) => {
53
+ if (event === "change") changeListeners.set(query, cb)
54
+ }),
55
+ removeEventListener: vi.fn(),
56
+ })),
57
+ })
58
+
59
+ const scheme = useColorScheme()
60
+ mountCallbacks.forEach((cb) => {
61
+ cb()
62
+ })
63
+ expect(scheme()).toBe("dark")
64
+ })
65
+
66
+ it("updates when color scheme changes from light to dark", () => {
67
+ const scheme = useColorScheme()
68
+ mountCallbacks.forEach((cb) => {
69
+ cb()
70
+ })
71
+ expect(scheme()).toBe("light")
72
+
73
+ const listener = changeListeners.get("(prefers-color-scheme: dark)")
74
+ listener?.({ matches: true } as MediaQueryListEvent)
75
+ expect(scheme()).toBe("dark")
76
+ })
77
+
78
+ it("updates when color scheme changes from dark to light", () => {
79
+ Object.defineProperty(window, "matchMedia", {
80
+ writable: true,
81
+ value: vi.fn((query: string) => ({
82
+ matches: true,
83
+ media: query,
84
+ addEventListener: vi.fn((event: string, cb: (e: MediaQueryListEvent) => void) => {
85
+ if (event === "change") changeListeners.set(query, cb)
86
+ }),
87
+ removeEventListener: vi.fn(),
88
+ })),
89
+ })
90
+
91
+ const scheme = useColorScheme()
92
+ mountCallbacks.forEach((cb) => {
93
+ cb()
94
+ })
95
+ expect(scheme()).toBe("dark")
96
+
97
+ const listener = changeListeners.get("(prefers-color-scheme: dark)")
98
+ listener?.({ matches: false } as MediaQueryListEvent)
99
+ expect(scheme()).toBe("light")
100
+ })
101
+ })
@@ -0,0 +1,45 @@
1
+ import { describe, expect, it, vi } from "vitest"
2
+ import { useControllableState } from "../useControllableState"
3
+
4
+ describe("useControllableState", () => {
5
+ it("uses defaultValue when uncontrolled", () => {
6
+ const [value] = useControllableState({ defaultValue: "hello" })
7
+ expect(value()).toBe("hello")
8
+ })
9
+
10
+ it("updates internal state when uncontrolled", () => {
11
+ const [value, setValue] = useControllableState({ defaultValue: 0 })
12
+ setValue(5)
13
+ expect(value()).toBe(5)
14
+ })
15
+
16
+ it("uses value when controlled", () => {
17
+ const [value] = useControllableState({ value: "controlled", defaultValue: "default" })
18
+ expect(value()).toBe("controlled")
19
+ })
20
+
21
+ it("does not update internal state when controlled", () => {
22
+ const onChange = vi.fn()
23
+ const [value, setValue] = useControllableState({
24
+ value: "controlled",
25
+ defaultValue: "default",
26
+ onChange,
27
+ })
28
+ setValue("new")
29
+ expect(value()).toBe("controlled")
30
+ expect(onChange).toHaveBeenCalledWith("new")
31
+ })
32
+
33
+ it("calls onChange in uncontrolled mode", () => {
34
+ const onChange = vi.fn()
35
+ const [, setValue] = useControllableState({ defaultValue: 0, onChange })
36
+ setValue(10)
37
+ expect(onChange).toHaveBeenCalledWith(10)
38
+ })
39
+
40
+ it("supports updater function", () => {
41
+ const [value, setValue] = useControllableState({ defaultValue: 1 })
42
+ setValue((prev: number) => prev + 1)
43
+ expect(value()).toBe(2)
44
+ })
45
+ })
@@ -0,0 +1,80 @@
1
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"
2
+ import { useDebouncedCallback } from "../useDebouncedCallback"
3
+
4
+ // Mock onUnmount since it requires component lifecycle context
5
+ vi.mock("@pyreon/core", () => ({
6
+ onMount: (fn: () => void) => fn(),
7
+ onUnmount: (_fn: () => void) => {
8
+ /* no-op */
9
+ },
10
+ }))
11
+
12
+ describe("useDebouncedCallback", () => {
13
+ beforeEach(() => vi.useFakeTimers())
14
+ afterEach(() => vi.useRealTimers())
15
+
16
+ it("debounces the callback", () => {
17
+ const fn = vi.fn()
18
+ const debounced = useDebouncedCallback(fn, 100)
19
+
20
+ debounced("a")
21
+ debounced("b")
22
+ debounced("c")
23
+
24
+ expect(fn).not.toHaveBeenCalled()
25
+ vi.advanceTimersByTime(100)
26
+ expect(fn).toHaveBeenCalledTimes(1)
27
+ expect(fn).toHaveBeenCalledWith("c")
28
+ })
29
+
30
+ it("cancel prevents the callback", () => {
31
+ const fn = vi.fn()
32
+ const debounced = useDebouncedCallback(fn, 100)
33
+
34
+ debounced("a")
35
+ debounced.cancel()
36
+ vi.advanceTimersByTime(200)
37
+
38
+ expect(fn).not.toHaveBeenCalled()
39
+ })
40
+
41
+ it("flush invokes immediately", () => {
42
+ const fn = vi.fn()
43
+ const debounced = useDebouncedCallback(fn, 100)
44
+
45
+ debounced("x")
46
+ debounced.flush()
47
+
48
+ expect(fn).toHaveBeenCalledWith("x")
49
+ })
50
+
51
+ it("flush is a no-op when no pending timer", () => {
52
+ const fn = vi.fn()
53
+ const debounced = useDebouncedCallback(fn, 100)
54
+
55
+ debounced.flush()
56
+ expect(fn).not.toHaveBeenCalled()
57
+ })
58
+
59
+ it("flush is a no-op after timer already fired", () => {
60
+ const fn = vi.fn()
61
+ const debounced = useDebouncedCallback(fn, 100)
62
+
63
+ debounced("a")
64
+ vi.advanceTimersByTime(100)
65
+ expect(fn).toHaveBeenCalledTimes(1)
66
+
67
+ debounced.flush()
68
+ expect(fn).toHaveBeenCalledTimes(1)
69
+ })
70
+
71
+ it("flush is a no-op after cancel", () => {
72
+ const fn = vi.fn()
73
+ const debounced = useDebouncedCallback(fn, 100)
74
+
75
+ debounced("a")
76
+ debounced.cancel()
77
+ debounced.flush()
78
+ expect(fn).not.toHaveBeenCalled()
79
+ })
80
+ })
@@ -0,0 +1,112 @@
1
+ import { signal } from "@pyreon/reactivity"
2
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"
3
+ import { useDebouncedValue } from "../useDebouncedValue"
4
+
5
+ // Mock onUnmount since it requires component lifecycle context
6
+ vi.mock("@pyreon/core", () => ({
7
+ onMount: (fn: () => void) => fn(),
8
+ onUnmount: (_fn: () => void) => {
9
+ /* no-op */
10
+ },
11
+ }))
12
+
13
+ describe("useDebouncedValue", () => {
14
+ beforeEach(() => {
15
+ vi.useFakeTimers()
16
+ })
17
+
18
+ afterEach(() => {
19
+ vi.useRealTimers()
20
+ })
21
+
22
+ it("returns the initial value immediately", () => {
23
+ const source = signal(42)
24
+ const debounced = useDebouncedValue(source, 300)
25
+ expect(debounced()).toBe(42)
26
+ })
27
+
28
+ it("does not update immediately when source changes", () => {
29
+ const source = signal("hello")
30
+ const debounced = useDebouncedValue(source, 300)
31
+ expect(debounced()).toBe("hello")
32
+
33
+ source.set("world")
34
+ expect(debounced()).toBe("hello")
35
+ })
36
+
37
+ it("updates after the delay elapses", () => {
38
+ const source = signal("hello")
39
+ const debounced = useDebouncedValue(source, 300)
40
+
41
+ source.set("world")
42
+ expect(debounced()).toBe("hello")
43
+
44
+ vi.advanceTimersByTime(300)
45
+ expect(debounced()).toBe("world")
46
+ })
47
+
48
+ it("resets the timer on rapid changes", () => {
49
+ const source = signal(1)
50
+ const debounced = useDebouncedValue(source, 300)
51
+
52
+ source.set(2)
53
+ vi.advanceTimersByTime(100)
54
+ expect(debounced()).toBe(1)
55
+
56
+ source.set(3)
57
+ vi.advanceTimersByTime(100)
58
+ expect(debounced()).toBe(1)
59
+
60
+ source.set(4)
61
+ vi.advanceTimersByTime(300)
62
+ expect(debounced()).toBe(4)
63
+ })
64
+
65
+ it("only applies the last value after debounce", () => {
66
+ const source = signal(0)
67
+ const debounced = useDebouncedValue(source, 200)
68
+
69
+ source.set(1)
70
+ source.set(2)
71
+ source.set(3)
72
+ source.set(4)
73
+ source.set(5)
74
+
75
+ vi.advanceTimersByTime(200)
76
+ expect(debounced()).toBe(5)
77
+ })
78
+
79
+ it("handles multiple debounce cycles", () => {
80
+ const source = signal("a")
81
+ const debounced = useDebouncedValue(source, 100)
82
+
83
+ source.set("b")
84
+ vi.advanceTimersByTime(100)
85
+ expect(debounced()).toBe("b")
86
+
87
+ source.set("c")
88
+ vi.advanceTimersByTime(100)
89
+ expect(debounced()).toBe("c")
90
+ })
91
+
92
+ it("works with zero delay", () => {
93
+ const source = signal(1)
94
+ const debounced = useDebouncedValue(source, 0)
95
+
96
+ source.set(2)
97
+ vi.advanceTimersByTime(0)
98
+ expect(debounced()).toBe(2)
99
+ })
100
+
101
+ it("works with object values", () => {
102
+ const obj1 = { name: "Alice" }
103
+ const obj2 = { name: "Bob" }
104
+ const source = signal(obj1)
105
+ const debounced = useDebouncedValue(source, 100)
106
+ expect(debounced()).toBe(obj1)
107
+
108
+ source.set(obj2)
109
+ vi.advanceTimersByTime(100)
110
+ expect(debounced()).toBe(obj2)
111
+ })
112
+ })