Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 2 additions & 3 deletions .mise.toml
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,5 @@
PRETTIER_EXPERIMENTAL_CLI = "1"

[tools]
node = "22.21.0"
pnpm = "10.18.3"

node = "24.11.0"
pnpm = "10.20.0"
14 changes: 7 additions & 7 deletions package.json
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
{
"name": "@trashpanda001/helpers",
"description": "Helpers for my projects.",
"version": "0.22.0",
"version": "0.23.0",
"type": "module",
"engines": {
"node": "^22.0.0"
"node": ">=22.0.0"
},
"packageManager": "pnpm@10.18.3",
"packageManager": "pnpm@10.20.0",
"license": "MIT",
"repository": {
"type": "git",
Expand Down Expand Up @@ -61,20 +61,20 @@
"@testing-library/dom": "^10.4.1",
"@testing-library/react": "^16.3.0",
"@types/luxon": "^3.7.1",
"@types/node": "^22.18.11",
"@types/node": "^24.9.1",
"@types/react": "^19.2.2",
"@types/react-dom": "^19.2.2",
"@types/string-natural-compare": "^3.0.4",
"eslint": "^9.38.0",
"eslint-plugin-perfectionist": "^4.15.1",
"eslint-plugin-react": "^7.37.5",
"eslint-plugin-unused-imports": "^4.3.0",
"happy-dom": "^20.0.7",
"happy-dom": "^20.0.8",
"prettier": "^3.6.2",
"typedoc": "^0.28.14",
"typescript": "^5.9.3",
"typescript-eslint": "^8.46.1",
"vitest": "^3.2.4"
"typescript-eslint": "^8.46.2",
"vitest": "^4.0.4"
},
"peerDependencies": {
"luxon": "^3",
Expand Down
510 changes: 218 additions & 292 deletions pnpm-lock.yaml

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions src/react/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ export { useIsMounted } from "./use-is-mounted.js"
export { useIsPageVisible } from "./use-is-page-visible.js"
export { useIsPortrait } from "./use-is-portrait.js"
export { useMediaQuery } from "./use-media-query.js"
export { useRafDebounce } from "./use-raf-debounce.js"
export { useResizeObserver } from "./use-resize-observer.js"
export { useToggle } from "./use-toggle.js"
export { useViewport, type ViewportInfo } from "./use-viewport.js"
66 changes: 66 additions & 0 deletions src/react/use-raf-debounce.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import { act, renderHook } from "@testing-library/react"
import { useRafDebounce } from "@trashpanda001/helpers/react"
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"

describe("useRafDebounce", () => {
let rafCallbacks: Map<number, FrameRequestCallback>
let cancelledHandles: number[]
let nextHandle: number

beforeEach(() => {
nextHandle = 1
rafCallbacks = new Map()
cancelledHandles = []

const stubbedRequestAnimationFrame: typeof requestAnimationFrame = (callback) => {
const handle = nextHandle++
rafCallbacks.set(handle, callback)
return handle
}

const stubbedCancelAnimationFrame: typeof cancelAnimationFrame = (handle) => {
cancelledHandles.push(handle)
rafCallbacks.delete(handle)
}

vi.stubGlobal("requestAnimationFrame", stubbedRequestAnimationFrame)
vi.stubGlobal("cancelAnimationFrame", stubbedCancelAnimationFrame)
})

afterEach(() => {
vi.unstubAllGlobals()
})

it("only executes the latest invocation on the next animation frame", () => {
const callback = vi.fn<(value: string) => void>()
const { result } = renderHook(() => useRafDebounce(callback))

act(() => {
result.current("first")
})

const handlesAfterFirst = [...rafCallbacks.keys()]
expect(handlesAfterFirst).toHaveLength(1)
const firstHandle = handlesAfterFirst[0]!

act(() => {
result.current("second")
})

const handles = [...rafCallbacks.keys()]
expect(handles).toHaveLength(1)
const latestHandle = handles[0]!

expect(cancelledHandles).toContain(firstHandle)
expect(callback).not.toHaveBeenCalled()

act(() => {
const frame = rafCallbacks.get(latestHandle)
expect(frame).toBeDefined()
frame?.(16 as DOMHighResTimeStamp)
})

expect(callback).toHaveBeenCalledTimes(1)
expect(callback).toHaveBeenCalledWith("second")
})
})
34 changes: 34 additions & 0 deletions src/react/use-raf-debounce.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import { useCallback, useRef } from "react"

/**
* Debounces a callback using requestAnimationFrame to sync with display refresh rate.
*
* This is ideal for high-frequency events like scroll, resize, or mousemove that need
* to update the UI. Instead of running on every event (which can be 1000+ times/sec),
* the callback runs at the display refresh rate (60fps/120fps).
*
* @param callback - Function to debounce
* @returns Debounced function that runs at display refresh rate
*
* @example
* ```tsx
* const debouncedScroll = useRafDebounce(() => {
* updateScrollPosition()
* })
*
* window.addEventListener('scroll', debouncedScroll, { passive: true })
* window.removeEventListener('scroll', debouncedScroll)
* ```
*/
export function useRafDebounce<T extends unknown[]>(callback: (...args: T) => void): (...args: T) => void {
const handleRef = useRef(0)
return useCallback(
(...args: T) => {
cancelAnimationFrame(handleRef.current)
handleRef.current = requestAnimationFrame((_time: DOMHighResTimeStamp) => {
callback(...args)
})
},
[callback],
)
}
135 changes: 43 additions & 92 deletions src/react/use-resize-observer.test.tsx
Original file line number Diff line number Diff line change
@@ -1,120 +1,71 @@
import { act, render, renderHook, screen } from "@testing-library/react"
import { act, renderHook } from "@testing-library/react"
import { useResizeObserver } from "@trashpanda001/helpers/react"
import { useRef } from "react"
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"

// Mock implementation of ResizeObserver
class MockResizeObserver {
callback: ResizeObserverCallback
elements: Set<Element>
constructor(callback: ResizeObserverCallback) {
this.callback = callback
this.elements = new Set()
}
disconnect() {
this.elements.clear()
}
observe(element: Element) {
this.elements.add(element)
}
triggerResize(element: Element, rect: DOMRectReadOnly) {
this.callback(
[
{
contentRect: rect,
target: element,
} as ResizeObserverEntry,
],
this,
)
}
unobserve(element: Element) {
this.elements.delete(element)
}
}
describe("useResizeObserver", () => {
let OriginalRO: typeof ResizeObserver
let observe: ReturnType<typeof vi.fn>
let unobserve: ReturnType<typeof vi.fn>
let callback: ResizeObserverCallback

function TestComponent() {
const ref = useRef<HTMLDivElement>(null)
const rect = useResizeObserver(ref)
beforeEach(() => {
OriginalRO = window.ResizeObserver

return (
<div>
<div data-testid="resizable-element" ref={ref} style={{ height: "100px", width: "100px" }} />
<div data-testid="rect-info">
Width: {rect.width}, Height: {rect.height}
</div>
</div>
)
}
observe = vi.fn()
unobserve = vi.fn()

describe("useResizeObserver", () => {
let originalResizeObserver: typeof ResizeObserver
let mockObserver: MockResizeObserver
class ROStub {
disconnect = vi.fn()
observe = observe
unobserve = unobserve
constructor(cb: ResizeObserverCallback) {
callback = cb
}
}

beforeEach(() => {
originalResizeObserver = window.ResizeObserver
mockObserver = new MockResizeObserver(() => {})
window.ResizeObserver = vi.fn().mockImplementation((callback) => {
mockObserver = new MockResizeObserver(callback)
return mockObserver
})
window.ResizeObserver = ROStub as unknown as typeof ResizeObserver
})

afterEach(() => {
window.ResizeObserver = originalResizeObserver
window.ResizeObserver = OriginalRO
vi.restoreAllMocks()
})

it("initializes with empty DOMRectReadOnly", () => {
const { result } = renderHook(() => {
const ref = { current: document.createElement("div") }
return useResizeObserver(ref)
})

expect(result.current.width).toBe(0)
expect(result.current.height).toBe(0)
})

it("throws an error if ref is not set", () => {
it("throws if ref is not set", () => {
expect(() => {
renderHook(() => {
const ref = { current: null }
return useResizeObserver(ref)
})
renderHook(() => useResizeObserver({ current: null }))
}).toThrow("useResizeObserver: ref is not set")
})

it("observes the referenced element", () => {
const spyObserve = vi.spyOn(MockResizeObserver.prototype, "observe")

renderHook(() => {
const ref = { current: document.createElement("div") }
return useResizeObserver(ref)
})

expect(spyObserve).toHaveBeenCalledTimes(1)
})
it("observes on mount and unobserves on unmount", () => {
const ref = { current: document.createElement("div") }
const { unmount } = renderHook(() => useResizeObserver(ref))

it("unobserves the element on unmount", () => {
const spyUnobserve = vi.spyOn(MockResizeObserver.prototype, "unobserve")

const { unmount } = renderHook(() => {
const ref = { current: document.createElement("div") }
return useResizeObserver(ref)
})
expect(observe).toHaveBeenCalledWith(ref.current)

unmount()
expect(spyUnobserve).toHaveBeenCalledTimes(1)
expect(unobserve).toHaveBeenCalledWith(ref.current)
})

it("updates rect when element size changes", async () => {
render(<TestComponent />)
it("updates rect when element size changes", () => {
const ref = { current: document.createElement("div") }
const { result } = renderHook(() => useResizeObserver(ref))

const element = screen.getByTestId("resizable-element")
expect(result.current.width).toBe(0)
expect(result.current.height).toBe(0)

act(() => {
mockObserver.triggerResize(element, new DOMRectReadOnly(0, 0, 200, 150))
callback(
[
{
contentRect: new DOMRectReadOnly(0, 0, 200, 150),
} as unknown as ResizeObserverEntry,
] as unknown as ResizeObserverEntry[],
{} as unknown as ResizeObserver,
)
})

expect(screen.getByTestId("rect-info").textContent).toBe("Width: 200, Height: 150")
expect(result.current.width).toBe(200)
expect(result.current.height).toBe(150)
})
})