Skip to content
Open
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
9 changes: 9 additions & 0 deletions .changeset/perf-use-resize-observer.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
---
'@primer/react': patch
---

perf(hooks): Add first-immediate throttling to useResizeObserver and useOverflow

- useResizeObserver now fires callback immediately on first observation, then throttles with rAF
- useOverflow now uses the same pattern to avoid initial flash of incorrect overflow state
- Added isFirstCallback ref pattern to skip throttling on initial mount
170 changes: 170 additions & 0 deletions packages/react/src/hooks/__tests__/useOverflow.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,170 @@
import {render, waitFor, act} from '@testing-library/react'
import {useRef, useState, useEffect, useImperativeHandle, forwardRef} from 'react'
import {describe, expect, test} from 'vitest'
import {useOverflow} from '../useOverflow'

interface TestHandle {
setContainerHeight: (height: number) => void
}

const OverflowContainer = forwardRef<TestHandle, {onOverflowChange: (hasOverflow: boolean) => void}>(
function OverflowContainer({onOverflowChange}, ref) {
const containerRef = useRef<HTMLDivElement>(null)
const [containerHeight, setContainerHeight] = useState(200)
const hasOverflow = useOverflow(containerRef)

useEffect(() => {
onOverflowChange(hasOverflow)
}, [hasOverflow, onOverflowChange])

useImperativeHandle(ref, () => ({
setContainerHeight,
}))

return (
<div ref={containerRef} style={{width: 100, height: containerHeight, overflow: 'auto'}}>
<div style={{width: 50, height: 150}}>Content</div>
</div>
)
},
)

describe('useOverflow', () => {
test('returns false when element has no overflow', async () => {
const results: boolean[] = []

function TestComponent() {
const ref = useRef<HTMLDivElement>(null)
const hasOverflow = useOverflow(ref)

useEffect(() => {
results.push(hasOverflow)
}, [hasOverflow])

return (
<div ref={ref} style={{width: 100, height: 100, overflow: 'auto'}}>
<div style={{width: 50, height: 50}}>Small content</div>
</div>
)
}

render(<TestComponent />)

await waitFor(() => {
expect(results.length).toBeGreaterThan(0)
})

expect(results[results.length - 1]).toBe(false)
})

test('returns true when element has vertical overflow', async () => {
const results: boolean[] = []

function TestComponent() {
const ref = useRef<HTMLDivElement>(null)
const hasOverflow = useOverflow(ref)

useEffect(() => {
results.push(hasOverflow)
}, [hasOverflow])

return (
<div ref={ref} style={{width: 100, height: 100, overflow: 'auto'}}>
<div style={{width: 50, height: 200}}>Tall content</div>
</div>
)
}

render(<TestComponent />)

await waitFor(() => {
expect(results).toContain(true)
})
})

test('returns true when element has horizontal overflow', async () => {
const results: boolean[] = []

function TestComponent() {
const ref = useRef<HTMLDivElement>(null)
const hasOverflow = useOverflow(ref)

useEffect(() => {
results.push(hasOverflow)
}, [hasOverflow])

return (
<div ref={ref} style={{width: 100, height: 100, overflow: 'auto'}}>
<div style={{width: 200, height: 50, whiteSpace: 'nowrap'}}>Wide content</div>
</div>
)
}

render(<TestComponent />)

await waitFor(() => {
expect(results).toContain(true)
})
})

test('returns false when ref.current is null', async () => {
const results: boolean[] = []

function TestComponent() {
const ref = useRef<HTMLDivElement>(null)
const hasOverflow = useOverflow(ref)

useEffect(() => {
results.push(hasOverflow)
}, [hasOverflow])

return null
}

render(<TestComponent />)

await waitFor(() => {
expect(results.length).toBeGreaterThan(0)
})

expect(results[0]).toBe(false)
})

test('updates when overflow state changes', async () => {
const results: boolean[] = []
const handleRef = {current: null as TestHandle | null}

function TestComponent() {
const ref = useRef<TestHandle>(null)

useEffect(() => {
handleRef.current = ref.current
})

return (
<OverflowContainer
ref={ref}
onOverflowChange={hasOverflow => {
results.push(hasOverflow)
}}
/>
)
}

render(<TestComponent />)

// Initially containerHeight=200, content height=150, so no overflow
await waitFor(() => {
expect(results).toContain(false)
})

// Shrink container to 100px, content is 150px, so overflow should be true
await act(async () => {
handleRef.current?.setContainerHeight(100)
})

await waitFor(() => {
expect(results).toContain(true)
})
})
})
194 changes: 194 additions & 0 deletions packages/react/src/hooks/__tests__/useResizeObserver.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,194 @@
import {render, waitFor, act} from '@testing-library/react'
import {useRef, useEffect, useState, useImperativeHandle, forwardRef} from 'react'
import {describe, expect, test} from 'vitest'
import {useResizeObserver, type ResizeObserverEntry} from '../useResizeObserver'

interface TestHandle {
setWidth: (width: number) => void
}

const ResizableComponent = forwardRef<TestHandle, {callback: (entries: ResizeObserverEntry[]) => void}>(
function ResizableComponent({callback}, ref) {
const elementRef = useRef<HTMLDivElement>(null)
const [width, setWidth] = useState(100)
useResizeObserver(callback, elementRef)

useImperativeHandle(ref, () => ({
setWidth,
}))

return <div ref={elementRef} style={{width, height: 100}} data-testid="target" />
},
)

describe('useResizeObserver', () => {
test('fires callback on first observation', async () => {
const callbackEntries: ResizeObserverEntry[][] = []

function TestComponent() {
const ref = useRef<HTMLDivElement>(null)
useResizeObserver(entries => {
callbackEntries.push(entries)
}, ref)
return <div ref={ref} style={{width: 100, height: 100}} data-testid="target" />
}

render(<TestComponent />)

await waitFor(() => {
expect(callbackEntries.length).toBeGreaterThan(0)
})
})

test('fires callback when element resizes', async () => {
const callbackEntries: ResizeObserverEntry[][] = []
const handleRef = {current: null as TestHandle | null}

function TestComponent() {
const ref = useRef<TestHandle>(null)

useEffect(() => {
handleRef.current = ref.current
})

return (
<ResizableComponent
ref={ref}
callback={entries => {
callbackEntries.push(entries)
}}
/>
)
}

render(<TestComponent />)

await waitFor(() => {
expect(callbackEntries.length).toBe(1)
})

await act(async () => {
handleRef.current?.setWidth(200)
})

await waitFor(() => {
expect(callbackEntries.length).toBeGreaterThan(1)
})

const lastEntry = callbackEntries[callbackEntries.length - 1][0]
expect(lastEntry.contentRect.width).toBe(200)
})

test('uses document.documentElement as default target', async () => {
const callbackEntries: ResizeObserverEntry[][] = []

function TestComponent() {
useResizeObserver(entries => {
callbackEntries.push(entries)
})
return null
}

render(<TestComponent />)

await waitFor(() => {
expect(callbackEntries.length).toBeGreaterThan(0)
})
})

test('observes provided ref as target', async () => {
const callbackEntries: ResizeObserverEntry[][] = []

function TestComponent() {
const ref = useRef<HTMLDivElement>(null)
useResizeObserver(entries => {
callbackEntries.push(entries)
}, ref)
return <div ref={ref} style={{width: 150, height: 75}} data-testid="target" />
}

render(<TestComponent />)

await waitFor(() => {
expect(callbackEntries.length).toBeGreaterThan(0)
})

const entry = callbackEntries[0][0]
expect(entry.contentRect.width).toBe(150)
expect(entry.contentRect.height).toBe(75)
})

test('uses latest callback when it changes', async () => {
const callback1Entries: ResizeObserverEntry[][] = []
const callback2Entries: ResizeObserverEntry[][] = []

function TestComponent({callback, width}: {callback: (entries: ResizeObserverEntry[]) => void; width: number}) {
const ref = useRef<HTMLDivElement>(null)
useResizeObserver(callback, ref)
return <div ref={ref} style={{width, height: 100}} data-testid="target" />
}

const {rerender} = render(<TestComponent callback={entries => callback1Entries.push(entries)} width={100} />)

await waitFor(() => {
expect(callback1Entries.length).toBeGreaterThan(0)
})

// Update callback and trigger resize
rerender(<TestComponent callback={entries => callback2Entries.push(entries)} width={200} />)

await waitFor(() => {
expect(callback2Entries.length).toBeGreaterThan(0)
})
})

test('re-observes when depsArray changes', async () => {
const callbackEntries: ResizeObserverEntry[][] = []

function TestComponent({dep}: {dep: number}) {
const ref = useRef<HTMLDivElement>(null)
useResizeObserver(
entries => {
callbackEntries.push(entries)
},
ref,
[dep],
)
return <div ref={ref} style={{width: 100, height: 100}} data-testid="target" />
}

const {rerender} = render(<TestComponent dep={1} />)

await waitFor(() => {
expect(callbackEntries.length).toBeGreaterThan(0)
})

const initialCallCount = callbackEntries.length

rerender(<TestComponent dep={2} />)

await waitFor(() => {
expect(callbackEntries.length).toBeGreaterThan(initialCallCount)
})
})

test('does not fire callback when ref is null', async () => {
const callbackEntries: ResizeObserverEntry[][] = []

function TestComponent() {
const ref = useRef<HTMLDivElement>(null)
useResizeObserver(entries => {
callbackEntries.push(entries)
}, ref)
// Don't attach ref to any element
return null
}

render(<TestComponent />)

// Wait a bit to ensure no callbacks fire
await new Promise(resolve => setTimeout(resolve, 100))

expect(callbackEntries.length).toBe(0)
})
})
Loading
Loading