Skip to content

Commit ae2df29

Browse files
committed
feat: add ResizeObserver polyfill with entry creation, scheduling, and batching
- Implement ResizeObserver class following Web API spec - Add ResizeObserverEntry for size change reporting - Support observation scheduling via requestAnimationFrame - Add callback batching for performance - Include disconnect and unobserve cleanup - Support multiple element observation - Add box size calculation utilities with fractional pixel fix - Add comprehensive Jest tests Bug-fix: Fix fractional pixel rounding in element size calculations Signed-off-by: Srikanth Patchava <spatchava@meta.com>
1 parent af75e9a commit ae2df29

3 files changed

Lines changed: 734 additions & 0 deletions

File tree

Lines changed: 318 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,318 @@
1+
/**
2+
* Copyright (c) Meta Platforms, Inc. and affiliates.
3+
*
4+
* This source code is licensed under the MIT license found in the
5+
* LICENSE file in the root directory of this source tree.
6+
*
7+
* @flow strict-local
8+
* @format
9+
*/
10+
11+
// flowlint unsafe-getters-setters:off
12+
13+
import type ReactNativeElement from '../nodes/ReactNativeElement';
14+
15+
import {
16+
roundToDevicePixel,
17+
computeContentBoxSize,
18+
computeBorderBoxSize,
19+
computeDevicePixelContentBoxSize,
20+
} from './ResizeObserverUtils';
21+
22+
export type ResizeObserverBoxOptions =
23+
| 'content-box'
24+
| 'border-box'
25+
| 'device-pixel-content-box';
26+
27+
export interface ResizeObserverOptions {
28+
+box?: ResizeObserverBoxOptions;
29+
}
30+
31+
export type ResizeObserverCallback = (
32+
entries: $ReadOnlyArray<ResizeObserverEntry>,
33+
observer: ResizeObserver,
34+
) => mixed;
35+
36+
type ResizeObserverSize = {
37+
+inlineSize: number,
38+
+blockSize: number,
39+
};
40+
41+
/**
42+
* Represents a single size change observation for a target element.
43+
*
44+
* An array of `ResizeObserverEntry` objects is delivered to the
45+
* `ResizeObserver` callback as the first argument.
46+
*
47+
* @see https://developer.mozilla.org/en-US/docs/Web/API/ResizeObserverEntry
48+
*/
49+
export class ResizeObserverEntry {
50+
_target: ReactNativeElement;
51+
_contentRect: {x: number, y: number, width: number, height: number};
52+
_contentBoxSize: $ReadOnlyArray<ResizeObserverSize>;
53+
_borderBoxSize: $ReadOnlyArray<ResizeObserverSize>;
54+
_devicePixelContentBoxSize: $ReadOnlyArray<ResizeObserverSize>;
55+
56+
constructor(target: ReactNativeElement): void {
57+
this._target = target;
58+
59+
const layout = target._layout;
60+
const width = layout != null ? layout.width : 0;
61+
const height = layout != null ? layout.height : 0;
62+
63+
const contentBox = computeContentBoxSize(width, height);
64+
const borderBox = computeBorderBoxSize(width, height);
65+
const devicePixelBox = computeDevicePixelContentBoxSize(width, height);
66+
67+
this._contentRect = {
68+
x: 0,
69+
y: 0,
70+
width: contentBox.inlineSize,
71+
height: contentBox.blockSize,
72+
};
73+
74+
this._contentBoxSize = [contentBox];
75+
this._borderBoxSize = [borderBox];
76+
this._devicePixelContentBoxSize = [devicePixelBox];
77+
}
78+
79+
/**
80+
* The `ReactNativeElement` being observed.
81+
*/
82+
get target(): ReactNativeElement {
83+
return this._target;
84+
}
85+
86+
/**
87+
* A DOMRectReadOnly-like object containing the new size of the observed
88+
* element when the callback is run. This uses the content box dimensions.
89+
*/
90+
get contentRect(): {x: number, y: number, width: number, height: number} {
91+
return this._contentRect;
92+
}
93+
94+
/**
95+
* An array containing the new content box size of the observed element.
96+
*/
97+
get contentBoxSize(): $ReadOnlyArray<ResizeObserverSize> {
98+
return this._contentBoxSize;
99+
}
100+
101+
/**
102+
* An array containing the new border box size of the observed element.
103+
*/
104+
get borderBoxSize(): $ReadOnlyArray<ResizeObserverSize> {
105+
return this._borderBoxSize;
106+
}
107+
108+
/**
109+
* An array containing the new content box size of the observed element
110+
* in device pixel units.
111+
*/
112+
get devicePixelContentBoxSize(): $ReadOnlyArray<ResizeObserverSize> {
113+
return this._devicePixelContentBoxSize;
114+
}
115+
}
116+
117+
type ObservationRecord = {
118+
target: ReactNativeElement,
119+
box: ResizeObserverBoxOptions,
120+
lastReportedWidth: number,
121+
lastReportedHeight: number,
122+
};
123+
124+
// Global list of all active ResizeObserver instances for scheduling
125+
const activeObservers: Set<ResizeObserver> = new Set();
126+
127+
// Batch scheduling state
128+
let scheduledFrameId: ?AnimationFrameID = null;
129+
130+
/**
131+
* Process all pending resize observations across all active observers.
132+
* Observations are batched and delivered in a single callback per observer
133+
* per frame to avoid layout thrashing.
134+
*/
135+
function processObservations(): void {
136+
scheduledFrameId = null;
137+
138+
for (const observer of activeObservers) {
139+
const entries: Array<ResizeObserverEntry> = [];
140+
141+
for (const record of observer._observations) {
142+
const target = record.target;
143+
144+
// Skip disconnected elements — null-check prevents crashes
145+
// when an element has been removed from the tree between frames.
146+
const layout = target._layout;
147+
if (layout == null) {
148+
continue;
149+
}
150+
151+
const currentWidth = roundToDevicePixel(layout.width);
152+
const currentHeight = roundToDevicePixel(layout.height);
153+
154+
// Only report if dimensions actually changed since last report
155+
if (
156+
currentWidth !== record.lastReportedWidth ||
157+
currentHeight !== record.lastReportedHeight
158+
) {
159+
record.lastReportedWidth = currentWidth;
160+
record.lastReportedHeight = currentHeight;
161+
entries.push(new ResizeObserverEntry(target));
162+
}
163+
}
164+
165+
if (entries.length > 0) {
166+
try {
167+
observer._callback(entries, observer);
168+
} catch (error) {
169+
// Matches browser behavior: errors in callbacks are reported
170+
// but do not prevent other observers from being notified.
171+
console.error(
172+
"Error in ResizeObserver callback: '%s'",
173+
error.message,
174+
);
175+
}
176+
}
177+
}
178+
179+
// Reschedule if there are still active observers
180+
if (activeObservers.size > 0) {
181+
scheduleObservationProcessing();
182+
}
183+
}
184+
185+
/**
186+
* Schedule a batched processing of all resize observations on the next
187+
* animation frame. Multiple calls within the same frame are coalesced.
188+
*/
189+
function scheduleObservationProcessing(): void {
190+
if (scheduledFrameId == null) {
191+
scheduledFrameId = requestAnimationFrame(processObservations);
192+
}
193+
}
194+
195+
/**
196+
* React Native implementation of the `ResizeObserver` API.
197+
*
198+
* Reports changes to the dimensions of an element's content or border box.
199+
* Observations are batched and delivered via `requestAnimationFrame` to
200+
* avoid layout thrashing and provide consistent timing.
201+
*
202+
* @see https://developer.mozilla.org/en-US/docs/Web/API/ResizeObserver
203+
*/
204+
export default class ResizeObserver {
205+
_callback: ResizeObserverCallback;
206+
_observations: Array<ObservationRecord>;
207+
208+
constructor(callback: ResizeObserverCallback): void {
209+
if (callback == null) {
210+
throw new TypeError(
211+
"Failed to construct 'ResizeObserver': 1 argument required, but only 0 present.",
212+
);
213+
}
214+
215+
if (typeof callback !== 'function') {
216+
throw new TypeError(
217+
"Failed to construct 'ResizeObserver': parameter 1 is not of type 'Function'.",
218+
);
219+
}
220+
221+
this._callback = callback;
222+
this._observations = [];
223+
}
224+
225+
/**
226+
* Starts observing the specified `ReactNativeElement`.
227+
*
228+
* If the element is already being observed, the existing observation is
229+
* updated with the new box option. Calling observe with no options
230+
* defaults to `content-box`.
231+
*
232+
* @see https://developer.mozilla.org/en-US/docs/Web/API/ResizeObserver/observe
233+
*/
234+
observe(target: ReactNativeElement, options?: ResizeObserverOptions): void {
235+
if (target == null) {
236+
throw new TypeError(
237+
"Failed to execute 'observe' on 'ResizeObserver': parameter 1 is null or undefined.",
238+
);
239+
}
240+
241+
const box: ResizeObserverBoxOptions = options?.box ?? 'content-box';
242+
243+
if (
244+
box !== 'content-box' &&
245+
box !== 'border-box' &&
246+
box !== 'device-pixel-content-box'
247+
) {
248+
throw new TypeError(
249+
`Failed to execute 'observe' on 'ResizeObserver': '${box}' is not a valid enum value of type ResizeObserverBoxOptions.`,
250+
);
251+
}
252+
253+
// If already observing this target, update the box option per spec
254+
const existingIndex = this._observations.findIndex(
255+
record => record.target === target,
256+
);
257+
258+
if (existingIndex !== -1) {
259+
this._observations[existingIndex].box = box;
260+
return;
261+
}
262+
263+
const layout = target._layout;
264+
const initialWidth =
265+
layout != null ? roundToDevicePixel(layout.width) : -1;
266+
const initialHeight =
267+
layout != null ? roundToDevicePixel(layout.height) : -1;
268+
269+
this._observations.push({
270+
target,
271+
box,
272+
// Use -1 to force an initial callback delivery
273+
lastReportedWidth: initialWidth === 0 ? -1 : initialWidth,
274+
lastReportedHeight: initialHeight === 0 ? -1 : initialHeight,
275+
});
276+
277+
activeObservers.add(this);
278+
scheduleObservationProcessing();
279+
}
280+
281+
/**
282+
* Ends the observing of a specified `ReactNativeElement`.
283+
*
284+
* @see https://developer.mozilla.org/en-US/docs/Web/API/ResizeObserver/unobserve
285+
*/
286+
unobserve(target: ReactNativeElement): void {
287+
if (target == null) {
288+
throw new TypeError(
289+
"Failed to execute 'unobserve' on 'ResizeObserver': parameter 1 is null or undefined.",
290+
);
291+
}
292+
293+
const index = this._observations.findIndex(
294+
record => record.target === target,
295+
);
296+
297+
if (index === -1) {
298+
return;
299+
}
300+
301+
this._observations.splice(index, 1);
302+
303+
if (this._observations.length === 0) {
304+
activeObservers.delete(this);
305+
}
306+
}
307+
308+
/**
309+
* Unobserves all observed elements and deactivates the observer.
310+
* The observer can be reused by calling `observe()` again.
311+
*
312+
* @see https://developer.mozilla.org/en-US/docs/Web/API/ResizeObserver/disconnect
313+
*/
314+
disconnect(): void {
315+
this._observations = [];
316+
activeObservers.delete(this);
317+
}
318+
}

0 commit comments

Comments
 (0)