Add getClientRects to fragment instances (#32660)
Adds `getClientRects()` to fragment instances with a fixture test case. `Element.getClientRect` returns a collection of `DOMRect`s (see example of multiline span returning two `DOMRect` boxes). `fragmentInstance.getClientRects` here flattens those collections into an array of rects.
Jack Pope committed
Mar 18, 2025 at 13:54 UTC
476f53879e80d4ee976ed036a0e8986126fa3117
10 files changed
+266
-76
fixtures/dom/src/components/Fixture.js
+4
@@ -16,3 +16,7 @@ class Fixture extends React.Component {
16
Fixture.propTypes = propTypes;
17
18
export default Fixture;
19
+
20
+Fixture.Controls = function FixtureControls({children}) {
21
+ return <div className="test-fixture__controls">{children}</div>;
22
+};
fixtures/dom/src/components/fixtures/fragment-refs/EventListenerCase.js
+19
-19
@@ -49,7 +49,7 @@ export default function EventListenerCase() {
49
</TestCase.ExpectedResult>
50
51
<Fixture>
52
- <div className="control-box">
52
+ <Fixture.Controls>
53
<div>Target count: {extraChildCount + 3}</div>
54
<button
55
onClick={() => {
@@ -69,26 +69,26 @@ export default function EventListenerCase() {
69
}}>
70
Remove click event listeners
71
</button>
72
- <div class="card-container">
73
- <Fragment ref={fragmentRef}>
74
- <div className="card" id="child-a">
75
- Child A
72
+ </Fixture.Controls>
73
+ <div className="card-container">
74
+ <Fragment ref={fragmentRef}>
75
+ <div className="card" id="child-a">
76
+ Child A
77
+ </div>
78
+ <div className="card" id="child-b">
79
+ Child B
80
+ </div>
81
+ <WrapperComponent>
82
+ <div className="card" id="child-c">
83
+ Child C
84
</div>
77
- <div className="card" id="child-b">
78
- Child B
79
- </div>
80
- <WrapperComponent>
81
- <div className="card" id="child-c">
82
- Child C
85
+ {Array.from({length: extraChildCount}).map((_, index) => (
86
+ <div className="card" id={'extra-child-' + index} key={index}>
87
+ Extra Child {index}
88
</div>
84
- {Array.from({length: extraChildCount}).map((_, index) => (
85
- <div className="card" id={'extra-child-' + index} key={index}>
86
- Extra Child {index}
87
- </div>
88
- ))}
89
- </WrapperComponent>
90
- </Fragment>
91
- </div>
89
+ ))}
90
+ </WrapperComponent>
91
+ </Fragment>
92
</div>
93
</Fixture>
94
</TestCase>
fixtures/dom/src/components/fixtures/fragment-refs/FocusCase.js
+9
-8
@@ -31,15 +31,16 @@ export default function FocusCase() {
31
</p>
32
</TestCase.ExpectedResult>
33
34
- <button onClick={() => fragmentRef.current.focus()}>
35
- Focus first child
36
- </button>
37
- <button onClick={() => fragmentRef.current.focusLast()}>
38
- Focus last child
39
- </button>
40
- <button onClick={() => fragmentRef.current.blur()}>Blur</button>
41
-
34
<Fixture>
35
+ <Fixture.Controls>
36
+ <button onClick={() => fragmentRef.current.focus()}>
37
+ Focus first child
38
+ </button>
39
+ <button onClick={() => fragmentRef.current.focusLast()}>
40
+ Focus last child
41
+ </button>
42
+ <button onClick={() => fragmentRef.current.blur()}>Blur</button>
43
+ </Fixture.Controls>
44
<div className="highlight-focused-children" style={{display: 'flex'}}>
45
<Fragment ref={fragmentRef}>
46
<div style={{outline: '1px solid black'}}>Unfocusable div</div>
fixtures/dom/src/components/fixtures/fragment-refs/GetClientRectsCase.js
new
+100
@@ -0,0 +1,100 @@
1
+import TestCase from '../../TestCase';
2
+import Fixture from '../../Fixture';
3
+
4
+const React = window.React;
5
+const {Fragment, useEffect, useRef, useState} = React;
6
+
7
+export default function GetClientRectsCase() {
8
+ const fragmentRef = useRef(null);
9
+ const [rects, setRects] = useState([]);
10
+ const getRects = () => {
11
+ const rects = fragmentRef.current.getClientRects();
12
+ setRects(rects);
13
+ };
14
+
15
+ return (
16
+ <TestCase title="getClientRects">
17
+ <TestCase.Steps>
18
+ <li>
19
+ Click the "Print Rects" button to get the client rects of the
20
+ elements.
21
+ </li>
22
+ </TestCase.Steps>
23
+ <TestCase.ExpectedResult>
24
+ Calling getClientRects on the fragment instance will return a list of a
25
+ DOMRectList for each child node.
26
+ </TestCase.ExpectedResult>
27
+ <Fixture>
28
+ <Fixture.Controls>
29
+ <button onClick={getRects}>Print Rects</button>
30
+ <div style={{display: 'flex'}}>
31
+ <div
32
+ style={{
33
+ position: 'relative',
34
+ width: '30vw',
35
+ height: '30vh',
36
+ border: '1px solid black',
37
+ }}>
38
+ {rects.map(({x, y, width, height}, index) => {
39
+ const scale = 0.3;
40
+
41
+ return (
42
+ <div
43
+ key={index}
44
+ style={{
45
+ position: 'absolute',
46
+ top: y * scale,
47
+ left: x * scale,
48
+ width: width * scale,
49
+ height: height * scale,
50
+ border: '1px solid red',
51
+ boxSizing: 'border-box',
52
+ }}></div>
53
+ );
54
+ })}
55
+ </div>
56
+ <div>
57
+ {rects.map(({x, y, width, height}, index) => {
58
+ return (
59
+ <div>
60
+ {index} :: {`{`}x: {x}, y: {y}, width: {width}, height:{' '}
61
+ {height}
62
+ {`}`}
63
+ </div>
64
+ );
65
+ })}
66
+ </div>
67
+ </div>
68
+ </Fixture.Controls>
69
+ <Fragment ref={fragmentRef}>
70
+ <span
71
+ style={{
72
+ width: '300px',
73
+ height: '250px',
74
+ backgroundColor: 'lightblue',
75
+ fontSize: 20,
76
+ border: '1px solid black',
77
+ marginBottom: '10px',
78
+ }}>
79
+ Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do
80
+ eiusmod tempor incididunt ut labore et dolore magna aliqua.
81
+ </span>
82
+ <div
83
+ style={{
84
+ width: '150px',
85
+ height: '100px',
86
+ backgroundColor: 'lightgreen',
87
+ border: '1px solid black',
88
+ }}></div>
89
+ <div
90
+ style={{
91
+ width: '500px',
92
+ height: '50px',
93
+ backgroundColor: 'lightpink',
94
+ border: '1px solid black',
95
+ }}></div>
96
+ </Fragment>
97
+ </Fixture>
98
+ </TestCase>
99
+ );
100
+}
fixtures/dom/src/components/fixtures/fragment-refs/IntersectionObserverCase.js
+49
-47
@@ -90,53 +90,55 @@ export default function IntersectionObserverCase() {
90
</p>
91
</TestCase.ExpectedResult>
92
<Fixture>
93
- <button
94
- onClick={() => {
95
- setItems(prev => [
96
- ...prev,
97
- [`Extra child: ${prev.length + 1}`, false],
98
- ]);
99
- }}>
100
- Add Child
101
- </button>
102
- <button
103
- onClick={() => {
104
- setItems(prev => {
105
- if (prev.length === 3) {
106
- return prev;
107
- }
108
- return prev.slice(0, prev.length - 1);
109
- });
110
- }}>
111
- Remove Child
112
- </button>
113
- <button
114
- onClick={() => {
115
- fragmentRef.current.observeUsing(observerRef.current);
116
- }}>
117
- Observe
118
- </button>
119
- <button
120
- onClick={() => {
121
- fragmentRef.current.unobserveUsing(observerRef.current);
122
- setItems(prev => {
123
- return prev.map(item => [item[0], false]);
124
- });
125
- }}>
126
- Unobserve
127
- </button>
128
- {anyOnScreen && (
129
- <div className="fixed-sidebar card-container">
130
- <p>
131
- <strong>Children on screen:</strong>
132
- </p>
133
- {items.map(item => (
134
- <div className={`card ${item[1] ? 'onscreen' : null}`}>
135
- {item[0]}
136
- </div>
137
- ))}
138
- </div>
139
- )}
93
+ <Fixture.Controls>
94
+ <button
95
+ onClick={() => {
96
+ setItems(prev => [
97
+ ...prev,
98
+ [`Extra child: ${prev.length + 1}`, false],
99
+ ]);
100
+ }}>
101
+ Add Child
102
+ </button>
103
+ <button
104
+ onClick={() => {
105
+ setItems(prev => {
106
+ if (prev.length === 3) {
107
+ return prev;
108
+ }
109
+ return prev.slice(0, prev.length - 1);
110
+ });
111
+ }}>
112
+ Remove Child
113
+ </button>
114
+ <button
115
+ onClick={() => {
116
+ fragmentRef.current.observeUsing(observerRef.current);
117
+ }}>
118
+ Observe
119
+ </button>
120
+ <button
121
+ onClick={() => {
122
+ fragmentRef.current.unobserveUsing(observerRef.current);
123
+ setItems(prev => {
124
+ return prev.map(item => [item[0], false]);
125
+ });
126
+ }}>
127
+ Unobserve
128
+ </button>
129
+ {anyOnScreen && (
130
+ <div className="fixed-sidebar card-container">
131
+ <p>
132
+ <strong>Children on screen:</strong>
133
+ </p>
134
+ {items.map(item => (
135
+ <div className={`card ${item[1] ? 'onscreen' : null}`}>
136
+ {item[0]}
137
+ </div>
138
+ ))}
139
+ </div>
140
+ )}
141
+ </Fixture.Controls>
142
<Fragment ref={fragmentRef}>
143
<ObservedChild id="A" />
144
<WrapperComponent>
fixtures/dom/src/components/fixtures/fragment-refs/index.js
+2
@@ -3,6 +3,7 @@ import EventListenerCase from './EventListenerCase';
3
import IntersectionObserverCase from './IntersectionObserverCase';
4
import ResizeObserverCase from './ResizeObserverCase';
5
import FocusCase from './FocusCase';
6
+import GetClientRectsCase from './GetClientRectsCase';
7
8
const React = window.React;
9
@@ -13,6 +14,7 @@ export default function FragmentRefsPage() {
14
<IntersectionObserverCase />
15
<ResizeObserverCase />
16
<FocusCase />
17
+ <GetClientRectsCase />
18
</FixtureSet>
19
);
20
}
fixtures/dom/src/style.css
+8
-2
@@ -224,7 +224,7 @@ p {
224
}
225
226
.test-case__body {
227
- padding: 10px;
227
+ padding: 10px 10px 0 10px;
228
}
229
230
.test-case__desc {
@@ -280,11 +280,17 @@ p {
280
281
.test-fixture {
282
padding: 20px;
283
- margin: 0 -15px; /* opposite of .test-case padding */
283
+ margin: 0 -10px; /* opposite of .test-case padding */
284
background-color: #f4f4f4;
285
border-top: 1px solid #d9d9d9;
286
}
287
288
+.test-fixture__controls {
289
+ margin: -20px -20px 20px -20px;
290
+ padding: 20px;
291
+ border: 1px solid #444;
292
+}
293
+
294
.field-group {
295
overflow: hidden;
296
}
packages/react-dom-bindings/src/client/ReactFiberConfigDOM.js
+14
@@ -2229,6 +2229,7 @@ export type FragmentInstanceType = {
2229
blur(): void,
2230
observeUsing(observer: IntersectionObserver | ResizeObserver): void,
2231
unobserveUsing(observer: IntersectionObserver | ResizeObserver): void,
2232
+ getClientRects(): Array<DOMRect>,
2233
};
2234
2235
function FragmentInstance(this: FragmentInstanceType, fragmentFiber: Fiber) {
@@ -2406,6 +2407,19 @@ function unobserveChild(
2407
observer.unobserve(child);
2408
return false;
2409
}
2410
+// $FlowFixMe[prop-missing]
2411
+FragmentInstance.prototype.getClientRects = function (
2412
+ this: FragmentInstanceType,
2413
+): Array<DOMRect> {
2414
+ const rects: Array<DOMRect> = [];
2415
+ traverseFragmentInstance(this._fragmentFiber, collectClientRects, rects);
2416
+ return rects;
2417
+};
2418
+function collectClientRects(child: Instance, rects: Array<DOMRect>): boolean {
2419
+ // $FlowFixMe[method-unbinding]
2420
+ rects.push.apply(rects, child.getClientRects());
2421
+ return false;
2422
+}
2423
2424
function normalizeListenerOptions(
2425
opts: ?EventListenerOptionsOrUseCapture,
packages/react-dom/src/__tests__/ReactDOMFragmentRefs-test.js
+43
@@ -17,6 +17,7 @@ let Fragment;
17
let Activity;
18
let mockIntersectionObserver;
19
let simulateIntersection;
20
+let setClientRects;
21
let assertConsoleErrorDev;
22
23
function Wrapper({children}) {
@@ -34,6 +35,7 @@ describe('FragmentRefs', () => {
35
const IntersectionMocks = require('./utils/IntersectionMocks');
36
mockIntersectionObserver = IntersectionMocks.mockIntersectionObserver;
37
simulateIntersection = IntersectionMocks.simulateIntersection;
38
+ setClientRects = IntersectionMocks.setClientRects;
39
assertConsoleErrorDev =
40
require('internal-test-utils').assertConsoleErrorDev;
41
@@ -841,4 +843,45 @@ describe('FragmentRefs', () => {
843
);
844
});
845
});
846
+
847
+ describe('getClientRects', () => {
848
+ // @gate enableFragmentRefs
849
+ it('returns the bounding client recs of all children', async () => {
850
+ const fragmentRef = React.createRef();
851
+ const childARef = React.createRef();
852
+ const childBRef = React.createRef();
853
+ const root = ReactDOMClient.createRoot(container);
854
+
855
+ function Test() {
856
+ return (
857
+ <React.Fragment ref={fragmentRef}>
858
+ <div ref={childARef} />
859
+ <div ref={childBRef} />
860
+ </React.Fragment>
861
+ );
862
+ }
863
+
864
+ await act(() => root.render(<Test />));
865
+ setClientRects(childARef.current, [
866
+ {
867
+ x: 1,
868
+ y: 2,
869
+ width: 3,
870
+ height: 4,
871
+ },
872
+ {
873
+ x: 5,
874
+ y: 6,
875
+ width: 7,
876
+ height: 8,
877
+ },
878
+ ]);
879
+ setClientRects(childBRef.current, [{x: 9, y: 10, width: 11, height: 12}]);
880
+ const clientRects = fragmentRef.current.getClientRects();
881
+ expect(clientRects.length).toBe(3);
882
+ expect(clientRects[0].left).toBe(1);
883
+ expect(clientRects[1].left).toBe(5);
884
+ expect(clientRects[2].left).toBe(9);
885
+ });
886
+ });
887
});
packages/react-dom/src/__tests__/utils/IntersectionMocks.js
+18
@@ -75,3 +75,21 @@ export function setBoundingClientRect(target, {x, y, width, height}) {
75
};
76
};
77
}
78
+
79
+/**
80
+ * Stub out getClientRects for the specified target.
81
+ */
82
+export function setClientRects(target, rects) {
83
+ target.getClientRects = function () {
84
+ return rects.map(({x, y, width, height}) => ({
85
+ width,
86
+ height,
87
+ left: x,
88
+ right: x + width,
89
+ top: y,
90
+ bottom: y + height,
91
+ x,
92
+ y,
93
+ }));
94
+ };
95
+}