@samitouri / QOS-React-2 / commits / 875b06489f

Add text node support to FragmentInstance operations (#35630)

This PR adds text node support to FragmentInstance operations, allowing fragment refs to properly handle fragments that contain text nodes (either mixed with elements or text-only). Not currently adding/removing new text nodes as we don't need to track them for events or observers in DOM. Will follow up on this and with Fabric support. ## Support through parent element - `dispatchEvent` - `compareDocumentPosition` - `getRootNode` ## Support through Range API - `getClientRects`: Uses Range to calculate bounding rects for text nodes - `scrollIntoView`: Uses Range to scroll to text node positions directly ## No support - `focus`/`focusLast`/`blur`: Noop for text-only fragments - `observeUsing`: Warns for text-only fragments in DEV - `addEventListener`/`removeEventListener`: Ignores text nodes, but still works on Fragment level through `dispatchEvent`

Jack Pope committed Jan 28, 2026 at 14:45 UTC 875b06489f4436125613535dfe0833cd12a500f9
17 files changed +777 -76
fixtures/dom/src/components/fixtures/fragment-refs/GetClientRectsCase.js
+26 -72
@@ -1,17 +1,10 @@
1 import TestCase from '../../TestCase';
2 import Fixture from '../../Fixture';
3 +import PrintRectsFragmentContainer from './PrintRectsFragmentContainer';
4
5 const React = window.React;
5 -const {Fragment, 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 -
8 return (
9 <TestCase title="getClientRects">
10 <TestCase.Steps>
@@ -26,74 +19,35 @@ export default function GetClientRectsCase() {
19 </TestCase.ExpectedResult>
20 <Fixture>
21 <Fixture.Controls>
29 - <button onClick={getRects}>Print Rects</button>
30 - <div style={{display: 'flex'}}>
31 - <div
22 + <PrintRectsFragmentContainer>
23 + <span
24 style={{
33 - position: 'relative',
34 - width: '30vw',
35 - height: '30vh',
25 + width: '300px',
26 + height: '250px',
27 + backgroundColor: 'lightblue',
28 + fontSize: 20,
29 border: '1px solid black',
30 + marginBottom: '10px',
31 }}>
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>
32 + Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do
33 + eiusmod tempor incididunt ut labore et dolore magna aliqua.
34 + </span>
35 + <div
36 + style={{
37 + width: '150px',
38 + height: '100px',
39 + backgroundColor: 'lightgreen',
40 + border: '1px solid black',
41 + }}></div>
42 + <div
43 + style={{
44 + width: '500px',
45 + height: '50px',
46 + backgroundColor: 'lightpink',
47 + border: '1px solid black',
48 + }}></div>
49 + </PrintRectsFragmentContainer>
50 </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>
51 </Fixture>
52 </TestCase>
53 );
fixtures/dom/src/components/fixtures/fragment-refs/PrintRectsFragmentContainer.js new
+126
@@ -0,0 +1,126 @@
1 +const React = window.React;
2 +const {Fragment, useRef, useState} = React;
3 +
4 +const colors = [
5 + '#e74c3c',
6 + '#3498db',
7 + '#2ecc71',
8 + '#9b59b6',
9 + '#f39c12',
10 + '#1abc9c',
11 +];
12 +
13 +export default function PrintRectsFragmentContainer({children}) {
14 + const fragmentRef = useRef(null);
15 + const [rects, setRects] = useState([]);
16 +
17 + const getRects = () => {
18 + const rectsResult = fragmentRef.current.getClientRects();
19 + setRects(Array.from(rectsResult));
20 + };
21 +
22 + const getColor = index => colors[index % colors.length];
23 +
24 + return (
25 + <Fragment>
26 + <div style={{marginBottom: '16px'}}>
27 + <button
28 + onClick={getRects}
29 + style={{
30 + padding: '8px 16px',
31 + fontSize: '14px',
32 + fontWeight: 'bold',
33 + cursor: 'pointer',
34 + }}>
35 + Print Rects
36 + </button>
37 + {rects.length > 0 && (
38 + <span style={{marginLeft: '12px', color: '#666'}}>
39 + Found {rects.length} rect{rects.length !== 1 ? 's' : ''}
40 + </span>
41 + )}
42 + </div>
43 +
44 + <div style={{display: 'flex', gap: '20px', marginBottom: '16px'}}>
45 + <div
46 + style={{
47 + position: 'relative',
48 + width: '30vw',
49 + height: '30vh',
50 + border: '1px solid #ccc',
51 + backgroundColor: '#fafafa',
52 + borderRadius: '4px',
53 + overflow: 'hidden',
54 + }}>
55 + {rects.length === 0 && (
56 + <div
57 + style={{
58 + position: 'absolute',
59 + top: '50%',
60 + left: '50%',
61 + transform: 'translate(-50%, -50%)',
62 + color: '#999',
63 + fontSize: '14px',
64 + }}>
65 + Click button to visualize rects
66 + </div>
67 + )}
68 + {rects.map(({x, y, width, height}, index) => {
69 + const scale = 0.3;
70 + const color = getColor(index);
71 +
72 + return (
73 + <div
74 + key={index}
75 + style={{
76 + position: 'absolute',
77 + top: y * scale,
78 + left: x * scale,
79 + width: width * scale,
80 + height: height * scale,
81 + border: `2px solid ${color}`,
82 + backgroundColor: `${color}22`,
83 + boxSizing: 'border-box',
84 + borderRadius: '2px',
85 + }}
86 + />
87 + );
88 + })}
89 + </div>
90 +
91 + <div style={{flex: 1, fontSize: '13px', fontFamily: 'monospace'}}>
92 + {rects.map(({x, y, width, height}, index) => {
93 + const color = getColor(index);
94 + return (
95 + <div
96 + key={index}
97 + style={{
98 + padding: '6px 10px',
99 + marginBottom: '4px',
100 + backgroundColor: '#f5f5f5',
101 + borderLeft: `3px solid ${color}`,
102 + borderRadius: '2px',
103 + }}>
104 + <span style={{color: '#666'}}>#{index}</span>{' '}
105 + <span style={{color: '#333'}}>
106 + x: {Math.round(x)}, y: {Math.round(y)}, w: {Math.round(width)}
107 + , h: {Math.round(height)}
108 + </span>
109 + </div>
110 + );
111 + })}
112 + </div>
113 + </div>
114 +
115 + <div
116 + style={{
117 + padding: '12px',
118 + border: '1px dashed #ccc',
119 + borderRadius: '4px',
120 + backgroundColor: '#fff',
121 + }}>
122 + <Fragment ref={fragmentRef}>{children}</Fragment>
123 + </div>
124 + </Fragment>
125 + );
126 +}
fixtures/dom/src/components/fixtures/fragment-refs/TextNodesCase.js new
+319
@@ -0,0 +1,319 @@
1 +import TestCase from '../../TestCase';
2 +import Fixture from '../../Fixture';
3 +import PrintRectsFragmentContainer from './PrintRectsFragmentContainer';
4 +
5 +const React = window.React;
6 +const {Fragment, useRef, useState} = React;
7 +
8 +function GetClientRectsTextOnly() {
9 + return (
10 + <TestCase title="getClientRects - Text Only">
11 + <TestCase.Steps>
12 + <li>Click the "Print Rects" button</li>
13 + </TestCase.Steps>
14 + <TestCase.ExpectedResult>
15 + The fragment contains only text nodes. getClientRects should return
16 + bounding rectangles for the text content using the Range API.
17 + </TestCase.ExpectedResult>
18 + <Fixture>
19 + <Fixture.Controls>
20 + <PrintRectsFragmentContainer>
21 + This is text content inside a fragment with no element children.
22 + </PrintRectsFragmentContainer>
23 + </Fixture.Controls>
24 + </Fixture>
25 + </TestCase>
26 + );
27 +}
28 +
29 +function GetClientRectsMixed() {
30 + return (
31 + <TestCase title="getClientRects - Mixed Content">
32 + <TestCase.Steps>
33 + <li>Click the "Print Rects" button</li>
34 + </TestCase.Steps>
35 + <TestCase.ExpectedResult>
36 + The fragment contains both text nodes and elements. getClientRects
37 + should return bounding rectangles for both text content (via Range API)
38 + and elements.
39 + </TestCase.ExpectedResult>
40 + <Fixture>
41 + <Fixture.Controls>
42 + <PrintRectsFragmentContainer>
43 + Text before the span.
44 + <span
45 + style={{
46 + display: 'inline-block',
47 + padding: '5px 10px',
48 + backgroundColor: 'lightblue',
49 + border: '1px solid blue',
50 + margin: '0 5px',
51 + }}>
52 + Element
53 + </span>
54 + Text after the span.
55 + <div
56 + style={{
57 + width: '500px',
58 + height: '50px',
59 + backgroundColor: 'lightpink',
60 + border: '1px solid black',
61 + }}></div>
62 + More text at the end.
63 + </PrintRectsFragmentContainer>
64 + </Fixture.Controls>
65 + </Fixture>
66 + </TestCase>
67 + );
68 +}
69 +
70 +function FocusTextOnlyNoop() {
71 + const fragmentRef = useRef(null);
72 + const [message, setMessage] = useState('');
73 +
74 + const tryFocus = () => {
75 + fragmentRef.current.focus();
76 + setMessage('Called focus() - no-op for text-only fragments');
77 + };
78 +
79 + const tryFocusLast = () => {
80 + fragmentRef.current.focusLast();
81 + setMessage('Called focusLast() - no-op for text-only fragments');
82 + };
83 +
84 + return (
85 + <TestCase title="focus/focusLast - Text Only (No-op)">
86 + <TestCase.Steps>
87 + <li>Click either focus button</li>
88 + </TestCase.Steps>
89 + <TestCase.ExpectedResult>
90 + Calling focus() or focusLast() on a fragment with only text children is
91 + a no-op. Nothing happens and no warning is logged. This is because text
92 + nodes cannot receive focus.
93 + </TestCase.ExpectedResult>
94 + <Fixture>
95 + <Fixture.Controls>
96 + <button onClick={tryFocus}>focus()</button>
97 + <button onClick={tryFocusLast}>focusLast()</button>
98 + {message && (
99 + <div style={{marginTop: '10px', color: '#666'}}>{message}</div>
100 + )}
101 + </Fixture.Controls>
102 + <div
103 + style={{
104 + padding: '20px',
105 + backgroundColor: '#f5f5f5',
106 + border: '1px solid #ddd',
107 + }}>
108 + <Fragment ref={fragmentRef}>
109 + This fragment contains only text. Text nodes are not focusable.
110 + </Fragment>
111 + </div>
112 + </Fixture>
113 + </TestCase>
114 + );
115 +}
116 +
117 +function ScrollIntoViewTextOnly() {
118 + const fragmentRef = useRef(null);
119 + const [message, setMessage] = useState('');
120 +
121 + const tryScrollIntoView = alignToTop => {
122 + fragmentRef.current.scrollIntoView(alignToTop);
123 + setMessage(
124 + `Called scrollIntoView(${alignToTop}) - page should scroll to text`
125 + );
126 + };
127 +
128 + return (
129 + <TestCase title="scrollIntoView - Text Only">
130 + <TestCase.Steps>
131 + <li>Scroll down the page so the text fragment is not visible</li>
132 + <li>Click one of the scrollIntoView buttons</li>
133 + </TestCase.Steps>
134 + <TestCase.ExpectedResult>
135 + The page should scroll to bring the text content into view. With
136 + alignToTop=true, the text should appear at the top of the viewport. With
137 + alignToTop=false, it should appear at the bottom. This uses the Range
138 + API to calculate text node positions.
139 + </TestCase.ExpectedResult>
140 + <Fixture>
141 + <Fixture.Controls>
142 + <button onClick={() => tryScrollIntoView(true)}>
143 + scrollIntoView(true)
144 + </button>
145 + <button onClick={() => tryScrollIntoView(false)}>
146 + scrollIntoView(false)
147 + </button>
148 + {message && (
149 + <div style={{marginTop: '10px', color: 'green'}}>{message}</div>
150 + )}
151 + </Fixture.Controls>
152 + <div
153 + style={{
154 + marginTop: '100vh',
155 + marginBottom: '100vh',
156 + padding: '20px',
157 + backgroundColor: '#f0fff0',
158 + border: '1px solid #cfc',
159 + }}>
160 + <Fragment ref={fragmentRef}>
161 + This fragment contains only text. The scrollIntoView method uses the
162 + Range API to calculate the text position and scroll to it.
163 + </Fragment>
164 + </div>
165 + </Fixture>
166 + </TestCase>
167 + );
168 +}
169 +
170 +function ScrollIntoViewMixed() {
171 + const fragmentRef = useRef(null);
172 + const [message, setMessage] = useState('');
173 +
174 + const tryScrollIntoView = alignToTop => {
175 + fragmentRef.current.scrollIntoView(alignToTop);
176 + setMessage(
177 + `Called scrollIntoView(${alignToTop}) - page should scroll to fragment`
178 + );
179 + };
180 +
181 + const targetStyle = {
182 + height: 300,
183 + marginBottom: 50,
184 + display: 'flex',
185 + alignItems: 'center',
186 + justifyContent: 'center',
187 + fontSize: '24px',
188 + fontWeight: 'bold',
189 + };
190 +
191 + return (
192 + <TestCase title="scrollIntoView - Mixed Content">
193 + <TestCase.Steps>
194 + <li>Scroll down the page so the fragment is not visible</li>
195 + <li>Click one of the scrollIntoView buttons</li>
196 + </TestCase.Steps>
197 + <TestCase.ExpectedResult>
198 + The fragment contains raw text nodes (not wrapped in elements) and
199 + elements in alternating order. With alignToTop=true, scroll starts from
200 + the last child and works backwards, ending with the first text node at
201 + the top. With alignToTop=false, scroll starts from the first child and
202 + works forward, ending with the last text node at the bottom. Text nodes
203 + use the Range API for scrolling.
204 + </TestCase.ExpectedResult>
205 + <Fixture>
206 + <Fixture.Controls>
207 + <button onClick={() => tryScrollIntoView(true)}>
208 + scrollIntoView(true)
209 + </button>
210 + <button onClick={() => tryScrollIntoView(false)}>
211 + scrollIntoView(false)
212 + </button>
213 + {message && (
214 + <div style={{marginTop: '10px', color: 'green'}}>{message}</div>
215 + )}
216 + </Fixture.Controls>
217 + <div
218 + style={{
219 + marginTop: '100vh',
220 + marginBottom: '100vh',
221 + whiteSpace: 'pre-wrap',
222 + lineHeight: '2',
223 + }}>
224 + <Fragment ref={fragmentRef}>
225 + TEXT NODE 1 - This is a raw text node at the start of the fragment
226 + <div style={{...targetStyle, backgroundColor: 'lightyellow'}}>
227 + ELEMENT 1
228 + </div>
229 + TEXT NODE 2 - This is a raw text node between elements
230 + <div style={{...targetStyle, backgroundColor: 'lightpink'}}>
231 + ELEMENT 2
232 + </div>
233 + TEXT NODE 3 - This is a raw text node between elements
234 + <div style={{...targetStyle, backgroundColor: 'lightcyan'}}>
235 + ELEMENT 3
236 + </div>
237 + TEXT NODE 4 - This is a raw text node at the end of the fragment
238 + </Fragment>
239 + </div>
240 + </Fixture>
241 + </TestCase>
242 + );
243 +}
244 +
245 +function ObserveTextOnlyWarning() {
246 + const fragmentRef = useRef(null);
247 + const [message, setMessage] = useState('');
248 +
249 + const tryObserve = () => {
250 + setMessage('Called observeUsing() - check console for warning');
251 + const observer = new IntersectionObserver(() => {});
252 + fragmentRef.current.observeUsing(observer);
253 + };
254 +
255 + return (
256 + <TestCase title="observeUsing - Text Only Warning">
257 + <TestCase.Steps>
258 + <li>Open the browser console</li>
259 + <li>Click the observeUsing button</li>
260 + </TestCase.Steps>
261 + <TestCase.ExpectedResult>
262 + A warning should appear in the console because IntersectionObserver
263 + cannot observe text nodes. The warning message should indicate that
264 + observeUsing() was called on a FragmentInstance with only text children.
265 + </TestCase.ExpectedResult>
266 + <Fixture>
267 + <Fixture.Controls>
268 + <button onClick={tryObserve}>
269 + observeUsing(IntersectionObserver)
270 + </button>
271 + {message && (
272 + <div style={{marginTop: '10px', color: 'orange'}}>{message}</div>
273 + )}
274 + </Fixture.Controls>
275 + <div
276 + style={{
277 + padding: '20px',
278 + backgroundColor: '#fff0f0',
279 + border: '1px solid #fcc',
280 + }}>
281 + <Fragment ref={fragmentRef}>
282 + This fragment contains only text. Text nodes cannot be observed.
283 + </Fragment>
284 + </div>
285 + </Fixture>
286 + </TestCase>
287 + );
288 +}
289 +
290 +export default function TextNodesCase() {
291 + return (
292 + <TestCase title="Text Node Support">
293 + <TestCase.ExpectedResult>
294 + <p>
295 + This section demonstrates how various FragmentInstance methods work
296 + with text nodes.
297 + </p>
298 + <p>
299 + <strong>Supported:</strong> getClientRects, compareDocumentPosition,
300 + scrollIntoView
301 + </p>
302 + <p>
303 + <strong>No-op (silent):</strong> focus, focusLast (text nodes cannot
304 + receive focus)
305 + </p>
306 + <p>
307 + <strong>Not supported (warns):</strong> observeUsing (observers cannot
308 + observe text nodes)
309 + </p>
310 + </TestCase.ExpectedResult>
311 + <GetClientRectsTextOnly />
312 + <GetClientRectsMixed />
313 + <FocusTextOnlyNoop />
314 + <ScrollIntoViewTextOnly />
315 + <ScrollIntoViewMixed />
316 + <ObserveTextOnlyWarning />
317 + </TestCase>
318 + );
319 +}
fixtures/dom/src/components/fixtures/fragment-refs/index.js
+2
@@ -6,6 +6,7 @@ import ResizeObserverCase from './ResizeObserverCase';
6 import FocusCase from './FocusCase';
7 import GetClientRectsCase from './GetClientRectsCase';
8 import ScrollIntoViewCase from './ScrollIntoViewCase';
9 +import TextNodesCase from './TextNodesCase';
10
11 const React = window.React;
12
@@ -19,6 +20,7 @@ export default function FragmentRefsPage() {
20 <FocusCase />
21 <GetClientRectsCase />
22 <ScrollIntoViewCase />
23 + <TextNodesCase />
24 </FixtureSet>
25 );
26 }
packages/react-dom-bindings/src/client/ReactFiberConfigDOM.js
+66 -3
@@ -127,6 +127,7 @@ import {
127 enableFragmentRefsScrollIntoView,
128 enableProfilerTimer,
129 enableFragmentRefsInstanceHandles,
130 + enableFragmentRefsTextNodes,
131 } from 'shared/ReactFeatureFlags';
132 import {
133 HostComponent,
@@ -2956,6 +2957,7 @@ function FragmentInstance(this: FragmentInstanceType, fragmentFiber: Fiber) {
2957 this._eventListeners = null;
2958 this._observers = null;
2959 }
2960 +
2961 // $FlowFixMe[prop-missing]
2962 FragmentInstance.prototype.addEventListener = function (
2963 this: FragmentInstanceType,
@@ -3119,6 +3121,12 @@ function setFocusOnFiberIfFocusable(
3121 fiber: Fiber,
3122 focusOptions?: FocusOptions,
3123 ): boolean {
3124 + if (enableFragmentRefsTextNodes) {
3125 + // Skip text nodes - they are not focusable
3126 + if (fiber.tag === HostText) {
3127 + return false;
3128 + }
3129 + }
3130 const instance = getInstanceFromHostFiber<Instance>(fiber);
3131 return setFocusIfFocusable(instance, focusOptions);
3132 }
@@ -3169,6 +3177,28 @@ FragmentInstance.prototype.observeUsing = function (
3177 this: FragmentInstanceType,
3178 observer: IntersectionObserver | ResizeObserver,
3179 ): void {
3180 + if (__DEV__) {
3181 + if (enableFragmentRefsTextNodes) {
3182 + let hasText = false;
3183 + let hasElement = false;
3184 + traverseFragmentInstance(this._fragmentFiber, (child: Fiber) => {
3185 + if (child.tag === HostText) {
3186 + hasText = true;
3187 + } else {
3188 + // Stop traversal, found element
3189 + hasElement = true;
3190 + return true;
3191 + }
3192 + return false;
3193 + });
3194 + if (hasText && !hasElement) {
3195 + console.error(
3196 + 'observeUsing() was called on a FragmentInstance with only text children. ' +
3197 + 'Observers do not work on text nodes.',
3198 + );
3199 + }
3200 + }
3201 + }
3202 if (this._observers === null) {
3203 this._observers = new Set();
3204 }
@@ -3179,6 +3209,12 @@ function observeChild(
3209 child: Fiber,
3210 observer: IntersectionObserver | ResizeObserver,
3211 ) {
3212 + if (enableFragmentRefsTextNodes) {
3213 + // Skip text nodes - observers don't work on them
3214 + if (child.tag === HostText) {
3215 + return false;
3216 + }
3217 + }
3218 const instance = getInstanceFromHostFiber<Instance>(child);
3219 observer.observe(instance);
3220 return false;
@@ -3205,6 +3241,12 @@ function unobserveChild(
3241 child: Fiber,
3242 observer: IntersectionObserver | ResizeObserver,
3243 ) {
3244 + if (enableFragmentRefsTextNodes) {
3245 + // Skip text nodes - they were never observed
3246 + if (child.tag === HostText) {
3247 + return false;
3248 + }
3249 + }
3250 const instance = getInstanceFromHostFiber<Instance>(child);
3251 observer.unobserve(instance);
3252 return false;
@@ -3218,9 +3260,17 @@ FragmentInstance.prototype.getClientRects = function (
3260 return rects;
3261 };
3262 function collectClientRects(child: Fiber, rects: Array<DOMRect>): boolean {
3221 - const instance = getInstanceFromHostFiber<Instance>(child);
3222 - // $FlowFixMe[method-unbinding]
3223 - rects.push.apply(rects, instance.getClientRects());
3263 + if (enableFragmentRefsTextNodes && child.tag === HostText) {
3264 + const textNode: Text = child.stateNode;
3265 + const range = textNode.ownerDocument.createRange();
3266 + range.selectNodeContents(textNode);
3267 + // $FlowFixMe[method-unbinding]
3268 + rects.push.apply(rects, range.getClientRects());
3269 + } else {
3270 + const instance = getInstanceFromHostFiber<Instance>(child);
3271 + // $FlowFixMe[method-unbinding]
3272 + rects.push.apply(rects, instance.getClientRects());
3273 + }
3274 return false;
3275 }
3276 // $FlowFixMe[prop-missing]
@@ -3426,6 +3476,19 @@ if (enableFragmentRefsScrollIntoView) {
3476 let i = resolvedAlignToTop ? children.length - 1 : 0;
3477 while (i !== (resolvedAlignToTop ? -1 : children.length)) {
3478 const child = children[i];
3479 + // For text nodes, use Range API to scroll to their position
3480 + if (enableFragmentRefsTextNodes && child.tag === HostText) {
3481 + const textNode: Text = child.stateNode;
3482 + const range = textNode.ownerDocument.createRange();
3483 + range.selectNodeContents(textNode);
3484 + const rect = range.getBoundingClientRect();
3485 + const scrollY = resolvedAlignToTop
3486 + ? window.scrollY + rect.top
3487 + : window.scrollY + rect.bottom - window.innerHeight;
3488 + window.scrollTo(window.scrollX + rect.left, scrollY);
3489 + i += resolvedAlignToTop ? -1 : 1;
3490 + continue;
3491 + }
3492 const instance = getInstanceFromHostFiber<Instance>(child);
3493 instance.scrollIntoView(alignToTop);
3494 i += resolvedAlignToTop ? -1 : 1;
packages/react-dom/src/__tests__/ReactDOMFragmentRefs-test.js
+167
@@ -20,6 +20,7 @@ let Activity;
20 let mockIntersectionObserver;
21 let simulateIntersection;
22 let setClientRects;
23 +let mockRangeClientRects;
24 let assertConsoleErrorDev;
25
26 function Wrapper({children}) {
@@ -40,6 +41,7 @@ describe('FragmentRefs', () => {
41 mockIntersectionObserver = IntersectionMocks.mockIntersectionObserver;
42 simulateIntersection = IntersectionMocks.simulateIntersection;
43 setClientRects = IntersectionMocks.setClientRects;
44 + mockRangeClientRects = IntersectionMocks.mockRangeClientRects;
45 assertConsoleErrorDev =
46 require('internal-test-utils').assertConsoleErrorDev;
47
@@ -2426,4 +2428,169 @@ describe('FragmentRefs', () => {
2428 });
2429 });
2430 });
2431 +
2432 + describe('with text nodes', () => {
2433 + // @gate enableFragmentRefs && enableFragmentRefsTextNodes
2434 + it('getClientRects includes text node bounds', async () => {
2435 + const restoreRange = mockRangeClientRects([
2436 + {x: 0, y: 0, width: 80, height: 16},
2437 + ]);
2438 + const fragmentRef = React.createRef();
2439 + const root = ReactDOMClient.createRoot(container);
2440 +
2441 + await act(() =>
2442 + root.render(
2443 + <div>
2444 + <Fragment ref={fragmentRef}>Hello World</Fragment>
2445 + </div>,
2446 + ),
2447 + );
2448 +
2449 + const rects = fragmentRef.current.getClientRects();
2450 + expect(rects.length).toBe(1);
2451 + expect(rects[0].width).toBe(80);
2452 + restoreRange();
2453 + });
2454 +
2455 + // @gate enableFragmentRefs && enableFragmentRefsTextNodes
2456 + it('getClientRects includes both text and element bounds', async () => {
2457 + const restoreRange = mockRangeClientRects([
2458 + {x: 0, y: 0, width: 60, height: 16},
2459 + ]);
2460 + const fragmentRef = React.createRef();
2461 + const childRef = React.createRef();
2462 + const root = ReactDOMClient.createRoot(container);
2463 +
2464 + await act(() =>
2465 + root.render(
2466 + <div>
2467 + <Fragment ref={fragmentRef}>
2468 + Text before
2469 + <div ref={childRef}>Element</div>
2470 + Text after
2471 + </Fragment>
2472 + </div>,
2473 + ),
2474 + );
2475 +
2476 + setClientRects(childRef.current, [
2477 + {x: 10, y: 10, width: 100, height: 20},
2478 + ]);
2479 + const rects = fragmentRef.current.getClientRects();
2480 + // Should have rects from 2 text nodes + 1 element = 3 total
2481 + expect(rects.length).toBe(3);
2482 + restoreRange();
2483 + });
2484 +
2485 + // @gate enableFragmentRefs
2486 + it('compareDocumentPosition works with text children', async () => {
2487 + const fragmentRef = React.createRef();
2488 + const beforeRef = React.createRef();
2489 + const root = ReactDOMClient.createRoot(container);
2490 +
2491 + await act(() =>
2492 + root.render(
2493 + <div>
2494 + <div ref={beforeRef} />
2495 + <Fragment ref={fragmentRef}>Text content</Fragment>
2496 + </div>,
2497 + ),
2498 + );
2499 +
2500 + const position = fragmentRef.current.compareDocumentPosition(
2501 + beforeRef.current,
2502 + );
2503 + expect(position & Node.DOCUMENT_POSITION_PRECEDING).toBeTruthy();
2504 + });
2505 +
2506 + // @gate enableFragmentRefs
2507 + it('focus is a no-op on text-only fragment', async () => {
2508 + const fragmentRef = React.createRef();
2509 + const root = ReactDOMClient.createRoot(container);
2510 +
2511 + await act(() =>
2512 + root.render(
2513 + <div>
2514 + <Fragment ref={fragmentRef}>Text only content</Fragment>
2515 + </div>,
2516 + ),
2517 + );
2518 +
2519 + // Should not throw or warn - just a silent no-op
2520 + fragmentRef.current.focus();
2521 + // Test passes if no error is thrown
2522 + });
2523 +
2524 + // @gate enableFragmentRefs
2525 + it('focusLast is a no-op on text-only fragment', async () => {
2526 + const fragmentRef = React.createRef();
2527 + const root = ReactDOMClient.createRoot(container);
2528 +
2529 + await act(() =>
2530 + root.render(
2531 + <div>
2532 + <Fragment ref={fragmentRef}>Text only content</Fragment>
2533 + </div>,
2534 + ),
2535 + );
2536 +
2537 + // Should not throw or warn - just a silent no-op
2538 + fragmentRef.current.focusLast();
2539 + });
2540 +
2541 + // @gate enableFragmentRefs && enableFragmentRefsTextNodes
2542 + it('warns when observeUsing is called on text-only fragment', async () => {
2543 + mockIntersectionObserver();
2544 + const fragmentRef = React.createRef();
2545 + const root = ReactDOMClient.createRoot(container);
2546 +
2547 + await act(() =>
2548 + root.render(
2549 + <div>
2550 + <Fragment ref={fragmentRef}>Text only content</Fragment>
2551 + </div>,
2552 + ),
2553 + );
2554 +
2555 + const observer = new IntersectionObserver(() => {});
2556 + fragmentRef.current.observeUsing(observer);
2557 + assertConsoleErrorDev(
2558 + [
2559 + 'observeUsing() was called on a FragmentInstance with only text children. ' +
2560 + 'Observers do not work on text nodes.',
2561 + ],
2562 + {withoutStack: true},
2563 + );
2564 + });
2565 +
2566 + // @gate enableFragmentRefs && enableFragmentRefsScrollIntoView
2567 + it('scrollIntoView works on text-only fragment using Range API', async () => {
2568 + const restoreRange = mockRangeClientRects([
2569 + {x: 100, y: 200, width: 80, height: 16},
2570 + ]);
2571 + const fragmentRef = React.createRef();
2572 + const root = ReactDOMClient.createRoot(container);
2573 +
2574 + await act(() =>
2575 + root.render(
2576 + <div>
2577 + <Fragment ref={fragmentRef}>Text content</Fragment>
2578 + </div>,
2579 + ),
2580 + );
2581 +
2582 + // Mock window.scrollTo to verify it was called
2583 + const originalScrollTo = window.scrollTo;
2584 + const scrollToMock = jest.fn();
2585 + window.scrollTo = scrollToMock;
2586 +
2587 + fragmentRef.current.scrollIntoView();
2588 +
2589 + // Should have called window.scrollTo for the text node
2590 + expect(scrollToMock).toHaveBeenCalled();
2591 +
2592 + window.scrollTo = originalScrollTo;
2593 + restoreRange();
2594 + });
2595 + });
2596 });
packages/react-dom/src/__tests__/utils/IntersectionMocks.js
+55
@@ -93,3 +93,58 @@ export function setClientRects(target, rects) {
93 }));
94 };
95 }
96 +
97 +/**
98 + * Mock Range.prototype.getClientRects and getBoundingClientRect since jsdom doesn't implement them.
99 + * Call this in beforeEach to set up the mock.
100 + */
101 +export function mockRangeClientRects(
102 + rects = [{x: 0, y: 0, width: 100, height: 20}],
103 +) {
104 + const originalCreateRange = document.createRange;
105 + document.createRange = function () {
106 + const range = originalCreateRange.call(document);
107 + range.getClientRects = function () {
108 + return rects.map(({x, y, width, height}) => ({
109 + width,
110 + height,
111 + left: x,
112 + right: x + width,
113 + top: y,
114 + bottom: y + height,
115 + x,
116 + y,
117 + }));
118 + };
119 + range.getBoundingClientRect = function () {
120 + // Return the bounding rect that encompasses all rects
121 + if (rects.length === 0) {
122 + return {
123 + width: 0,
124 + height: 0,
125 + left: 0,
126 + right: 0,
127 + top: 0,
128 + bottom: 0,
129 + x: 0,
130 + y: 0,
131 + };
132 + }
133 + const first = rects[0];
134 + return {
135 + width: first.width,
136 + height: first.height,
137 + left: first.x,
138 + right: first.x + first.width,
139 + top: first.y,
140 + bottom: first.y + first.height,
141 + x: first.x,
142 + y: first.y,
143 + };
144 + };
145 + return range;
146 + };
147 + return function restore() {
148 + document.createRange = originalCreateRange;
149 + };
150 +}
packages/react-reconciler/src/ReactFiberTreeReflection.js
+6 -1
@@ -29,6 +29,7 @@ import {
29 Fragment,
30 } from './ReactWorkTags';
31 import {NoFlags, Placement, Hydrating} from './ReactFiberFlags';
32 +import {enableFragmentRefsTextNodes} from 'shared/ReactFeatureFlags';
33
34 export function getNearestMountedFiber(fiber: Fiber): null | Fiber {
35 let node = fiber;
@@ -373,7 +374,10 @@ function traverseVisibleHostChildren<A, B, C>(
374 c: C,
375 ): boolean {
376 while (child !== null) {
376 - if (child.tag === HostComponent && fn(child, a, b, c)) {
377 + const isHostNode =
378 + child.tag === HostComponent ||
379 + (enableFragmentRefsTextNodes && child.tag === HostText);
380 + if (isHostNode && fn(child, a, b, c)) {
381 return true;
382 } else if (
383 child.tag === OffscreenComponent &&
@@ -473,6 +477,7 @@ function findFragmentInstanceSiblings(
477 export function getInstanceFromHostFiber<I>(fiber: Fiber): I {
478 switch (fiber.tag) {
479 case HostComponent:
480 + case HostText:
481 return fiber.stateNode;
482 case HostRoot:
483 return fiber.stateNode.containerInfo;
packages/shared/ReactFeatureFlags.js
+1
@@ -144,6 +144,7 @@ export const enableInfiniteRenderLoopDetection: boolean = false;
144 export const enableFragmentRefs: boolean = true;
145 export const enableFragmentRefsScrollIntoView: boolean = true;
146 export const enableFragmentRefsInstanceHandles: boolean = false;
147 +export const enableFragmentRefsTextNodes: boolean = true;
148
149 export const enableInternalInstanceMap: boolean = false;
150
packages/shared/forks/ReactFeatureFlags.native-fb-dynamic.js
+1
@@ -25,3 +25,4 @@ export const passChildrenWhenCloningPersistedNodes = __VARIANT__;
25 export const enableFragmentRefs = __VARIANT__;
26 export const enableFragmentRefsScrollIntoView = __VARIANT__;
27 export const enableFragmentRefsInstanceHandles = __VARIANT__;
28 +export const enableFragmentRefsTextNodes = __VARIANT__;
packages/shared/forks/ReactFeatureFlags.native-fb.js
+1
@@ -27,6 +27,7 @@ export const {
27 enableFragmentRefs,
28 enableFragmentRefsScrollIntoView,
29 enableFragmentRefsInstanceHandles,
30 + enableFragmentRefsTextNodes,
31 } = dynamicFlags;
32
33 // The rest of the flags are static for better dead code elimination.
packages/shared/forks/ReactFeatureFlags.native-oss.js
+1
@@ -72,6 +72,7 @@ export const ownerStackLimit = 1e4;
72 export const enableFragmentRefs: boolean = true;
73 export const enableFragmentRefsScrollIntoView: boolean = false;
74 export const enableFragmentRefsInstanceHandles: boolean = false;
75 +export const enableFragmentRefsTextNodes: boolean = false;
76
77 export const enableInternalInstanceMap: boolean = false;
78
packages/shared/forks/ReactFeatureFlags.test-renderer.js
+1
@@ -73,6 +73,7 @@ export const ownerStackLimit = 1e4;
73 export const enableFragmentRefs: boolean = true;
74 export const enableFragmentRefsScrollIntoView: boolean = true;
75 export const enableFragmentRefsInstanceHandles: boolean = false;
76 +export const enableFragmentRefsTextNodes: boolean = true;
77
78 export const enableInternalInstanceMap: boolean = false;
79
packages/shared/forks/ReactFeatureFlags.test-renderer.native-fb.js
+2
@@ -65,6 +65,8 @@ export const enableHydrationChangeEvent = false;
65 export const enableDefaultTransitionIndicator = true;
66 export const enableFragmentRefs = false;
67 export const enableFragmentRefsScrollIntoView = false;
68 +export const enableFragmentRefsInstanceHandles = false;
69 +export const enableFragmentRefsTextNodes = false;
70 export const ownerStackLimit = 1e4;
71 export const enableOptimisticKey = false;
72
packages/shared/forks/ReactFeatureFlags.test-renderer.www.js
+1
@@ -78,6 +78,7 @@ export const enableDefaultTransitionIndicator: boolean = true;
78 export const enableFragmentRefs: boolean = false;
79 export const enableFragmentRefsScrollIntoView: boolean = false;
80 export const enableFragmentRefsInstanceHandles: boolean = false;
81 +export const enableFragmentRefsTextNodes: boolean = false;
82 export const ownerStackLimit = 1e4;
83
84 export const enableInternalInstanceMap: boolean = false;
packages/shared/forks/ReactFeatureFlags.www-dynamic.js
+1
@@ -34,6 +34,7 @@ export const enableViewTransition: boolean = __VARIANT__;
34 export const enableScrollEndPolyfill: boolean = __VARIANT__;
35 export const enableFragmentRefs: boolean = __VARIANT__;
36 export const enableFragmentRefsScrollIntoView: boolean = __VARIANT__;
37 +export const enableFragmentRefsTextNodes: boolean = __VARIANT__;
38 export const enableAsyncDebugInfo: boolean = __VARIANT__;
39 export const enableInternalInstanceMap: boolean = __VARIANT__;
40 export const enableTrustedTypesIntegration: boolean = __VARIANT__;
packages/shared/forks/ReactFeatureFlags.www.js
+1
@@ -32,6 +32,7 @@ export const {
32 enableScrollEndPolyfill,
33 enableFragmentRefs,
34 enableFragmentRefsScrollIntoView,
35 + enableFragmentRefsTextNodes,
36 enableAsyncDebugInfo,
37 enableInternalInstanceMap,
38 } = dynamicFeatureFlags;