@@ -20,14 +20,13 @@ function useResolvedElement<T extends HTMLElement>(
2020 refOrElement ?: T | RefObject < T > | null
2121) : RefCallback < T > {
2222 const callbackRefElement = useRef < T | null > ( null ) ;
23- const refCallback = useCallback < RefCallback < T > > ( ( element ) => {
24- callbackRefElement . current = element ;
25- callSubscriber ( ) ;
26- } , [ ] ) ;
27- const lastReportedElementRef = useRef < T | null > ( null ) ;
23+ const lastReportRef = useRef < {
24+ reporter : ( ) => void ;
25+ element : T | null ;
26+ } | null > ( null ) ;
2827 const cleanupRef = useRef < SubscriberResponse | null > ( ) ;
2928
30- const callSubscriber = ( ) => {
29+ const callSubscriber = useCallback ( ( ) => {
3130 let element = null ;
3231 if ( callbackRefElement . current ) {
3332 element = callbackRefElement . current ;
@@ -39,7 +38,11 @@ function useResolvedElement<T extends HTMLElement>(
3938 }
4039 }
4140
42- if ( lastReportedElementRef . current === element ) {
41+ if (
42+ lastReportRef . current &&
43+ lastReportRef . current . element === element &&
44+ lastReportRef . current . reporter === callSubscriber
45+ ) {
4346 return ;
4447 }
4548
@@ -48,26 +51,34 @@ function useResolvedElement<T extends HTMLElement>(
4851 // Making sure the cleanup is not called accidentally multiple times.
4952 cleanupRef . current = null ;
5053 }
51- lastReportedElementRef . current = element ;
54+ lastReportRef . current = {
55+ reporter : callSubscriber ,
56+ element,
57+ } ;
5258
5359 // Only calling the subscriber, if there's an actual element to report.
5460 if ( element ) {
5561 cleanupRef . current = subscriber ( element ) ;
5662 }
57- } ;
63+ } , [ refOrElement , subscriber ] ) ;
5864
5965 // On each render, we check whether a ref changed, or if we got a new raw
6066 // element.
6167 useEffect ( ( ) => {
62- // Note that this does not mean that "element" will necessarily be whatever
63- // the ref currently holds. It'll simply "update" `element` each render to
64- // the current ref value, but there's no guarantee that the ref value will
65- // not change later without a render.
66- // This may or may not be a problem depending on the specific use case.
68+ // With this we're *technically* supporting cases where ref objects' current value changes, but only if there's a
69+ // render accompanying that change as well.
70+ // To guarantee we always have the right element, one must use the ref callback provided instead, but we support
71+ // RefObjects to make the hook API more convenient in certain cases.
6772 callSubscriber ( ) ;
68- } , [ refOrElement ] ) ;
73+ } , [ callSubscriber ] ) ;
6974
70- return refCallback ;
75+ return useCallback < RefCallback < T > > (
76+ ( element ) => {
77+ callbackRefElement . current = element ;
78+ callSubscriber ( ) ;
79+ } ,
80+ [ callSubscriber ]
81+ ) ;
7182}
7283
7384type ObservedSize = {
@@ -81,21 +92,95 @@ type HookResponse<T extends HTMLElement> = {
8192 ref : RefCallback < T > ;
8293} & ObservedSize ;
8394
95+ // Declaring my own type here instead of using the one provided by TS (available since 4.2.2), because this way I'm not
96+ // forcing consumers to use a specific TS version.
97+ type ResizeObserverBoxOptions =
98+ | "border-box"
99+ | "content-box"
100+ | "device-pixel-content-box" ;
101+
102+ declare global {
103+ interface ResizeObserverEntry {
104+ readonly devicePixelContentBoxSize : ReadonlyArray < ResizeObserverSize > ;
105+ }
106+ }
107+
108+ // We're only using the first element of the size sequences, until future versions of the spec solidify on how
109+ // exactly it'll be used for fragments in multi-column scenarios:
110+ // From the spec:
111+ // > The box size properties are exposed as FrozenArray in order to support elements that have multiple fragments,
112+ // > which occur in multi-column scenarios. However the current definitions of content rect and border box do not
113+ // > mention how those boxes are affected by multi-column layout. In this spec, there will only be a single
114+ // > ResizeObserverSize returned in the FrozenArray, which will correspond to the dimensions of the first column.
115+ // > A future version of this spec will extend the returned FrozenArray to contain the per-fragment size information.
116+ // (https://drafts.csswg.org/resize-observer/#resize-observer-entry-interface)
117+ //
118+ // Also, testing these new box options revealed that in both Chrome and FF everything is returned in the callback,
119+ // regardless of the "box" option.
120+ // The spec states the following on this:
121+ // > This does not have any impact on which box dimensions are returned to the defined callback when the event
122+ // > is fired, it solely defines which box the author wishes to observe layout changes on.
123+ // (https://drafts.csswg.org/resize-observer/#resize-observer-interface)
124+ // I'm not exactly clear on what this means, especially when you consider a later section stating the following:
125+ // > This section is non-normative. An author may desire to observe more than one CSS box.
126+ // > In this case, author will need to use multiple ResizeObservers.
127+ // (https://drafts.csswg.org/resize-observer/#resize-observer-interface)
128+ // Which is clearly not how current browser implementations behave, and seems to contradict the previous quote.
129+ // For this reason I decided to only return the requested size,
130+ // even though it seems we have access to results for all box types.
131+ // This also means that we get to keep the current api, being able to return a simple { width, height } pair,
132+ // regardless of box option.
133+ const extractSize = (
134+ entry : ResizeObserverEntry ,
135+ boxProp : "borderBoxSize" | "contentBoxSize" | "devicePixelContentBoxSize" ,
136+ sizeType : keyof ResizeObserverSize
137+ ) : number | undefined => {
138+ if ( ! entry [ boxProp ] ) {
139+ if ( boxProp === "contentBoxSize" ) {
140+ // The dimensions in `contentBoxSize` and `contentRect` are equivalent according to the spec.
141+ // See the 6th step in the description for the RO algorithm:
142+ // https://drafts.csswg.org/resize-observer/#create-and-populate-resizeobserverentry-h
143+ // > Set this.contentRect to logical this.contentBoxSize given target and observedBox of "content-box".
144+ // In real browser implementations of course these objects differ, but the width/height values should be equivalent.
145+ return entry . contentRect [ sizeType === "inlineSize" ? "width" : "height" ] ;
146+ }
147+
148+ return undefined ;
149+ }
150+
151+ // A couple bytes smaller than calling Array.isArray() and just as effective here.
152+ return entry [ boxProp ] [ 0 ]
153+ ? entry [ boxProp ] [ 0 ] [ sizeType ]
154+ : // TS complains about this, because the RO entry type follows the spec and does not reflect Firefox's current
155+ // behaviour of returning objects instead of arrays for `borderBoxSize` and `contentBoxSize`.
156+ // @ts -ignore
157+ entry [ boxProp ] [ sizeType ] ;
158+ } ;
159+
160+ type RoundingFunction = ( n : number ) => number ;
161+
84162function useResizeObserver < T extends HTMLElement > (
85163 opts : {
86164 ref ?: RefObject < T > | T | null | undefined ;
87165 onResize ?: ResizeHandler ;
166+ box ?: ResizeObserverBoxOptions ;
167+ round ?: RoundingFunction ;
88168 } = { }
89169) : HookResponse < T > {
90170 // Saving the callback as a ref. With this, I don't need to put onResize in the
91171 // effect dep array, and just passing in an anonymous function without memoising
92- // will not reinstantiate the hook's ResizeObserver
172+ // will not reinstantiate the hook's ResizeObserver.
93173 const onResize = opts . onResize ;
94174 const onResizeRef = useRef < ResizeHandler | undefined > ( undefined ) ;
95175 onResizeRef . current = onResize ;
176+ const round = opts . round || Math . round ;
96177
97178 // Using a single instance throughout the hook's lifetime
98- const resizeObserverRef = useRef < ResizeObserver > ( ) ;
179+ const resizeObserverRef = useRef < {
180+ box ?: ResizeObserverBoxOptions ;
181+ round ?: RoundingFunction ;
182+ instance : ResizeObserver ;
183+ } > ( ) ;
99184
100185 const [ size , setSize ] = useState < {
101186 width ?: number ;
@@ -114,7 +199,7 @@ function useResizeObserver<T extends HTMLElement>(
114199 } ;
115200 } , [ ] ) ;
116201
117- // Using a ref to track the previous width / height to avoid unnecessary renders
202+ // Using a ref to track the previous width / height to avoid unnecessary renders.
118203 const previous : {
119204 current : {
120205 width ?: number ;
@@ -128,46 +213,68 @@ function useResizeObserver<T extends HTMLElement>(
128213 // This block is kinda like a useEffect, only it's called whenever a new
129214 // element could be resolved based on the ref option. It also has a cleanup
130215 // function.
131- const refCallback = useResolvedElement < T > ( ( element ) => {
132- // Initialising the RO instance
133- if ( ! resizeObserverRef . current ) {
134- // Saving a single instance, used by the hook from this point on.
135- resizeObserverRef . current = new ResizeObserver ( ( entries ) => {
136- if ( ! Array . isArray ( entries ) ) {
137- return ;
138- }
139-
140- const entry = entries [ 0 ] ;
141-
142- // `Math.round` is in line with how CSS resolves sub-pixel values
143- const newWidth = Math . round ( entry . contentRect . width ) ;
144- const newHeight = Math . round ( entry . contentRect . height ) ;
216+ const refCallback = useResolvedElement < T > (
217+ useCallback (
218+ ( element ) => {
219+ // We only use a single Resize Observer instance, and we're instantiating it on demand, only once there's something to observe.
220+ // This instance is also recreated when the `box` option changes, so that a new observation is fired if there was a previously observed element with a different box option.
145221 if (
146- previous . current . width !== newWidth ||
147- previous . current . height !== newHeight
222+ ! resizeObserverRef . current ||
223+ resizeObserverRef . current . box !== opts . box ||
224+ resizeObserverRef . current . round !== round
148225 ) {
149- const newSize = { width : newWidth , height : newHeight } ;
150- if ( onResizeRef . current ) {
151- onResizeRef . current ( newSize ) ;
152- } else {
153- previous . current . width = newWidth ;
154- previous . current . height = newHeight ;
155- if ( ! didUnmount . current ) {
156- setSize ( newSize ) ;
157- }
158- }
226+ resizeObserverRef . current = {
227+ box : opts . box ,
228+ round,
229+ instance : new ResizeObserver ( ( entries ) => {
230+ const entry = entries [ 0 ] ;
231+
232+ const boxProp =
233+ opts . box === "border-box"
234+ ? "borderBoxSize"
235+ : opts . box === "device-pixel-content-box"
236+ ? "devicePixelContentBoxSize"
237+ : "contentBoxSize" ;
238+
239+ const reportedWidth = extractSize ( entry , boxProp , "inlineSize" ) ;
240+ const reportedHeight = extractSize ( entry , boxProp , "blockSize" ) ;
241+
242+ const newWidth = reportedWidth ? round ( reportedWidth ) : undefined ;
243+ const newHeight = reportedHeight
244+ ? round ( reportedHeight )
245+ : undefined ;
246+
247+ if (
248+ previous . current . width !== newWidth ||
249+ previous . current . height !== newHeight
250+ ) {
251+ const newSize = { width : newWidth , height : newHeight } ;
252+ previous . current . width = newWidth ;
253+ previous . current . height = newHeight ;
254+ if ( onResizeRef . current ) {
255+ onResizeRef . current ( newSize ) ;
256+ } else {
257+ if ( ! didUnmount . current ) {
258+ setSize ( newSize ) ;
259+ }
260+ }
261+ }
262+ } ) ,
263+ } ;
159264 }
160- } ) ;
161- }
162265
163- resizeObserverRef . current . observe ( element ) ;
266+ resizeObserverRef . current . instance . observe ( element , { box : opts . box } ) ;
164267
165- return ( ) => {
166- if ( resizeObserverRef . current ) {
167- resizeObserverRef . current . unobserve ( element ) ;
168- }
169- } ;
170- } , opts . ref ) ;
268+ return ( ) => {
269+ if ( resizeObserverRef . current ) {
270+ resizeObserverRef . current . instance . unobserve ( element ) ;
271+ }
272+ } ;
273+ } ,
274+ [ opts . box , round ]
275+ ) ,
276+ opts . ref
277+ ) ;
171278
172279 return useMemo (
173280 ( ) => ( {
0 commit comments