Skip to content

Commit c6c6ee0

Browse files
Active beacon (#18)
* Update pnpm to 10.19.0 * Update happy-dom to 20.0.8 * Fix broken test * Update tests * Update vitest * Update vitest * Add useRafDebounce hook
1 parent 6ad1726 commit c6c6ee0

File tree

7 files changed

+371
-394
lines changed

7 files changed

+371
-394
lines changed

.mise.toml

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,5 @@
22
PRETTIER_EXPERIMENTAL_CLI = "1"
33

44
[tools]
5-
node = "22.21.0"
6-
pnpm = "10.18.3"
7-
5+
node = "24.11.0"
6+
pnpm = "10.20.0"

package.json

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,12 @@
11
{
22
"name": "@trashpanda001/helpers",
33
"description": "Helpers for my projects.",
4-
"version": "0.22.0",
4+
"version": "0.23.0",
55
"type": "module",
66
"engines": {
7-
"node": "^22.0.0"
7+
"node": ">=22.0.0"
88
},
9-
"packageManager": "pnpm@10.18.3",
9+
"packageManager": "pnpm@10.20.0",
1010
"license": "MIT",
1111
"repository": {
1212
"type": "git",
@@ -61,20 +61,20 @@
6161
"@testing-library/dom": "^10.4.1",
6262
"@testing-library/react": "^16.3.0",
6363
"@types/luxon": "^3.7.1",
64-
"@types/node": "^22.18.11",
64+
"@types/node": "^24.9.1",
6565
"@types/react": "^19.2.2",
6666
"@types/react-dom": "^19.2.2",
6767
"@types/string-natural-compare": "^3.0.4",
6868
"eslint": "^9.38.0",
6969
"eslint-plugin-perfectionist": "^4.15.1",
7070
"eslint-plugin-react": "^7.37.5",
7171
"eslint-plugin-unused-imports": "^4.3.0",
72-
"happy-dom": "^20.0.7",
72+
"happy-dom": "^20.0.8",
7373
"prettier": "^3.6.2",
7474
"typedoc": "^0.28.14",
7575
"typescript": "^5.9.3",
76-
"typescript-eslint": "^8.46.1",
77-
"vitest": "^3.2.4"
76+
"typescript-eslint": "^8.46.2",
77+
"vitest": "^4.0.4"
7878
},
7979
"peerDependencies": {
8080
"luxon": "^3",

pnpm-lock.yaml

Lines changed: 218 additions & 292 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/react/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ export { useIsMounted } from "./use-is-mounted.js"
1515
export { useIsPageVisible } from "./use-is-page-visible.js"
1616
export { useIsPortrait } from "./use-is-portrait.js"
1717
export { useMediaQuery } from "./use-media-query.js"
18+
export { useRafDebounce } from "./use-raf-debounce.js"
1819
export { useResizeObserver } from "./use-resize-observer.js"
1920
export { useToggle } from "./use-toggle.js"
2021
export { useViewport, type ViewportInfo } from "./use-viewport.js"

src/react/use-raf-debounce.test.ts

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
import { act, renderHook } from "@testing-library/react"
2+
import { useRafDebounce } from "@trashpanda001/helpers/react"
3+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"
4+
5+
describe("useRafDebounce", () => {
6+
let rafCallbacks: Map<number, FrameRequestCallback>
7+
let cancelledHandles: number[]
8+
let nextHandle: number
9+
10+
beforeEach(() => {
11+
nextHandle = 1
12+
rafCallbacks = new Map()
13+
cancelledHandles = []
14+
15+
const stubbedRequestAnimationFrame: typeof requestAnimationFrame = (callback) => {
16+
const handle = nextHandle++
17+
rafCallbacks.set(handle, callback)
18+
return handle
19+
}
20+
21+
const stubbedCancelAnimationFrame: typeof cancelAnimationFrame = (handle) => {
22+
cancelledHandles.push(handle)
23+
rafCallbacks.delete(handle)
24+
}
25+
26+
vi.stubGlobal("requestAnimationFrame", stubbedRequestAnimationFrame)
27+
vi.stubGlobal("cancelAnimationFrame", stubbedCancelAnimationFrame)
28+
})
29+
30+
afterEach(() => {
31+
vi.unstubAllGlobals()
32+
})
33+
34+
it("only executes the latest invocation on the next animation frame", () => {
35+
const callback = vi.fn<(value: string) => void>()
36+
const { result } = renderHook(() => useRafDebounce(callback))
37+
38+
act(() => {
39+
result.current("first")
40+
})
41+
42+
const handlesAfterFirst = [...rafCallbacks.keys()]
43+
expect(handlesAfterFirst).toHaveLength(1)
44+
const firstHandle = handlesAfterFirst[0]!
45+
46+
act(() => {
47+
result.current("second")
48+
})
49+
50+
const handles = [...rafCallbacks.keys()]
51+
expect(handles).toHaveLength(1)
52+
const latestHandle = handles[0]!
53+
54+
expect(cancelledHandles).toContain(firstHandle)
55+
expect(callback).not.toHaveBeenCalled()
56+
57+
act(() => {
58+
const frame = rafCallbacks.get(latestHandle)
59+
expect(frame).toBeDefined()
60+
frame?.(16 as DOMHighResTimeStamp)
61+
})
62+
63+
expect(callback).toHaveBeenCalledTimes(1)
64+
expect(callback).toHaveBeenCalledWith("second")
65+
})
66+
})

src/react/use-raf-debounce.ts

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
import { useCallback, useRef } from "react"
2+
3+
/**
4+
* Debounces a callback using requestAnimationFrame to sync with display refresh rate.
5+
*
6+
* This is ideal for high-frequency events like scroll, resize, or mousemove that need
7+
* to update the UI. Instead of running on every event (which can be 1000+ times/sec),
8+
* the callback runs at the display refresh rate (60fps/120fps).
9+
*
10+
* @param callback - Function to debounce
11+
* @returns Debounced function that runs at display refresh rate
12+
*
13+
* @example
14+
* ```tsx
15+
* const debouncedScroll = useRafDebounce(() => {
16+
* updateScrollPosition()
17+
* })
18+
*
19+
* window.addEventListener('scroll', debouncedScroll, { passive: true })
20+
* window.removeEventListener('scroll', debouncedScroll)
21+
* ```
22+
*/
23+
export function useRafDebounce<T extends unknown[]>(callback: (...args: T) => void): (...args: T) => void {
24+
const handleRef = useRef(0)
25+
return useCallback(
26+
(...args: T) => {
27+
cancelAnimationFrame(handleRef.current)
28+
handleRef.current = requestAnimationFrame((_time: DOMHighResTimeStamp) => {
29+
callback(...args)
30+
})
31+
},
32+
[callback],
33+
)
34+
}
Lines changed: 43 additions & 92 deletions
Original file line numberDiff line numberDiff line change
@@ -1,120 +1,71 @@
1-
import { act, render, renderHook, screen } from "@testing-library/react"
1+
import { act, renderHook } from "@testing-library/react"
22
import { useResizeObserver } from "@trashpanda001/helpers/react"
3-
import { useRef } from "react"
43
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"
54

6-
// Mock implementation of ResizeObserver
7-
class MockResizeObserver {
8-
callback: ResizeObserverCallback
9-
elements: Set<Element>
10-
constructor(callback: ResizeObserverCallback) {
11-
this.callback = callback
12-
this.elements = new Set()
13-
}
14-
disconnect() {
15-
this.elements.clear()
16-
}
17-
observe(element: Element) {
18-
this.elements.add(element)
19-
}
20-
triggerResize(element: Element, rect: DOMRectReadOnly) {
21-
this.callback(
22-
[
23-
{
24-
contentRect: rect,
25-
target: element,
26-
} as ResizeObserverEntry,
27-
],
28-
this,
29-
)
30-
}
31-
unobserve(element: Element) {
32-
this.elements.delete(element)
33-
}
34-
}
5+
describe("useResizeObserver", () => {
6+
let OriginalRO: typeof ResizeObserver
7+
let observe: ReturnType<typeof vi.fn>
8+
let unobserve: ReturnType<typeof vi.fn>
9+
let callback: ResizeObserverCallback
3510

36-
function TestComponent() {
37-
const ref = useRef<HTMLDivElement>(null)
38-
const rect = useResizeObserver(ref)
11+
beforeEach(() => {
12+
OriginalRO = window.ResizeObserver
3913

40-
return (
41-
<div>
42-
<div data-testid="resizable-element" ref={ref} style={{ height: "100px", width: "100px" }} />
43-
<div data-testid="rect-info">
44-
Width: {rect.width}, Height: {rect.height}
45-
</div>
46-
</div>
47-
)
48-
}
14+
observe = vi.fn()
15+
unobserve = vi.fn()
4916

50-
describe("useResizeObserver", () => {
51-
let originalResizeObserver: typeof ResizeObserver
52-
let mockObserver: MockResizeObserver
17+
class ROStub {
18+
disconnect = vi.fn()
19+
observe = observe
20+
unobserve = unobserve
21+
constructor(cb: ResizeObserverCallback) {
22+
callback = cb
23+
}
24+
}
5325

54-
beforeEach(() => {
55-
originalResizeObserver = window.ResizeObserver
56-
mockObserver = new MockResizeObserver(() => {})
57-
window.ResizeObserver = vi.fn().mockImplementation((callback) => {
58-
mockObserver = new MockResizeObserver(callback)
59-
return mockObserver
60-
})
26+
window.ResizeObserver = ROStub as unknown as typeof ResizeObserver
6127
})
6228

6329
afterEach(() => {
64-
window.ResizeObserver = originalResizeObserver
30+
window.ResizeObserver = OriginalRO
31+
vi.restoreAllMocks()
6532
})
6633

67-
it("initializes with empty DOMRectReadOnly", () => {
68-
const { result } = renderHook(() => {
69-
const ref = { current: document.createElement("div") }
70-
return useResizeObserver(ref)
71-
})
72-
73-
expect(result.current.width).toBe(0)
74-
expect(result.current.height).toBe(0)
75-
})
76-
77-
it("throws an error if ref is not set", () => {
34+
it("throws if ref is not set", () => {
7835
expect(() => {
79-
renderHook(() => {
80-
const ref = { current: null }
81-
return useResizeObserver(ref)
82-
})
36+
renderHook(() => useResizeObserver({ current: null }))
8337
}).toThrow("useResizeObserver: ref is not set")
8438
})
8539

86-
it("observes the referenced element", () => {
87-
const spyObserve = vi.spyOn(MockResizeObserver.prototype, "observe")
88-
89-
renderHook(() => {
90-
const ref = { current: document.createElement("div") }
91-
return useResizeObserver(ref)
92-
})
93-
94-
expect(spyObserve).toHaveBeenCalledTimes(1)
95-
})
40+
it("observes on mount and unobserves on unmount", () => {
41+
const ref = { current: document.createElement("div") }
42+
const { unmount } = renderHook(() => useResizeObserver(ref))
9643

97-
it("unobserves the element on unmount", () => {
98-
const spyUnobserve = vi.spyOn(MockResizeObserver.prototype, "unobserve")
99-
100-
const { unmount } = renderHook(() => {
101-
const ref = { current: document.createElement("div") }
102-
return useResizeObserver(ref)
103-
})
44+
expect(observe).toHaveBeenCalledWith(ref.current)
10445

10546
unmount()
106-
expect(spyUnobserve).toHaveBeenCalledTimes(1)
47+
expect(unobserve).toHaveBeenCalledWith(ref.current)
10748
})
10849

109-
it("updates rect when element size changes", async () => {
110-
render(<TestComponent />)
50+
it("updates rect when element size changes", () => {
51+
const ref = { current: document.createElement("div") }
52+
const { result } = renderHook(() => useResizeObserver(ref))
11153

112-
const element = screen.getByTestId("resizable-element")
54+
expect(result.current.width).toBe(0)
55+
expect(result.current.height).toBe(0)
11356

11457
act(() => {
115-
mockObserver.triggerResize(element, new DOMRectReadOnly(0, 0, 200, 150))
58+
callback(
59+
[
60+
{
61+
contentRect: new DOMRectReadOnly(0, 0, 200, 150),
62+
} as unknown as ResizeObserverEntry,
63+
] as unknown as ResizeObserverEntry[],
64+
{} as unknown as ResizeObserver,
65+
)
11666
})
11767

118-
expect(screen.getByTestId("rect-info").textContent).toBe("Width: 200, Height: 150")
68+
expect(result.current.width).toBe(200)
69+
expect(result.current.height).toBe(150)
11970
})
12071
})

0 commit comments

Comments
 (0)