From 1c120578568c6045cec1dcfcb0ecdbded8426cfe Mon Sep 17 00:00:00 2001 From: Matthew Costabile Date: Mon, 15 Dec 2025 14:57:16 +0000 Subject: [PATCH 1/3] perf(hooks): Add first-immediate throttling to useResizeObserver and useOverflow - useResizeObserver fires callback immediately on first observation, then throttles with rAF - useOverflow uses the same pattern to avoid initial flash of incorrect overflow state - Added isFirstCallback ref pattern to skip throttling on initial mount Part of #7312 --- .changeset/perf-use-resize-observer.md | 9 +++++ packages/react/src/hooks/useOverflow.ts | 35 +++++++++++++++++-- packages/react/src/hooks/useResizeObserver.ts | 28 ++++++++++++++- 3 files changed, 69 insertions(+), 3 deletions(-) create mode 100644 .changeset/perf-use-resize-observer.md 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/useOverflow.ts b/packages/react/src/hooks/useOverflow.ts index 0c394d095e5..a2a8c014612 100644 --- a/packages/react/src/hooks/useOverflow.ts +++ b/packages/react/src/hooks/useOverflow.ts @@ -8,20 +8,51 @@ 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) + } + }) + } }) 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..8f7082d9c9d 100644 --- a/packages/react/src/hooks/useResizeObserver.ts +++ b/packages/react/src/hooks/useResizeObserver.ts @@ -28,13 +28,39 @@ 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) + } + }) + } }) observer.observe(targetEl) return () => { + if (pendingFrame !== null) { + cancelAnimationFrame(pendingFrame) + } observer.disconnect() } } else { From 9c91b8d9d9ce8aa0a56d3d506c6676e1cb1404f0 Mon Sep 17 00:00:00 2001 From: Matthew Costabile Date: Tue, 16 Dec 2025 19:13:13 +0000 Subject: [PATCH 2/3] update throttling behavior --- .../src/hooks/__tests__/useOverflow.test.tsx | 389 +++++++++++++++++ .../__tests__/useResizeObserver.test.tsx | 404 ++++++++++++++++++ packages/react/src/hooks/useOverflow.ts | 1 + packages/react/src/hooks/useResizeObserver.ts | 1 + 4 files changed, 795 insertions(+) create mode 100644 packages/react/src/hooks/__tests__/useOverflow.test.tsx create mode 100644 packages/react/src/hooks/__tests__/useResizeObserver.test.tsx 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..25720e7fd60 --- /dev/null +++ b/packages/react/src/hooks/__tests__/useOverflow.test.tsx @@ -0,0 +1,389 @@ +import {render, act} from '@testing-library/react' +import {useRef, useEffect} from 'react' +import {describe, expect, test, vi, beforeEach, afterEach} from 'vitest' +import {useOverflow} from '../useOverflow' + +// Mock ResizeObserver +let mockResizeObserverCallback: ResizeObserverCallback | null = null +let mockObservedElements: Element[] = [] + +class MockResizeObserver { + callback: ResizeObserverCallback + + constructor(callback: ResizeObserverCallback) { + this.callback = callback + mockResizeObserverCallback = callback + } + + observe(target: Element) { + mockObservedElements.push(target) + // ResizeObserver fires immediately on observe() with initial dimensions + this.callback( + [ + { + target, + contentRect: target.getBoundingClientRect(), + borderBoxSize: [], + contentBoxSize: [], + devicePixelContentBoxSize: [], + }, + ], + this, + ) + } + + unobserve(target: Element) { + mockObservedElements = mockObservedElements.filter(el => el !== target) + } + + disconnect() { + mockObservedElements = [] + } +} + +// Helper to trigger resize events on an element +function triggerResizeOnElement(target: Element, hasOverflow: boolean) { + if (mockResizeObserverCallback) { + // Mock the target's scroll dimensions + Object.defineProperty(target, 'scrollHeight', { + value: hasOverflow ? 200 : 100, + configurable: true, + }) + Object.defineProperty(target, 'clientHeight', { + value: 100, + configurable: true, + }) + Object.defineProperty(target, 'scrollWidth', { + value: hasOverflow ? 200 : 100, + configurable: true, + }) + Object.defineProperty(target, 'clientWidth', { + value: 100, + configurable: true, + }) + + mockResizeObserverCallback( + [ + { + target, + contentRect: target.getBoundingClientRect(), + borderBoxSize: [], + contentBoxSize: [], + devicePixelContentBoxSize: [], + }, + ], + {} as ResizeObserver, + ) + } +} + +describe('useOverflow', () => { + let originalResizeObserver: typeof ResizeObserver + let rafCallbacks: Array + let rafIdCounter: number + + beforeEach(() => { + // Store original and replace with mock + originalResizeObserver = globalThis.ResizeObserver + globalThis.ResizeObserver = MockResizeObserver as unknown as typeof ResizeObserver + + // Mock requestAnimationFrame + rafCallbacks = [] + rafIdCounter = 0 + vi.spyOn(globalThis, 'requestAnimationFrame').mockImplementation((callback: FrameRequestCallback) => { + rafCallbacks.push(callback) + return ++rafIdCounter + }) + vi.spyOn(globalThis, 'cancelAnimationFrame').mockImplementation((_id: number) => { + // Track the call + }) + + mockResizeObserverCallback = null + mockObservedElements = [] + }) + + afterEach(() => { + globalThis.ResizeObserver = originalResizeObserver + vi.restoreAllMocks() + }) + + // Helper to flush all pending animation frames + function flushAnimationFrames() { + const callbacks = [...rafCallbacks] + rafCallbacks = [] + for (const cb of callbacks) { + cb(performance.now()) + } + } + + // Test component that reports results via callback + function TestComponent({ + onResult, + onRef, + }: { + onResult?: (result: boolean) => void + onRef?: (element: HTMLDivElement | null) => void + }) { + const ref = useRef(null) + const result = useOverflow(ref) + + useEffect(() => { + onResult?.(result) + }, [result, onResult]) + + useEffect(() => { + onRef?.(ref.current) + }, [onRef]) + + return
+ } + + test('returns false when element has no overflow', () => { + const results: boolean[] = [] + + render( results.push(result)} />) + + expect(results).toContain(false) + }) + + test('checks overflow immediately on first observation', () => { + const results: boolean[] = [] + + render( results.push(result)} />) + + // Initial check happens immediately on observe + expect(results.length).toBeGreaterThan(0) + }) + + test('throttles subsequent overflow checks using requestAnimationFrame', () => { + let targetElement: HTMLDivElement | null = null + + render( (targetElement = el)} />) + + // First observation is immediate, no rAF yet + expect(requestAnimationFrame).toHaveBeenCalledTimes(0) + + // Trigger multiple rapid resize events + act(() => { + if (targetElement) { + triggerResizeOnElement(targetElement, false) + triggerResizeOnElement(targetElement, true) + triggerResizeOnElement(targetElement, false) + } + }) + + // requestAnimationFrame should have been called only once for throttling + expect(requestAnimationFrame).toHaveBeenCalledTimes(1) + }) + + test('processes latest entries after throttling', () => { + const results: boolean[] = [] + let targetElement: HTMLDivElement | null = null + + const {rerender} = render( + results.push(result)} onRef={el => (targetElement = el)} />, + ) + + // Initial state + expect(results).toContain(false) + + // Trigger multiple resize events with different overflow states + act(() => { + if (targetElement) { + triggerResizeOnElement(targetElement, false) // First: no overflow + triggerResizeOnElement(targetElement, true) // Second: has overflow + triggerResizeOnElement(targetElement, true) // Third: has overflow (latest) + } + }) + + // Flush animation frame + act(() => { + flushAnimationFrames() + }) + + // After flushing, should have the latest state (overflow) + rerender( results.push(result)} onRef={el => (targetElement = el)} />) + expect(results[results.length - 1]).toBe(true) + }) + + test('cancels pending animation frames on cleanup', () => { + let targetElement: HTMLDivElement | null = null + + const {unmount} = render( (targetElement = el)} />) + + // Trigger a resize to schedule an animation frame + act(() => { + if (targetElement) { + triggerResizeOnElement(targetElement, true) + } + }) + + expect(requestAnimationFrame).toHaveBeenCalledTimes(1) + + // Unmount before the animation frame executes + unmount() + + // cancelAnimationFrame should have been called + expect(cancelAnimationFrame).toHaveBeenCalledTimes(1) + expect(cancelAnimationFrame).toHaveBeenCalledWith(1) + }) + + test('does not cancel animation frame if none pending', () => { + const {unmount} = render() + + // No resize events triggered after initial observation + expect(requestAnimationFrame).toHaveBeenCalledTimes(0) + + unmount() + + // No cancelAnimationFrame should have been called + expect(cancelAnimationFrame).not.toHaveBeenCalled() + }) + + test('schedules new animation frame after previous one completes', () => { + let targetElement: HTMLDivElement | null = null + + render( (targetElement = el)} />) + + // First resize event + act(() => { + if (targetElement) { + triggerResizeOnElement(targetElement, true) + } + }) + + expect(requestAnimationFrame).toHaveBeenCalledTimes(1) + + // Complete the animation frame + act(() => { + flushAnimationFrames() + }) + + // Second resize event + act(() => { + if (targetElement) { + triggerResizeOnElement(targetElement, false) + } + }) + + // New animation frame should be scheduled + expect(requestAnimationFrame).toHaveBeenCalledTimes(2) + }) + + test('detects vertical overflow (scrollHeight > clientHeight)', () => { + const results: boolean[] = [] + let targetElement: HTMLDivElement | null = null + + const {rerender} = render( + results.push(result)} onRef={el => (targetElement = el)} />, + ) + + // Set up vertical overflow + act(() => { + if (targetElement) { + Object.defineProperty(targetElement, 'scrollHeight', {value: 200, configurable: true}) + Object.defineProperty(targetElement, 'clientHeight', {value: 100, configurable: true}) + Object.defineProperty(targetElement, 'scrollWidth', {value: 100, configurable: true}) + Object.defineProperty(targetElement, 'clientWidth', {value: 100, configurable: true}) + triggerResizeOnElement(targetElement, true) + } + }) + + act(() => { + flushAnimationFrames() + }) + + rerender( results.push(result)} onRef={el => (targetElement = el)} />) + expect(results[results.length - 1]).toBe(true) + }) + + test('detects horizontal overflow (scrollWidth > clientWidth)', () => { + const results: boolean[] = [] + let targetElement: HTMLDivElement | null = null + + const {rerender} = render( + results.push(result)} onRef={el => (targetElement = el)} />, + ) + + // Set up horizontal overflow + act(() => { + if (targetElement) { + Object.defineProperty(targetElement, 'scrollHeight', {value: 100, configurable: true}) + Object.defineProperty(targetElement, 'clientHeight', {value: 100, configurable: true}) + Object.defineProperty(targetElement, 'scrollWidth', {value: 200, configurable: true}) + Object.defineProperty(targetElement, 'clientWidth', {value: 100, configurable: true}) + triggerResizeOnElement(targetElement, true) + } + }) + + act(() => { + flushAnimationFrames() + }) + + rerender( results.push(result)} onRef={el => (targetElement = el)} />) + expect(results[results.length - 1]).toBe(true) + }) + + test('returns false when ref.current is null', () => { + const results: boolean[] = [] + + function NullRefComponent({onResult}: {onResult: (result: boolean) => void}) { + const ref = useRef(null) + const result = useOverflow(ref) + + useEffect(() => { + onResult(result) + }, [result, onResult]) + + // Don't render anything with the ref + return null + } + + render( results.push(result)} />) + + expect(results[0]).toBe(false) + expect(mockObservedElements).toHaveLength(0) + }) + + test('clears latestEntries after processing to avoid memory leaks', () => { + const results: boolean[] = [] + let targetElement: HTMLDivElement | null = null + + const {rerender} = render( + results.push(result)} onRef={el => (targetElement = el)} />, + ) + + // First resize event + act(() => { + if (targetElement) { + Object.defineProperty(targetElement, 'scrollHeight', {value: 200, configurable: true}) + Object.defineProperty(targetElement, 'clientHeight', {value: 100, configurable: true}) + triggerResizeOnElement(targetElement, true) + } + }) + + act(() => { + flushAnimationFrames() + }) + + rerender( results.push(result)} onRef={el => (targetElement = el)} />) + expect(results[results.length - 1]).toBe(true) + + // Second resize event with different state + act(() => { + if (targetElement) { + Object.defineProperty(targetElement, 'scrollHeight', {value: 100, configurable: true}) + Object.defineProperty(targetElement, 'clientHeight', {value: 100, configurable: true}) + triggerResizeOnElement(targetElement, false) + } + }) + + act(() => { + flushAnimationFrames() + }) + + rerender( results.push(result)} onRef={el => (targetElement = el)} />) + // Should get the new state, not cached from before + expect(results[results.length - 1]).toBe(false) + }) +}) 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..82a5c9e16e4 --- /dev/null +++ b/packages/react/src/hooks/__tests__/useResizeObserver.test.tsx @@ -0,0 +1,404 @@ +import {render, act} from '@testing-library/react' +import {useRef, useEffect} from 'react' +import {describe, expect, test, vi, beforeEach, afterEach} from 'vitest' +import {useResizeObserver} from '../useResizeObserver' + +// Mock ResizeObserver +let mockResizeObserverCallback: ResizeObserverCallback | null = null +let mockObservedElements: Element[] = [] + +class MockResizeObserver { + callback: ResizeObserverCallback + + constructor(callback: ResizeObserverCallback) { + this.callback = callback + mockResizeObserverCallback = callback + } + + observe(target: Element) { + mockObservedElements.push(target) + // ResizeObserver fires immediately on observe() with initial dimensions + this.callback( + [ + { + target, + contentRect: target.getBoundingClientRect(), + borderBoxSize: [], + contentBoxSize: [], + devicePixelContentBoxSize: [], + }, + ], + this, + ) + } + + unobserve(target: Element) { + mockObservedElements = mockObservedElements.filter(el => el !== target) + } + + disconnect() { + mockObservedElements = [] + } +} + +// Helper to trigger resize events +function triggerResize(entries: ResizeObserverEntry[]) { + if (mockResizeObserverCallback) { + mockResizeObserverCallback(entries, {} as ResizeObserver) + } +} + +// Helper to create mock resize entries +function createMockEntry(width: number, height: number, target?: Element): ResizeObserverEntry { + return { + target: target || document.createElement('div'), + contentRect: { + width, + height, + top: 0, + left: 0, + bottom: height, + right: width, + x: 0, + y: 0, + toJSON: () => ({}), + }, + borderBoxSize: [], + contentBoxSize: [], + devicePixelContentBoxSize: [], + } +} + +describe('useResizeObserver', () => { + let originalResizeObserver: typeof ResizeObserver + let rafCallbacks: Array + let rafIdCounter: number + + beforeEach(() => { + // Store original and replace with mock + originalResizeObserver = globalThis.ResizeObserver + globalThis.ResizeObserver = MockResizeObserver as unknown as typeof ResizeObserver + + // Mock requestAnimationFrame + rafCallbacks = [] + rafIdCounter = 0 + vi.spyOn(globalThis, 'requestAnimationFrame').mockImplementation((callback: FrameRequestCallback) => { + rafCallbacks.push(callback) + return ++rafIdCounter + }) + vi.spyOn(globalThis, 'cancelAnimationFrame').mockImplementation((_id: number) => { + // In a real implementation, we'd remove the callback, but for tests we just track the call + }) + + mockResizeObserverCallback = null + mockObservedElements = [] + }) + + afterEach(() => { + globalThis.ResizeObserver = originalResizeObserver + vi.restoreAllMocks() + }) + + // Helper to flush all pending animation frames + function flushAnimationFrames() { + const callbacks = [...rafCallbacks] + rafCallbacks = [] + for (const cb of callbacks) { + cb(performance.now()) + } + } + + test('fires callback immediately on first observation', () => { + const callback = vi.fn() + + function TestComponent() { + useResizeObserver(callback) + return null + } + + render() + + // Callback should have been called immediately (synchronously) on observe + expect(callback).toHaveBeenCalledTimes(1) + // Should have been called with entries containing contentRect + expect(callback.mock.calls[0][0][0]).toHaveProperty('contentRect') + }) + + test('fires callback immediately for target ref on first observation', () => { + const callback = vi.fn() + + function TestComponent() { + const ref = useRef(null) + useResizeObserver(callback, ref) + return
+ } + + render() + + // Callback should have been called immediately + expect(callback).toHaveBeenCalledTimes(1) + }) + + test('throttles subsequent callbacks using requestAnimationFrame', () => { + const callback = vi.fn() + + function TestComponent() { + useResizeObserver(callback) + return null + } + + render() + + // First callback was immediate + expect(callback).toHaveBeenCalledTimes(1) + callback.mockClear() + + // Trigger multiple rapid resize events + act(() => { + triggerResize([createMockEntry(100, 100)]) + triggerResize([createMockEntry(200, 200)]) + triggerResize([createMockEntry(300, 300)]) + }) + + // Callback should not have been called yet (throttled) + expect(callback).toHaveBeenCalledTimes(0) + + // requestAnimationFrame should have been called once + expect(requestAnimationFrame).toHaveBeenCalledTimes(1) + + // Flush the animation frame + act(() => { + flushAnimationFrames() + }) + + // Now callback should have been called once with the latest entries + expect(callback).toHaveBeenCalledTimes(1) + expect(callback.mock.calls[0][0][0].contentRect.width).toBe(300) + expect(callback.mock.calls[0][0][0].contentRect.height).toBe(300) + }) + + test('processes latest entries after throttling, not stale ones', () => { + const callback = vi.fn() + + function TestComponent() { + useResizeObserver(callback) + return null + } + + render() + callback.mockClear() + + // Trigger first resize event + act(() => { + triggerResize([createMockEntry(100, 100)]) + }) + + // Before rAF executes, trigger more resize events + act(() => { + triggerResize([createMockEntry(150, 150)]) + triggerResize([createMockEntry(200, 200)]) + }) + + // Only one rAF should be pending + expect(requestAnimationFrame).toHaveBeenCalledTimes(1) + + // Flush and verify we get the latest entry + act(() => { + flushAnimationFrames() + }) + + expect(callback).toHaveBeenCalledTimes(1) + expect(callback.mock.calls[0][0][0].contentRect.width).toBe(200) + }) + + test('cancels pending animation frames on cleanup', () => { + const callback = vi.fn() + + function TestComponent() { + useResizeObserver(callback) + return null + } + + const {unmount} = render() + callback.mockClear() + + // Trigger a resize to schedule an animation frame + act(() => { + triggerResize([createMockEntry(100, 100)]) + }) + + expect(requestAnimationFrame).toHaveBeenCalledTimes(1) + + // Unmount before the animation frame executes + unmount() + + // cancelAnimationFrame should have been called + expect(cancelAnimationFrame).toHaveBeenCalledTimes(1) + expect(cancelAnimationFrame).toHaveBeenCalledWith(1) // First rAF ID + }) + + test('does not cancel animation frame on cleanup if none pending', () => { + const callback = vi.fn() + + function TestComponent() { + useResizeObserver(callback) + return null + } + + const {unmount} = render() + + // Only the initial immediate callback, no rAF scheduled + expect(requestAnimationFrame).toHaveBeenCalledTimes(0) + + unmount() + + // No cancelAnimationFrame should have been called + expect(cancelAnimationFrame).not.toHaveBeenCalled() + }) + + test('schedules new animation frame after previous one completes', () => { + const callback = vi.fn() + + function TestComponent() { + useResizeObserver(callback) + return null + } + + render() + callback.mockClear() + + // First batch of resize events + act(() => { + triggerResize([createMockEntry(100, 100)]) + }) + + expect(requestAnimationFrame).toHaveBeenCalledTimes(1) + + // Complete the animation frame + act(() => { + flushAnimationFrames() + }) + + expect(callback).toHaveBeenCalledTimes(1) + + // Second batch of resize events + act(() => { + triggerResize([createMockEntry(200, 200)]) + }) + + // New animation frame should be scheduled + expect(requestAnimationFrame).toHaveBeenCalledTimes(2) + + act(() => { + flushAnimationFrames() + }) + + expect(callback).toHaveBeenCalledTimes(2) + }) + + test('uses document.documentElement as default target', () => { + const callback = vi.fn() + + function TestComponent() { + useResizeObserver(callback) + return null + } + + render() + + expect(mockObservedElements).toContain(document.documentElement) + }) + + test('uses provided ref as target', () => { + const callback = vi.fn() + let targetElement: HTMLDivElement | null = null + + function TestComponent() { + const ref = useRef(null) + useResizeObserver(callback, ref) + + useEffect(() => { + targetElement = ref.current + }) + + return
+ } + + render() + + expect(mockObservedElements).toContain(targetElement) + }) + + test('updates savedCallback ref when callback changes', () => { + const callback1 = vi.fn() + const callback2 = vi.fn() + + function TestComponent({callback}: {callback: (entries: ResizeObserverEntry[]) => void}) { + useResizeObserver(callback) + return null + } + + const {rerender} = render() + + // First callback should have been called + expect(callback1).toHaveBeenCalledTimes(1) + expect(callback2).toHaveBeenCalledTimes(0) + + callback1.mockClear() + + // Update callback + rerender() + + // Trigger resize + act(() => { + triggerResize([createMockEntry(100, 100)]) + }) + + act(() => { + flushAnimationFrames() + }) + + // New callback should be called + expect(callback1).toHaveBeenCalledTimes(0) + expect(callback2).toHaveBeenCalledTimes(1) + }) + + test('clears latestEntries after processing to avoid memory leaks', () => { + // This test verifies the fix for the memory leak issue + // We can't directly observe latestEntries, but we can verify behavior + const callback = vi.fn() + + function TestComponent() { + useResizeObserver(callback) + return null + } + + render() + callback.mockClear() + + // Trigger resize + act(() => { + triggerResize([createMockEntry(100, 100)]) + }) + + // Flush animation frame + act(() => { + flushAnimationFrames() + }) + + expect(callback).toHaveBeenCalledTimes(1) + + // Trigger another resize and flush immediately + act(() => { + triggerResize([createMockEntry(200, 200)]) + }) + + act(() => { + flushAnimationFrames() + }) + + // Should get the new entry, not the old one + expect(callback).toHaveBeenCalledTimes(2) + expect(callback.mock.calls[1][0][0].contentRect.width).toBe(200) + }) +}) diff --git a/packages/react/src/hooks/useOverflow.ts b/packages/react/src/hooks/useOverflow.ts index a2a8c014612..34e8359bee2 100644 --- a/packages/react/src/hooks/useOverflow.ts +++ b/packages/react/src/hooks/useOverflow.ts @@ -43,6 +43,7 @@ export function useOverflow(ref: React.RefObject) { pendingFrame = null if (latestEntries) { checkOverflow(latestEntries) + latestEntries = null } }) } diff --git a/packages/react/src/hooks/useResizeObserver.ts b/packages/react/src/hooks/useResizeObserver.ts index 8f7082d9c9d..80b07689d9e 100644 --- a/packages/react/src/hooks/useResizeObserver.ts +++ b/packages/react/src/hooks/useResizeObserver.ts @@ -50,6 +50,7 @@ export function useResizeObserver( pendingFrame = null if (latestEntries) { savedCallback.current(latestEntries) + latestEntries = null } }) } From 45f06d2226a8e9c281b54f773cf389e1ec31d06b Mon Sep 17 00:00:00 2001 From: Matthew Costabile Date: Tue, 16 Dec 2025 19:33:26 +0000 Subject: [PATCH 3/3] avoid mcoking in tests --- .../src/hooks/__tests__/useOverflow.test.tsx | 443 +++++------------- .../__tests__/useResizeObserver.test.tsx | 440 +++++------------ 2 files changed, 227 insertions(+), 656 deletions(-) diff --git a/packages/react/src/hooks/__tests__/useOverflow.test.tsx b/packages/react/src/hooks/__tests__/useOverflow.test.tsx index 25720e7fd60..2557355fff4 100644 --- a/packages/react/src/hooks/__tests__/useOverflow.test.tsx +++ b/packages/react/src/hooks/__tests__/useOverflow.test.tsx @@ -1,389 +1,170 @@ -import {render, act} from '@testing-library/react' -import {useRef, useEffect} from 'react' -import {describe, expect, test, vi, beforeEach, afterEach} from 'vitest' +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' -// Mock ResizeObserver -let mockResizeObserverCallback: ResizeObserverCallback | null = null -let mockObservedElements: Element[] = [] - -class MockResizeObserver { - callback: ResizeObserverCallback - - constructor(callback: ResizeObserverCallback) { - this.callback = callback - mockResizeObserverCallback = callback - } - - observe(target: Element) { - mockObservedElements.push(target) - // ResizeObserver fires immediately on observe() with initial dimensions - this.callback( - [ - { - target, - contentRect: target.getBoundingClientRect(), - borderBoxSize: [], - contentBoxSize: [], - devicePixelContentBoxSize: [], - }, - ], - this, - ) - } - - unobserve(target: Element) { - mockObservedElements = mockObservedElements.filter(el => el !== target) - } - - disconnect() { - mockObservedElements = [] - } +interface TestHandle { + setContainerHeight: (height: number) => void } -// Helper to trigger resize events on an element -function triggerResizeOnElement(target: Element, hasOverflow: boolean) { - if (mockResizeObserverCallback) { - // Mock the target's scroll dimensions - Object.defineProperty(target, 'scrollHeight', { - value: hasOverflow ? 200 : 100, - configurable: true, - }) - Object.defineProperty(target, 'clientHeight', { - value: 100, - configurable: true, - }) - Object.defineProperty(target, 'scrollWidth', { - value: hasOverflow ? 200 : 100, - configurable: true, - }) - Object.defineProperty(target, 'clientWidth', { - value: 100, - configurable: true, - }) - - mockResizeObserverCallback( - [ - { - target, - contentRect: target.getBoundingClientRect(), - borderBoxSize: [], - contentBoxSize: [], - devicePixelContentBoxSize: [], - }, - ], - {} as ResizeObserver, - ) - } -} - -describe('useOverflow', () => { - let originalResizeObserver: typeof ResizeObserver - let rafCallbacks: Array - let rafIdCounter: number - - beforeEach(() => { - // Store original and replace with mock - originalResizeObserver = globalThis.ResizeObserver - globalThis.ResizeObserver = MockResizeObserver as unknown as typeof ResizeObserver - - // Mock requestAnimationFrame - rafCallbacks = [] - rafIdCounter = 0 - vi.spyOn(globalThis, 'requestAnimationFrame').mockImplementation((callback: FrameRequestCallback) => { - rafCallbacks.push(callback) - return ++rafIdCounter - }) - vi.spyOn(globalThis, 'cancelAnimationFrame').mockImplementation((_id: number) => { - // Track the call - }) - - mockResizeObserverCallback = null - mockObservedElements = [] - }) - - afterEach(() => { - globalThis.ResizeObserver = originalResizeObserver - vi.restoreAllMocks() - }) - - // Helper to flush all pending animation frames - function flushAnimationFrames() { - const callbacks = [...rafCallbacks] - rafCallbacks = [] - for (const cb of callbacks) { - cb(performance.now()) - } - } - - // Test component that reports results via callback - function TestComponent({ - onResult, - onRef, - }: { - onResult?: (result: boolean) => void - onRef?: (element: HTMLDivElement | null) => void - }) { - const ref = useRef(null) - const result = useOverflow(ref) +const OverflowContainer = forwardRef void}>( + function OverflowContainer({onOverflowChange}, ref) { + const containerRef = useRef(null) + const [containerHeight, setContainerHeight] = useState(200) + const hasOverflow = useOverflow(containerRef) useEffect(() => { - onResult?.(result) - }, [result, onResult]) + onOverflowChange(hasOverflow) + }, [hasOverflow, onOverflowChange]) - useEffect(() => { - onRef?.(ref.current) - }, [onRef]) + useImperativeHandle(ref, () => ({ + setContainerHeight, + })) - return
- } - - test('returns false when element has no overflow', () => { - const results: boolean[] = [] - - render( results.push(result)} />) - - expect(results).toContain(false) - }) + return ( +
+
Content
+
+ ) + }, +) - test('checks overflow immediately on first observation', () => { +describe('useOverflow', () => { + test('returns false when element has no overflow', async () => { const results: boolean[] = [] - render( results.push(result)} />) - - // Initial check happens immediately on observe - expect(results.length).toBeGreaterThan(0) - }) - - test('throttles subsequent overflow checks using requestAnimationFrame', () => { - let targetElement: HTMLDivElement | null = null + function TestComponent() { + const ref = useRef(null) + const hasOverflow = useOverflow(ref) - render( (targetElement = el)} />) + useEffect(() => { + results.push(hasOverflow) + }, [hasOverflow]) + + return ( +
+
Small content
+
+ ) + } - // First observation is immediate, no rAF yet - expect(requestAnimationFrame).toHaveBeenCalledTimes(0) + render() - // Trigger multiple rapid resize events - act(() => { - if (targetElement) { - triggerResizeOnElement(targetElement, false) - triggerResizeOnElement(targetElement, true) - triggerResizeOnElement(targetElement, false) - } + await waitFor(() => { + expect(results.length).toBeGreaterThan(0) }) - // requestAnimationFrame should have been called only once for throttling - expect(requestAnimationFrame).toHaveBeenCalledTimes(1) + expect(results[results.length - 1]).toBe(false) }) - test('processes latest entries after throttling', () => { + test('returns true when element has vertical overflow', async () => { const results: boolean[] = [] - let targetElement: HTMLDivElement | null = null - - const {rerender} = render( - results.push(result)} onRef={el => (targetElement = el)} />, - ) - - // Initial state - expect(results).toContain(false) - - // Trigger multiple resize events with different overflow states - act(() => { - if (targetElement) { - triggerResizeOnElement(targetElement, false) // First: no overflow - triggerResizeOnElement(targetElement, true) // Second: has overflow - triggerResizeOnElement(targetElement, true) // Third: has overflow (latest) - } - }) - - // Flush animation frame - act(() => { - flushAnimationFrames() - }) - - // After flushing, should have the latest state (overflow) - rerender( results.push(result)} onRef={el => (targetElement = el)} />) - expect(results[results.length - 1]).toBe(true) - }) - - test('cancels pending animation frames on cleanup', () => { - let targetElement: HTMLDivElement | null = null - const {unmount} = render( (targetElement = el)} />) - - // Trigger a resize to schedule an animation frame - act(() => { - if (targetElement) { - triggerResizeOnElement(targetElement, true) - } - }) - - expect(requestAnimationFrame).toHaveBeenCalledTimes(1) - - // Unmount before the animation frame executes - unmount() - - // cancelAnimationFrame should have been called - expect(cancelAnimationFrame).toHaveBeenCalledTimes(1) - expect(cancelAnimationFrame).toHaveBeenCalledWith(1) - }) - - test('does not cancel animation frame if none pending', () => { - const {unmount} = render() - - // No resize events triggered after initial observation - expect(requestAnimationFrame).toHaveBeenCalledTimes(0) - - unmount() - - // No cancelAnimationFrame should have been called - expect(cancelAnimationFrame).not.toHaveBeenCalled() - }) - - test('schedules new animation frame after previous one completes', () => { - let targetElement: HTMLDivElement | null = null - - render( (targetElement = el)} />) + function TestComponent() { + const ref = useRef(null) + const hasOverflow = useOverflow(ref) - // First resize event - act(() => { - if (targetElement) { - triggerResizeOnElement(targetElement, true) - } - }) + useEffect(() => { + results.push(hasOverflow) + }, [hasOverflow]) + + return ( +
+
Tall content
+
+ ) + } - expect(requestAnimationFrame).toHaveBeenCalledTimes(1) + render() - // Complete the animation frame - act(() => { - flushAnimationFrames() + await waitFor(() => { + expect(results).toContain(true) }) - - // Second resize event - act(() => { - if (targetElement) { - triggerResizeOnElement(targetElement, false) - } - }) - - // New animation frame should be scheduled - expect(requestAnimationFrame).toHaveBeenCalledTimes(2) }) - test('detects vertical overflow (scrollHeight > clientHeight)', () => { + test('returns true when element has horizontal overflow', async () => { const results: boolean[] = [] - let targetElement: HTMLDivElement | null = null - const {rerender} = render( - results.push(result)} onRef={el => (targetElement = el)} />, - ) - - // Set up vertical overflow - act(() => { - if (targetElement) { - Object.defineProperty(targetElement, 'scrollHeight', {value: 200, configurable: true}) - Object.defineProperty(targetElement, 'clientHeight', {value: 100, configurable: true}) - Object.defineProperty(targetElement, 'scrollWidth', {value: 100, configurable: true}) - Object.defineProperty(targetElement, 'clientWidth', {value: 100, configurable: true}) - triggerResizeOnElement(targetElement, true) - } - }) - - act(() => { - flushAnimationFrames() - }) - - rerender( results.push(result)} onRef={el => (targetElement = el)} />) - expect(results[results.length - 1]).toBe(true) - }) - - test('detects horizontal overflow (scrollWidth > clientWidth)', () => { - const results: boolean[] = [] - let targetElement: HTMLDivElement | null = null + function TestComponent() { + const ref = useRef(null) + const hasOverflow = useOverflow(ref) - const {rerender} = render( - results.push(result)} onRef={el => (targetElement = el)} />, - ) + useEffect(() => { + results.push(hasOverflow) + }, [hasOverflow]) + + return ( +
+
Wide content
+
+ ) + } - // Set up horizontal overflow - act(() => { - if (targetElement) { - Object.defineProperty(targetElement, 'scrollHeight', {value: 100, configurable: true}) - Object.defineProperty(targetElement, 'clientHeight', {value: 100, configurable: true}) - Object.defineProperty(targetElement, 'scrollWidth', {value: 200, configurable: true}) - Object.defineProperty(targetElement, 'clientWidth', {value: 100, configurable: true}) - triggerResizeOnElement(targetElement, true) - } - }) + render() - act(() => { - flushAnimationFrames() + await waitFor(() => { + expect(results).toContain(true) }) - - rerender( results.push(result)} onRef={el => (targetElement = el)} />) - expect(results[results.length - 1]).toBe(true) }) - test('returns false when ref.current is null', () => { + test('returns false when ref.current is null', async () => { const results: boolean[] = [] - function NullRefComponent({onResult}: {onResult: (result: boolean) => void}) { + function TestComponent() { const ref = useRef(null) - const result = useOverflow(ref) + const hasOverflow = useOverflow(ref) useEffect(() => { - onResult(result) - }, [result, onResult]) + results.push(hasOverflow) + }, [hasOverflow]) - // Don't render anything with the ref return null } - render( results.push(result)} />) + render() + + await waitFor(() => { + expect(results.length).toBeGreaterThan(0) + }) expect(results[0]).toBe(false) - expect(mockObservedElements).toHaveLength(0) }) - test('clears latestEntries after processing to avoid memory leaks', () => { + test('updates when overflow state changes', async () => { const results: boolean[] = [] - let targetElement: HTMLDivElement | null = null + const handleRef = {current: null as TestHandle | null} - const {rerender} = render( - results.push(result)} onRef={el => (targetElement = el)} />, - ) - - // First resize event - act(() => { - if (targetElement) { - Object.defineProperty(targetElement, 'scrollHeight', {value: 200, configurable: true}) - Object.defineProperty(targetElement, 'clientHeight', {value: 100, configurable: true}) - triggerResizeOnElement(targetElement, true) - } - }) + function TestComponent() { + const ref = useRef(null) - act(() => { - flushAnimationFrames() - }) + useEffect(() => { + handleRef.current = ref.current + }) + + return ( + { + results.push(hasOverflow) + }} + /> + ) + } - rerender( results.push(result)} onRef={el => (targetElement = el)} />) - expect(results[results.length - 1]).toBe(true) + render() - // Second resize event with different state - act(() => { - if (targetElement) { - Object.defineProperty(targetElement, 'scrollHeight', {value: 100, configurable: true}) - Object.defineProperty(targetElement, 'clientHeight', {value: 100, configurable: true}) - triggerResizeOnElement(targetElement, false) - } + // Initially containerHeight=200, content height=150, so no overflow + await waitFor(() => { + expect(results).toContain(false) }) - act(() => { - flushAnimationFrames() + // Shrink container to 100px, content is 150px, so overflow should be true + await act(async () => { + handleRef.current?.setContainerHeight(100) }) - rerender( results.push(result)} onRef={el => (targetElement = el)} />) - // Should get the new state, not cached from before - expect(results[results.length - 1]).toBe(false) + 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 index 82a5c9e16e4..774c7ecb125 100644 --- a/packages/react/src/hooks/__tests__/useResizeObserver.test.tsx +++ b/packages/react/src/hooks/__tests__/useResizeObserver.test.tsx @@ -1,404 +1,194 @@ -import {render, act} from '@testing-library/react' -import {useRef, useEffect} from 'react' -import {describe, expect, test, vi, beforeEach, afterEach} from 'vitest' -import {useResizeObserver} from '../useResizeObserver' - -// Mock ResizeObserver -let mockResizeObserverCallback: ResizeObserverCallback | null = null -let mockObservedElements: Element[] = [] - -class MockResizeObserver { - callback: ResizeObserverCallback - - constructor(callback: ResizeObserverCallback) { - this.callback = callback - mockResizeObserverCallback = callback - } - - observe(target: Element) { - mockObservedElements.push(target) - // ResizeObserver fires immediately on observe() with initial dimensions - this.callback( - [ - { - target, - contentRect: target.getBoundingClientRect(), - borderBoxSize: [], - contentBoxSize: [], - devicePixelContentBoxSize: [], - }, - ], - this, - ) - } - - unobserve(target: Element) { - mockObservedElements = mockObservedElements.filter(el => el !== target) - } - - disconnect() { - mockObservedElements = [] - } -} +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' -// Helper to trigger resize events -function triggerResize(entries: ResizeObserverEntry[]) { - if (mockResizeObserverCallback) { - mockResizeObserverCallback(entries, {} as ResizeObserver) - } +interface TestHandle { + setWidth: (width: number) => void } -// Helper to create mock resize entries -function createMockEntry(width: number, height: number, target?: Element): ResizeObserverEntry { - return { - target: target || document.createElement('div'), - contentRect: { - width, - height, - top: 0, - left: 0, - bottom: height, - right: width, - x: 0, - y: 0, - toJSON: () => ({}), - }, - borderBoxSize: [], - contentBoxSize: [], - devicePixelContentBoxSize: [], - } -} +const ResizableComponent = forwardRef void}>( + function ResizableComponent({callback}, ref) { + const elementRef = useRef(null) + const [width, setWidth] = useState(100) + useResizeObserver(callback, elementRef) -describe('useResizeObserver', () => { - let originalResizeObserver: typeof ResizeObserver - let rafCallbacks: Array - let rafIdCounter: number - - beforeEach(() => { - // Store original and replace with mock - originalResizeObserver = globalThis.ResizeObserver - globalThis.ResizeObserver = MockResizeObserver as unknown as typeof ResizeObserver - - // Mock requestAnimationFrame - rafCallbacks = [] - rafIdCounter = 0 - vi.spyOn(globalThis, 'requestAnimationFrame').mockImplementation((callback: FrameRequestCallback) => { - rafCallbacks.push(callback) - return ++rafIdCounter - }) - vi.spyOn(globalThis, 'cancelAnimationFrame').mockImplementation((_id: number) => { - // In a real implementation, we'd remove the callback, but for tests we just track the call - }) + useImperativeHandle(ref, () => ({ + setWidth, + })) - mockResizeObserverCallback = null - mockObservedElements = [] - }) + return
+ }, +) - afterEach(() => { - globalThis.ResizeObserver = originalResizeObserver - vi.restoreAllMocks() - }) - - // Helper to flush all pending animation frames - function flushAnimationFrames() { - const callbacks = [...rafCallbacks] - rafCallbacks = [] - for (const cb of callbacks) { - cb(performance.now()) - } - } - - test('fires callback immediately on first observation', () => { - const callback = vi.fn() - - function TestComponent() { - useResizeObserver(callback) - return null - } - - render() - - // Callback should have been called immediately (synchronously) on observe - expect(callback).toHaveBeenCalledTimes(1) - // Should have been called with entries containing contentRect - expect(callback.mock.calls[0][0][0]).toHaveProperty('contentRect') - }) - - test('fires callback immediately for target ref on first observation', () => { - const callback = vi.fn() +describe('useResizeObserver', () => { + test('fires callback on first observation', async () => { + const callbackEntries: ResizeObserverEntry[][] = [] function TestComponent() { const ref = useRef(null) - useResizeObserver(callback, ref) - return
+ useResizeObserver(entries => { + callbackEntries.push(entries) + }, ref) + return
} render() - // Callback should have been called immediately - expect(callback).toHaveBeenCalledTimes(1) + await waitFor(() => { + expect(callbackEntries.length).toBeGreaterThan(0) + }) }) - test('throttles subsequent callbacks using requestAnimationFrame', () => { - const callback = vi.fn() + test('fires callback when element resizes', async () => { + const callbackEntries: ResizeObserverEntry[][] = [] + const handleRef = {current: null as TestHandle | null} function TestComponent() { - useResizeObserver(callback) - return null - } + const ref = useRef(null) - render() - - // First callback was immediate - expect(callback).toHaveBeenCalledTimes(1) - callback.mockClear() - - // Trigger multiple rapid resize events - act(() => { - triggerResize([createMockEntry(100, 100)]) - triggerResize([createMockEntry(200, 200)]) - triggerResize([createMockEntry(300, 300)]) - }) - - // Callback should not have been called yet (throttled) - expect(callback).toHaveBeenCalledTimes(0) - - // requestAnimationFrame should have been called once - expect(requestAnimationFrame).toHaveBeenCalledTimes(1) - - // Flush the animation frame - act(() => { - flushAnimationFrames() - }) - - // Now callback should have been called once with the latest entries - expect(callback).toHaveBeenCalledTimes(1) - expect(callback.mock.calls[0][0][0].contentRect.width).toBe(300) - expect(callback.mock.calls[0][0][0].contentRect.height).toBe(300) - }) - - test('processes latest entries after throttling, not stale ones', () => { - const callback = vi.fn() + useEffect(() => { + handleRef.current = ref.current + }) - function TestComponent() { - useResizeObserver(callback) - return null + return ( + { + callbackEntries.push(entries) + }} + /> + ) } render() - callback.mockClear() - // Trigger first resize event - act(() => { - triggerResize([createMockEntry(100, 100)]) + await waitFor(() => { + expect(callbackEntries.length).toBe(1) }) - // Before rAF executes, trigger more resize events - act(() => { - triggerResize([createMockEntry(150, 150)]) - triggerResize([createMockEntry(200, 200)]) + await act(async () => { + handleRef.current?.setWidth(200) }) - // Only one rAF should be pending - expect(requestAnimationFrame).toHaveBeenCalledTimes(1) - - // Flush and verify we get the latest entry - act(() => { - flushAnimationFrames() + await waitFor(() => { + expect(callbackEntries.length).toBeGreaterThan(1) }) - expect(callback).toHaveBeenCalledTimes(1) - expect(callback.mock.calls[0][0][0].contentRect.width).toBe(200) + const lastEntry = callbackEntries[callbackEntries.length - 1][0] + expect(lastEntry.contentRect.width).toBe(200) }) - test('cancels pending animation frames on cleanup', () => { - const callback = vi.fn() + test('uses document.documentElement as default target', async () => { + const callbackEntries: ResizeObserverEntry[][] = [] function TestComponent() { - useResizeObserver(callback) + useResizeObserver(entries => { + callbackEntries.push(entries) + }) return null } - const {unmount} = render() - callback.mockClear() + render() - // Trigger a resize to schedule an animation frame - act(() => { - triggerResize([createMockEntry(100, 100)]) + await waitFor(() => { + expect(callbackEntries.length).toBeGreaterThan(0) }) - - expect(requestAnimationFrame).toHaveBeenCalledTimes(1) - - // Unmount before the animation frame executes - unmount() - - // cancelAnimationFrame should have been called - expect(cancelAnimationFrame).toHaveBeenCalledTimes(1) - expect(cancelAnimationFrame).toHaveBeenCalledWith(1) // First rAF ID - }) - - test('does not cancel animation frame on cleanup if none pending', () => { - const callback = vi.fn() - - function TestComponent() { - useResizeObserver(callback) - return null - } - - const {unmount} = render() - - // Only the initial immediate callback, no rAF scheduled - expect(requestAnimationFrame).toHaveBeenCalledTimes(0) - - unmount() - - // No cancelAnimationFrame should have been called - expect(cancelAnimationFrame).not.toHaveBeenCalled() }) - test('schedules new animation frame after previous one completes', () => { - const callback = vi.fn() + test('observes provided ref as target', async () => { + const callbackEntries: ResizeObserverEntry[][] = [] function TestComponent() { - useResizeObserver(callback) - return null + const ref = useRef(null) + useResizeObserver(entries => { + callbackEntries.push(entries) + }, ref) + return
} render() - callback.mockClear() - - // First batch of resize events - act(() => { - triggerResize([createMockEntry(100, 100)]) - }) - expect(requestAnimationFrame).toHaveBeenCalledTimes(1) - - // Complete the animation frame - act(() => { - flushAnimationFrames() + await waitFor(() => { + expect(callbackEntries.length).toBeGreaterThan(0) }) - expect(callback).toHaveBeenCalledTimes(1) - - // Second batch of resize events - act(() => { - triggerResize([createMockEntry(200, 200)]) - }) - - // New animation frame should be scheduled - expect(requestAnimationFrame).toHaveBeenCalledTimes(2) - - act(() => { - flushAnimationFrames() - }) - - expect(callback).toHaveBeenCalledTimes(2) - }) - - test('uses document.documentElement as default target', () => { - const callback = vi.fn() - - function TestComponent() { - useResizeObserver(callback) - return null - } - - render() - - expect(mockObservedElements).toContain(document.documentElement) + const entry = callbackEntries[0][0] + expect(entry.contentRect.width).toBe(150) + expect(entry.contentRect.height).toBe(75) }) - test('uses provided ref as target', () => { - const callback = vi.fn() - let targetElement: HTMLDivElement | null = null + test('uses latest callback when it changes', async () => { + const callback1Entries: ResizeObserverEntry[][] = [] + const callback2Entries: ResizeObserverEntry[][] = [] - function TestComponent() { + function TestComponent({callback, width}: {callback: (entries: ResizeObserverEntry[]) => void; width: number}) { const ref = useRef(null) useResizeObserver(callback, ref) + return
+ } - useEffect(() => { - targetElement = ref.current - }) + const {rerender} = render( callback1Entries.push(entries)} width={100} />) - return
- } + await waitFor(() => { + expect(callback1Entries.length).toBeGreaterThan(0) + }) - render() + // Update callback and trigger resize + rerender( callback2Entries.push(entries)} width={200} />) - expect(mockObservedElements).toContain(targetElement) + await waitFor(() => { + expect(callback2Entries.length).toBeGreaterThan(0) + }) }) - test('updates savedCallback ref when callback changes', () => { - const callback1 = vi.fn() - const callback2 = vi.fn() + test('re-observes when depsArray changes', async () => { + const callbackEntries: ResizeObserverEntry[][] = [] - function TestComponent({callback}: {callback: (entries: ResizeObserverEntry[]) => void}) { - useResizeObserver(callback) - return null + function TestComponent({dep}: {dep: number}) { + const ref = useRef(null) + useResizeObserver( + entries => { + callbackEntries.push(entries) + }, + ref, + [dep], + ) + return
} - const {rerender} = render() - - // First callback should have been called - expect(callback1).toHaveBeenCalledTimes(1) - expect(callback2).toHaveBeenCalledTimes(0) + const {rerender} = render() - callback1.mockClear() + await waitFor(() => { + expect(callbackEntries.length).toBeGreaterThan(0) + }) - // Update callback - rerender() + const initialCallCount = callbackEntries.length - // Trigger resize - act(() => { - triggerResize([createMockEntry(100, 100)]) - }) + rerender() - act(() => { - flushAnimationFrames() + await waitFor(() => { + expect(callbackEntries.length).toBeGreaterThan(initialCallCount) }) - - // New callback should be called - expect(callback1).toHaveBeenCalledTimes(0) - expect(callback2).toHaveBeenCalledTimes(1) }) - test('clears latestEntries after processing to avoid memory leaks', () => { - // This test verifies the fix for the memory leak issue - // We can't directly observe latestEntries, but we can verify behavior - const callback = vi.fn() + test('does not fire callback when ref is null', async () => { + const callbackEntries: ResizeObserverEntry[][] = [] function TestComponent() { - useResizeObserver(callback) + const ref = useRef(null) + useResizeObserver(entries => { + callbackEntries.push(entries) + }, ref) + // Don't attach ref to any element return null } render() - callback.mockClear() - - // Trigger resize - act(() => { - triggerResize([createMockEntry(100, 100)]) - }) - // Flush animation frame - act(() => { - flushAnimationFrames() - }) - - expect(callback).toHaveBeenCalledTimes(1) - - // Trigger another resize and flush immediately - act(() => { - triggerResize([createMockEntry(200, 200)]) - }) - - act(() => { - flushAnimationFrames() - }) + // Wait a bit to ensure no callbacks fire + await new Promise(resolve => setTimeout(resolve, 100)) - // Should get the new entry, not the old one - expect(callback).toHaveBeenCalledTimes(2) - expect(callback.mock.calls[1][0][0].contentRect.width).toBe(200) + expect(callbackEntries.length).toBe(0) }) })