From ef74bf4b503458091f84b78c74d7bf546cb47c7b Mon Sep 17 00:00:00 2001 From: Mykola Kolomoyets Date: Sun, 26 Jul 2026 00:35:14 +0200 Subject: [PATCH] feat(useMeasure): accept custom measurer Measures came from the content box only, so consumers laying out in border-box read sizes without paddings and borders. A second parameter takes a measurer deriving `Measures` from the observer entry: `contentBoxMeasurer` stays the default, `borderBoxMeasurer` ships next to it for opt-in border-box sizing. Re-measurement still rides on the shared observer's default box, so a padding-only change that leaves the content box intact emits no new measurement. Closes: #1548 --- src/useMeasure/index.dom.test.ts | 32 +++++++++++++++++++++++++++++++- src/useMeasure/index.ts | 31 +++++++++++++++++++++++++++++-- 2 files changed, 60 insertions(+), 3 deletions(-) diff --git a/src/useMeasure/index.dom.test.ts b/src/useMeasure/index.dom.test.ts index 50a9fa73..a5b953ca 100644 --- a/src/useMeasure/index.dom.test.ts +++ b/src/useMeasure/index.dom.test.ts @@ -1,7 +1,7 @@ import {act, renderHook} from '@ver0/react-hooks-testing'; import {useEffect} from 'react'; import {afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi} from 'vitest'; -import {useMeasure} from '../index.js'; +import {borderBoxMeasurer, useMeasure} from '../index.js'; import {expectResultValue} from '../util/testing/test-helpers.js'; describe('useMeasure', () => { @@ -116,4 +116,34 @@ describe('useMeasure', () => { expect(value[1]).toStrictEqual({current: div}); expect(value[0]).toStrictEqual(measures); }); + + it('should set state by the given measurer', async () => { + const div = document.createElement('div'); + const {result} = await renderHook(() => { + const measure = useMeasure(true, borderBoxMeasurer); + + useEffect(() => { + measure[1].current = div; + }); + + return measure; + }); + + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion + const entry = { + target: div, + contentRect: {width: 5, height: 3}, + borderBoxSize: [{inlineSize: 9, blockSize: 7}], + contentBoxSize: {}, + } as unknown as ResizeObserverEntry; + + ResizeObserverSpy.mock.calls[0][0]([entry]); + + await act(async () => { + vi.advanceTimersByTime(1); + }); + + const value = expectResultValue(result); + expect(value[0]).toStrictEqual({width: 9, height: 7}); + }); }); diff --git a/src/useMeasure/index.ts b/src/useMeasure/index.ts index 871c897c..2db5d5ed 100644 --- a/src/useMeasure/index.ts +++ b/src/useMeasure/index.ts @@ -10,12 +10,39 @@ export type Measures = { height: number; }; +export type Measurer = (entry: ResizeObserverEntry) => Measures; + +/** + * Measures an observed element by the `content-box` sizing model, excluding paddings and borders. + * + * Default measurer of `useMeasure`. + */ +export const contentBoxMeasurer: Measurer = (entry) => ({ + width: entry.contentRect.width, + height: entry.contentRect.height, +}); + +/** + * Measures an observed element by the `border-box` sizing model, including paddings and borders. + * + * Border box sizes are writing-mode relative, so `inlineSize` maps to width and `blockSize` to + * height only in horizontal writing modes. + */ +export const borderBoxMeasurer: Measurer = (entry) => ({ + width: entry.borderBoxSize[0].inlineSize, + height: entry.borderBoxSize[0].blockSize, +}); + /** * Uses ResizeObserver to track element dimensions and re-render component when they change. * * @param enabled Whether resize observer is enabled or not. + * @param measurer Derives measures from the observer entry, `contentBoxMeasurer` by default. */ -export function useMeasure(enabled = true): [Measures | undefined, RefObject] { +export function useMeasure( + enabled = true, + measurer: Measurer = contentBoxMeasurer, +): [Measures | undefined, RefObject] { const [element, setElement] = useState(null); const elementRef = useHookableRef(null, (v) => { setElement(v); @@ -25,7 +52,7 @@ export function useMeasure(enabled = true): [Measures | undef const [measures, setMeasures] = useState(); const [observerHandler] = useRafCallback((entry) => { - setMeasures({width: entry.contentRect.width, height: entry.contentRect.height}); + setMeasures(measurer(entry)); }); useResizeObserver(element, observerHandler, enabled);