diff --git a/.changeset/perf-use-resize-observer.md b/.changeset/perf-use-resize-observer.md new file mode 100644 index 00000000000..3b438799daf --- /dev/null +++ b/.changeset/perf-use-resize-observer.md @@ -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 diff --git a/packages/react/src/hooks/__tests__/useOverflow.test.tsx b/packages/react/src/hooks/__tests__/useOverflow.test.tsx new file mode 100644 index 00000000000..2557355fff4 --- /dev/null +++ b/packages/react/src/hooks/__tests__/useOverflow.test.tsx @@ -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 void}>( + function OverflowContainer({onOverflowChange}, ref) { + const containerRef = useRef(null) + const [containerHeight, setContainerHeight] = useState(200) + const hasOverflow = useOverflow(containerRef) + + useEffect(() => { + onOverflowChange(hasOverflow) + }, [hasOverflow, onOverflowChange]) + + useImperativeHandle(ref, () => ({ + setContainerHeight, + })) + + return ( +
+
Content
+
+ ) + }, +) + +describe('useOverflow', () => { + test('returns false when element has no overflow', async () => { + const results: boolean[] = [] + + function TestComponent() { + const ref = useRef(null) + const hasOverflow = useOverflow(ref) + + useEffect(() => { + results.push(hasOverflow) + }, [hasOverflow]) + + return ( +
+
Small content
+
+ ) + } + + render() + + 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(null) + const hasOverflow = useOverflow(ref) + + useEffect(() => { + results.push(hasOverflow) + }, [hasOverflow]) + + return ( +
+
Tall content
+
+ ) + } + + render() + + await waitFor(() => { + expect(results).toContain(true) + }) + }) + + test('returns true when element has horizontal overflow', async () => { + const results: boolean[] = [] + + function TestComponent() { + const ref = useRef(null) + const hasOverflow = useOverflow(ref) + + useEffect(() => { + results.push(hasOverflow) + }, [hasOverflow]) + + return ( +
+
Wide content
+
+ ) + } + + render() + + await waitFor(() => { + expect(results).toContain(true) + }) + }) + + test('returns false when ref.current is null', async () => { + const results: boolean[] = [] + + function TestComponent() { + const ref = useRef(null) + const hasOverflow = useOverflow(ref) + + useEffect(() => { + results.push(hasOverflow) + }, [hasOverflow]) + + return null + } + + render() + + 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(null) + + useEffect(() => { + handleRef.current = ref.current + }) + + return ( + { + results.push(hasOverflow) + }} + /> + ) + } + + render() + + // 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) + }) + }) +}) diff --git a/packages/react/src/hooks/__tests__/useResizeObserver.test.tsx b/packages/react/src/hooks/__tests__/useResizeObserver.test.tsx new file mode 100644 index 00000000000..774c7ecb125 --- /dev/null +++ b/packages/react/src/hooks/__tests__/useResizeObserver.test.tsx @@ -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 void}>( + function ResizableComponent({callback}, ref) { + const elementRef = useRef(null) + const [width, setWidth] = useState(100) + useResizeObserver(callback, elementRef) + + useImperativeHandle(ref, () => ({ + setWidth, + })) + + return
+ }, +) + +describe('useResizeObserver', () => { + test('fires callback on first observation', async () => { + const callbackEntries: ResizeObserverEntry[][] = [] + + function TestComponent() { + const ref = useRef(null) + useResizeObserver(entries => { + callbackEntries.push(entries) + }, ref) + return
+ } + + render() + + 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(null) + + useEffect(() => { + handleRef.current = ref.current + }) + + return ( + { + callbackEntries.push(entries) + }} + /> + ) + } + + render() + + 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() + + await waitFor(() => { + expect(callbackEntries.length).toBeGreaterThan(0) + }) + }) + + test('observes provided ref as target', async () => { + const callbackEntries: ResizeObserverEntry[][] = [] + + function TestComponent() { + const ref = useRef(null) + useResizeObserver(entries => { + callbackEntries.push(entries) + }, ref) + return
+ } + + render() + + 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(null) + useResizeObserver(callback, ref) + return
+ } + + const {rerender} = render( callback1Entries.push(entries)} width={100} />) + + await waitFor(() => { + expect(callback1Entries.length).toBeGreaterThan(0) + }) + + // Update callback and trigger resize + rerender( 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(null) + useResizeObserver( + entries => { + callbackEntries.push(entries) + }, + ref, + [dep], + ) + return
+ } + + const {rerender} = render() + + await waitFor(() => { + expect(callbackEntries.length).toBeGreaterThan(0) + }) + + const initialCallCount = callbackEntries.length + + rerender() + + 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(null) + useResizeObserver(entries => { + callbackEntries.push(entries) + }, ref) + // Don't attach ref to any element + return null + } + + render() + + // Wait a bit to ensure no callbacks fire + await new Promise(resolve => setTimeout(resolve, 100)) + + expect(callbackEntries.length).toBe(0) + }) +}) diff --git a/packages/react/src/hooks/useOverflow.ts b/packages/react/src/hooks/useOverflow.ts index 0c394d095e5..34e8359bee2 100644 --- a/packages/react/src/hooks/useOverflow.ts +++ b/packages/react/src/hooks/useOverflow.ts @@ -8,20 +8,52 @@ export function useOverflow(ref: React.RefObject) { return } - const observer = new ResizeObserver(entries => { + // Track whether this is the first callback (fires immediately on observe()) + let isFirstCallback = true + let pendingFrame: number | null = null + let latestEntries: ResizeObserverEntry[] | null = null + + const checkOverflow = (entries: ResizeObserverEntry[]) => { for (const entry of entries) { if ( entry.target.scrollHeight > entry.target.clientHeight || entry.target.scrollWidth > entry.target.clientWidth ) { setHasOverflow(true) - break + return } } + setHasOverflow(false) + } + + const observer = new ResizeObserver(entries => { + // First callback must be immediate - ResizeObserver fires synchronously + // on observe() and consumers may depend on this timing + if (isFirstCallback) { + isFirstCallback = false + checkOverflow(entries) + return + } + + // Subsequent callbacks are throttled to reduce layout thrashing + // during rapid resize events (e.g., window drag) + latestEntries = entries + if (pendingFrame === null) { + pendingFrame = requestAnimationFrame(() => { + pendingFrame = null + if (latestEntries) { + checkOverflow(latestEntries) + latestEntries = null + } + }) + } }) observer.observe(ref.current) return () => { + if (pendingFrame !== null) { + cancelAnimationFrame(pendingFrame) + } observer.disconnect() } }, [ref]) diff --git a/packages/react/src/hooks/useResizeObserver.ts b/packages/react/src/hooks/useResizeObserver.ts index 704673af082..80b07689d9e 100644 --- a/packages/react/src/hooks/useResizeObserver.ts +++ b/packages/react/src/hooks/useResizeObserver.ts @@ -28,13 +28,40 @@ export function useResizeObserver( } if (typeof ResizeObserver === 'function') { + // Track whether this is the first callback (fires immediately on observe()) + let isFirstCallback = true + let pendingFrame: number | null = null + let latestEntries: ResizeObserverEntry[] | null = null + const observer = new ResizeObserver(entries => { - savedCallback.current(entries) + // First callback must be immediate - ResizeObserver fires synchronously + // on observe() and consumers may depend on this timing + if (isFirstCallback) { + isFirstCallback = false + savedCallback.current(entries) + return + } + + // Subsequent callbacks are throttled to reduce layout thrashing + // during rapid resize events (e.g., window drag) + latestEntries = entries + if (pendingFrame === null) { + pendingFrame = requestAnimationFrame(() => { + pendingFrame = null + if (latestEntries) { + savedCallback.current(latestEntries) + latestEntries = null + } + }) + } }) observer.observe(targetEl) return () => { + if (pendingFrame !== null) { + cancelAnimationFrame(pendingFrame) + } observer.disconnect() } } else {