I had this issue while trying to use ResizeObserver
. Ended up using callback reference observeRef
that I pass back from the custom hook which does the observing.
import { useState, useCallback, useRef } from 'react';
export interface ElementDimensions {
contentWidth: number;
contentHeight: number;
clientWidth: number;
clientHeight: number;
scrollWidth: number;
scrollHeight: number;
isOverflowingX: boolean;
isOverflowingY: boolean;
isOverflowing: boolean;
}
export interface UseResizeObserverResponse {
observeRef: (target: HTMLElement) => void;
dimensions: ElementDimensions;
}
/**
* @returns ref to pass to the target element, ElementDimensions
*/
export const useResizeObserver = (): UseResizeObserverResponse => {
const [dimensions, setDimensions] = useState<ElementDimensions>({} as ElementDimensions);
const observer = useRef<ResizeObserver | null>(null); // we only need one observer instance
const element = useRef<HTMLElement | null>(null);
const handleResize = useCallback((entries: ResizeObserverEntry[]) => {
if (!Array.isArray(entries)) {
return;
}
const entry = entries[0];
const newDimensions: ElementDimensions = {
contentWidth: entry.contentRect.width,
contentHeight: entry.contentRect.height,
clientWidth: entry.target.clientWidth,
clientHeight: entry.target.clientHeight,
scrollWidth: entry.target.scrollWidth,
scrollHeight: entry.target.scrollHeight,
isOverflowingX: entry.target.clientWidth < entry.target.scrollWidth,
isOverflowingY: entry.target.clientHeight < entry.target.scrollHeight,
// compute once on access then replace the getter with result
get isOverflowing() {
delete this.isOverflowing;
return (this.isOverflowing = this.isOverflowingX || this.isOverflowingY);
},
};
setDimensions(newDimensions);
}, []);
// initialize resize observer
const observeRef = useCallback(
(target: HTMLElement) => {
// the callback ref fires often without a target, so only process when we have a target
if (!target) {
return;
}
// instantiate a new observer if needed
if (!observer.current) {
observer.current = new ResizeObserver((entries) => handleResize(entries));
}
// monitor the new element with cleanup of the old element
if (element.current !== target) {
element.current && observer.current?.disconnect(); // call disconnect if monitoring old element
observer.current.observe(target);
element.current = target;
}
},
[handleResize]
);
return { observeRef, dimensions };
};
Use it
const { observeRef, dimensions } = useResizeObserver();
console.log('Observed dimensions: ', dimensions);
return <div ref={observeRef} >Observe me</div>;
Tests
import { act, renderHook } from '@testing-library/react';
import { useResizeObserver } from './useResizeObserver';
describe('useResizeObserver', () => {
let listener: ResizeObserverCallback;
let mockObserverInstance: ResizeObserver;
beforeEach(() => {
mockObserverInstance = {
observe: jest.fn(),
unobserve: jest.fn(),
disconnect: jest.fn(),
};
window.ResizeObserver = class MockResizeObserver {
public constructor(ls: ResizeObserverCallback) {
listener = ls;
}
public observe(elem: HTMLElement) {
mockObserverInstance.observe(elem);
}
public disconnect() {
mockObserverInstance.disconnect();
}
} as typeof ResizeObserver;
});
afterEach(() => {
listener = undefined as unknown as ResizeObserverCallback;
mockObserverInstance = undefined as unknown as ResizeObserver;
window.ResizeObserver = undefined as unknown as typeof ResizeObserver;
});
it('should return a callback ref', () => {
const { result } = renderHook(() => useResizeObserver());
expect(result.current).toEqual({ observeRef: expect.any(Function), dimensions: {} });
expect(mockObserverInstance.observe).not.toHaveBeenCalled();
});
it('should synchronously set up ResizeObserver listener', () => {
const { result } = renderHook(() => useResizeObserver());
expect(listener).toBeUndefined();
act(() => {
const div = document.createElement('div');
result.current.observeRef(div);
});
expect(typeof listener).toBe('function');
});
it('should monitor element target with observer', () => {
const { result } = renderHook(() => useResizeObserver());
expect(result.current).toEqual({ observeRef: expect.any(Function), dimensions: {} });
expect(mockObserverInstance.observe).not.toHaveBeenCalled();
const elem: HTMLElement = document.createElement('div');
result.current.observeRef(elem);
expect(mockObserverInstance.observe).toHaveBeenCalledWith(elem);
});
it('should stop monitoring old element when new element is provided', () => {
const { result } = renderHook(() => useResizeObserver());
expect(result.current).toEqual({ observeRef: expect.any(Function), dimensions: {} });
expect(mockObserverInstance.observe).not.toHaveBeenCalled();
const elem: HTMLElement = document.createElement('div');
result.current.observeRef(elem);
expect(mockObserverInstance.observe).toHaveBeenCalledWith(elem);
expect(mockObserverInstance.disconnect).not.toHaveBeenCalled();
const elem2: HTMLElement = document.createElement('span');
result.current.observeRef(elem2);
expect(mockObserverInstance.disconnect).toHaveBeenCalled();
expect(mockObserverInstance.observe).toHaveBeenCalledWith(elem2);
});
it('should not update dimensions when no entries provided to listener', () => {
const { result } = renderHook(() => useResizeObserver());
let div: HTMLElement;
act(() => {
div = document.createElement('div');
result.current.observeRef(div);
});
act(() => {
listener(undefined as unknown as ResizeObserverEntry[], {} as ResizeObserver);
});
expect(result.current.dimensions).toMatchObject({});
});
it('should track rectangle of a DOM element', () => {
const { result } = renderHook(() => useResizeObserver());
let div: HTMLElement;
act(() => {
div = document.createElement('div');
result.current.observeRef(div);
});
act(() => {
listener(
[
{
target: div,
contentRect: {
width: 200,
height: 200,
},
} as unknown as ResizeObserverEntry,
],
{} as ResizeObserver
);
});
const expectedDimensions = {
clientHeight: 0,
clientWidth: 0,
contentHeight: 200,
contentWidth: 200,
isOverflowing: false,
isOverflowingX: false,
isOverflowingY: false,
scrollHeight: 0,
scrollWidth: 0,
};
expect(result.current.dimensions).toMatchObject(expectedDimensions);
});
it('should compute overflow of target DOM element', () => {
const { result } = renderHook(() => useResizeObserver());
let div: HTMLElement;
act(() => {
div = document.createElement('div');
result.current.observeRef(div);
});
act(() => {
listener(
[
{
target: {
clientHeight: 100,
clientWidth: 100,
scrollWidth: 200,
scrollHeight: 100,
},
contentRect: {
width: 100,
height: 100,
},
} as unknown as ResizeObserverEntry,
],
{} as ResizeObserver
);
});
const expectedDimensions = {
clientHeight: 100,
clientWidth: 100,
contentHeight: 100,
contentWidth: 100,
isOverflowing: true,
isOverflowingX: true,
isOverflowingY: false,
scrollHeight: 100,
scrollWidth: 200,
};
expect(result.current.dimensions).toMatchObject(expectedDimensions);
act(() => {
listener(
[
{
target: {
clientHeight: 100,
clientWidth: 100,
scrollWidth: 100,
scrollHeight: 200,
},
contentRect: {
width: 100,
height: 100,
},
} as unknown as ResizeObserverEntry,
],
{} as ResizeObserver
);
});
const expectedDimensions2 = {
clientHeight: 100,
clientWidth: 100,
contentHeight: 100,
contentWidth: 100,
isOverflowing: true,
isOverflowingX: false,
isOverflowingY: true,
scrollHeight: 200,
scrollWidth: 100,
};
expect(result.current.dimensions).toMatchObject(expectedDimensions2);
});
});
ref.current
, is it possible to still miss some ref updates when it is only used to reference a DOM? – Iloilo