@samitouri / QOS-React / commits / 476f53879e

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 +}