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
15 changes: 15 additions & 0 deletions src/useMeasure/index.dom.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -146,4 +146,19 @@ describe('useMeasure', () => {
const value = expectResultValue(result);
expect(value[0]).toStrictEqual({width: 9, height: 7});
});

it('should observe the requested box model', async () => {
const div = document.createElement('div');
await renderHook(() => {
const measure = useMeasure<HTMLDivElement>(true, borderBoxMeasurer, 'border-box');

useEffect(() => {
measure[1].current = div;
});

return measure;
});

expect(observeSpy).toHaveBeenCalledWith(div, {box: 'border-box'});
});
});
6 changes: 5 additions & 1 deletion src/useMeasure/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,10 +41,14 @@ export const borderBoxMeasurer: Measurer = (entry) => {
*
* @param enabled Whether resize observer is enabled or not.
* @param measurer Derives measures from the observer entry, `contentBoxMeasurer` by default.
* @param box Box model whose changes trigger a re-measurement. Pair it with the measurer:
* `borderBoxMeasurer` under the default `content-box` observation misses padding and border
* changes that leave the content box intact.
*/
export function useMeasure<T extends Element>(
enabled = true,
measurer: Measurer = contentBoxMeasurer,
box: ResizeObserverBoxOptions = 'content-box',
): [Measures | undefined, RefObject<T | null>] {
const [element, setElement] = useState<T | null>(null);
const elementRef = useHookableRef<T | null>(null, (v) => {
Expand All @@ -58,7 +62,7 @@ export function useMeasure<T extends Element>(
setMeasures(measurer(entry));
});

useResizeObserver(element, observerHandler, enabled);
useResizeObserver(element, observerHandler, enabled, box);

return [measures, elementRef];
}
25 changes: 24 additions & 1 deletion src/useResizeObserver/index.dom.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -183,7 +183,7 @@ describe('useResizeObserver', () => {
});

expect(observeSpy).toHaveBeenCalledTimes(1);
expect(observeSpy).toHaveBeenCalledWith(div);
expect(observeSpy).toHaveBeenCalledWith(div, {box: 'content-box'});
expect(unobserveSpy).toHaveBeenCalledTimes(0);

await unmount();
Expand All @@ -193,6 +193,29 @@ describe('useResizeObserver', () => {
expect(unobserveSpy).toHaveBeenCalledWith(div);
});

it('should observe the requested box model', async () => {
const div = document.createElement('div');
await renderHook(() => {
useResizeObserver(div, vi.fn(), true, 'border-box');
});

expect(observeSpy).toHaveBeenCalledWith(div, {box: 'border-box'});
});

it('should keep a separate observer per box model', async () => {
const div = document.createElement('div');
const observersBefore = ResizeObserverSpy.mock.calls.length;

await renderHook(() => {
useResizeObserver(div, vi.fn(), true, 'device-pixel-content-box');
});

// A ResizeObserver only reports changes of the box it observes, so the
// singleton for a box model cannot be reused for another one.
expect(ResizeObserverSpy.mock.calls.length).toBe(observersBefore + 1);
expect(observeSpy).toHaveBeenCalledWith(div, {box: 'device-pixel-content-box'});
});

describe('disabled observer', () => {
it('should not subscribe in case observer is disabled', async () => {
const div = document.createElement('div');
Expand Down
21 changes: 15 additions & 6 deletions src/useResizeObserver/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,13 +11,17 @@ type ResizeObserverSingleton = {
unsubscribe: (target: Element, callback: UseResizeObserverCallback) => void;
};

let observerSingleton: ResizeObserverSingleton | undefined;
// One observer per box model: a ResizeObserver only notifies on changes of the
// box it was asked to observe, so boxes cannot share an instance.
const observerSingletons = new Map<ResizeObserverBoxOptions, ResizeObserverSingleton>();

function getResizeObserver(): ResizeObserverSingleton | undefined {
function getResizeObserver(box: ResizeObserverBoxOptions): ResizeObserverSingleton | undefined {
if (!isBrowser) {
return undefined;
}

const observerSingleton = observerSingletons.get(box);

if (observerSingleton) {
return observerSingleton;
}
Expand All @@ -39,7 +43,7 @@ function getResizeObserver(): ResizeObserverSingleton | undefined {
}
});

observerSingleton = {
const singleton: ResizeObserverSingleton = {
observer,
subscribe(target, callback) {
let cbs = callbacks.get(target);
Expand All @@ -48,7 +52,7 @@ function getResizeObserver(): ResizeObserverSingleton | undefined {
// If target has no observers yet - register it
cbs = new Set<UseResizeObserverCallback>();
callbacks.set(target, cbs);
observer.observe(target);
observer.observe(target, {box});
}

// As Set is duplicate-safe - simply add callback on each call
Expand All @@ -73,7 +77,9 @@ function getResizeObserver(): ResizeObserverSingleton | undefined {
},
};

return observerSingleton;
observerSingletons.set(box, singleton);

return singleton;
}

/**
Expand All @@ -82,13 +88,16 @@ function getResizeObserver(): ResizeObserverSingleton | undefined {
* @param target React reference or Element to track.
* @param callback Callback that will be invoked on resize.
* @param enabled Whether resize observer is enabled or not.
* @param box Box model whose changes trigger the callback. A `content-box` observer stays silent
* when padding or border grow around an unchanged content box, and vice versa.
*/
export function useResizeObserver<T extends Element>(
target: RefObject<T | null> | T | null,
callback: UseResizeObserverCallback,
enabled = true,
box: ResizeObserverBoxOptions = 'content-box',
): void {
const ro = enabled && getResizeObserver();
const ro = enabled && getResizeObserver(box);
const cb = useSyncedRef(callback);

const tgt = target && 'current' in target ? target.current : target;
Expand Down