@@ -20,14 +20,13 @@ function useResolvedElement<T extends HTMLElement>(
20
20
refOrElement ?: T | RefObject < T > | null
21
21
) : RefCallback < T > {
22
22
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 ) ;
28
27
const cleanupRef = useRef < SubscriberResponse | null > ( ) ;
29
28
30
- const callSubscriber = ( ) => {
29
+ const callSubscriber = useCallback ( ( ) => {
31
30
let element = null ;
32
31
if ( callbackRefElement . current ) {
33
32
element = callbackRefElement . current ;
@@ -39,7 +38,11 @@ function useResolvedElement<T extends HTMLElement>(
39
38
}
40
39
}
41
40
42
- if ( lastReportedElementRef . current === element ) {
41
+ if (
42
+ lastReportRef . current &&
43
+ lastReportRef . current . element === element &&
44
+ lastReportRef . current . reporter === callSubscriber
45
+ ) {
43
46
return ;
44
47
}
45
48
@@ -48,26 +51,34 @@ function useResolvedElement<T extends HTMLElement>(
48
51
// Making sure the cleanup is not called accidentally multiple times.
49
52
cleanupRef . current = null ;
50
53
}
51
- lastReportedElementRef . current = element ;
54
+ lastReportRef . current = {
55
+ reporter : callSubscriber ,
56
+ element,
57
+ } ;
52
58
53
59
// Only calling the subscriber, if there's an actual element to report.
54
60
if ( element ) {
55
61
cleanupRef . current = subscriber ( element ) ;
56
62
}
57
- } ;
63
+ } , [ refOrElement , subscriber ] ) ;
58
64
59
65
// On each render, we check whether a ref changed, or if we got a new raw
60
66
// element.
61
67
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.
67
72
callSubscriber ( ) ;
68
- } , [ refOrElement ] ) ;
73
+ } , [ callSubscriber ] ) ;
69
74
70
- return refCallback ;
75
+ return useCallback < RefCallback < T > > (
76
+ ( element ) => {
77
+ callbackRefElement . current = element ;
78
+ callSubscriber ( ) ;
79
+ } ,
80
+ [ callSubscriber ]
81
+ ) ;
71
82
}
72
83
73
84
type ObservedSize = {
@@ -81,21 +92,95 @@ type HookResponse<T extends HTMLElement> = {
81
92
ref : RefCallback < T > ;
82
93
} & ObservedSize ;
83
94
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
+
84
162
function useResizeObserver < T extends HTMLElement > (
85
163
opts : {
86
164
ref ?: RefObject < T > | T | null | undefined ;
87
165
onResize ?: ResizeHandler ;
166
+ box ?: ResizeObserverBoxOptions ;
167
+ round ?: RoundingFunction ;
88
168
} = { }
89
169
) : HookResponse < T > {
90
170
// Saving the callback as a ref. With this, I don't need to put onResize in the
91
171
// 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.
93
173
const onResize = opts . onResize ;
94
174
const onResizeRef = useRef < ResizeHandler | undefined > ( undefined ) ;
95
175
onResizeRef . current = onResize ;
176
+ const round = opts . round || Math . round ;
96
177
97
178
// 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
+ } > ( ) ;
99
184
100
185
const [ size , setSize ] = useState < {
101
186
width ?: number ;
@@ -114,7 +199,7 @@ function useResizeObserver<T extends HTMLElement>(
114
199
} ;
115
200
} , [ ] ) ;
116
201
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.
118
203
const previous : {
119
204
current : {
120
205
width ?: number ;
@@ -128,46 +213,68 @@ function useResizeObserver<T extends HTMLElement>(
128
213
// This block is kinda like a useEffect, only it's called whenever a new
129
214
// element could be resolved based on the ref option. It also has a cleanup
130
215
// 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.
145
221
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
148
225
) {
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
+ } ;
159
264
}
160
- } ) ;
161
- }
162
265
163
- resizeObserverRef . current . observe ( element ) ;
266
+ resizeObserverRef . current . instance . observe ( element , { box : opts . box } ) ;
164
267
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
+ ) ;
171
278
172
279
return useMemo (
173
280
( ) => ( {
0 commit comments