@samitouri / QOS-React / commits / 4206fe4982

Allow fragment refs to attempt focus/focusLast on nested host children (#33058)

This enables `focus` and `focusLast` methods on FragmentInstances to search nested host components, depth first. Attempts focus on each child and bails if one is successful. Previously, only the first level of host children would attempt focus. Now if we have an example like ``` component MenuItem() { return (<div><a>{...}</a></div>) } component Menu() { return <Fragment>{items.map(i => <MenuItem i={i} />)}</Fragment> } ``` We can target focus on the first or last a tag, rather than checking each wrapping div and then noop.

Jack Pope committed May 7, 2025 at 12:47 UTC 4206fe49825787eda57a5d142640a63772ccbf2b
5 files changed +88 -23
fixtures/dom/src/components/fixtures/fragment-refs/FocusCase.js
+10 -3
@@ -43,11 +43,18 @@ export default function FocusCase() {
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>
47 - <button>Button 1</button>
46 + <div style={{outline: '1px solid black'}}>
47 + <p>Unfocusable div</p>
48 + </div>
49 + <div style={{outline: '1px solid black'}}>
50 + <p>Unfocusable div with nested focusable button</p>
51 + <button>Button 1</button>
52 + </div>
53 <button>Button 2</button>
54 <input type="text" placeholder="Input field" />
50 - <div style={{outline: '1px solid black'}}>Unfocusable div</div>
55 + <div style={{outline: '1px solid black'}}>
56 + <p>Unfocusable div</p>
57 + </div>
58 </Fragment>
59 </div>
60 </Fixture>
fixtures/dom/src/style.css
+4
@@ -365,6 +365,10 @@ tbody tr:nth-child(even) {
365 background-color: green;
366 }
367
368 +.highlight-focused-children * {
369 + margin-left: 10px;
370 +}
371 +
372 .highlight-focused-children *:focus {
373 outline: 2px solid green;
374 }
packages/react-dom-bindings/src/client/ReactFiberConfigDOM.js
+7 -2
@@ -68,6 +68,7 @@ import {
68 getFragmentParentHostFiber,
69 getNextSiblingHostFiber,
70 getInstanceFromHostFiber,
71 + traverseFragmentInstanceDeeply,
72 } from 'react-reconciler/src/ReactFiberTreeReflection';
73
74 export {detachDeletedInstance};
@@ -2698,7 +2699,7 @@ FragmentInstance.prototype.focus = function (
2699 this: FragmentInstanceType,
2700 focusOptions?: FocusOptions,
2701 ): void {
2701 - traverseFragmentInstance(
2702 + traverseFragmentInstanceDeeply(
2703 this._fragmentFiber,
2704 setFocusOnFiberIfFocusable,
2705 focusOptions,
@@ -2717,7 +2718,11 @@ FragmentInstance.prototype.focusLast = function (
2718 focusOptions?: FocusOptions,
2719 ): void {
2720 const children: Array<Fiber> = [];
2720 - traverseFragmentInstance(this._fragmentFiber, collectChildren, children);
2721 + traverseFragmentInstanceDeeply(
2722 + this._fragmentFiber,
2723 + collectChildren,
2724 + children,
2725 + );
2726 for (let i = children.length - 1; i >= 0; i--) {
2727 const child = children[i];
2728 if (setFocusOnFiberIfFocusable(child, focusOptions)) {
packages/react-dom/src/__tests__/ReactDOMFragmentRefs-test.js
+54
@@ -145,6 +145,32 @@ describe('FragmentRefs', () => {
145 document.activeElement.blur();
146 });
147
148 + // @gate enableFragmentRefs
149 + it('focuses deeply nested focusable children, depth first', async () => {
150 + const fragmentRef = React.createRef();
151 + const root = ReactDOMClient.createRoot(container);
152 +
153 + function Test() {
154 + return (
155 + <Fragment ref={fragmentRef}>
156 + <div id="child-a">
157 + <div tabIndex={0} id="grandchild-a">
158 + <a id="greatgrandchild-a" href="/" />
159 + </div>
160 + </div>
161 + <a id="child-b" href="/" />
162 + </Fragment>
163 + );
164 + }
165 + await act(() => {
166 + root.render(<Test />);
167 + });
168 + await act(() => {
169 + fragmentRef.current.focus();
170 + });
171 + expect(document.activeElement.id).toEqual('grandchild-a');
172 + });
173 +
174 // @gate enableFragmentRefs
175 it('preserves document order when adding and removing children', async () => {
176 const fragmentRef = React.createRef();
@@ -228,6 +254,34 @@ describe('FragmentRefs', () => {
254 expect(document.activeElement.id).toEqual('child-c');
255 document.activeElement.blur();
256 });
257 +
258 + // @gate enableFragmentRefs
259 + it('focuses deeply nested focusable children, depth first', async () => {
260 + const fragmentRef = React.createRef();
261 + const root = ReactDOMClient.createRoot(container);
262 +
263 + function Test() {
264 + return (
265 + <Fragment ref={fragmentRef}>
266 + <div id="child-a" href="/">
267 + <a id="grandchild-a" href="/" />
268 + <a id="grandchild-b" href="/" />
269 + </div>
270 + <div tabIndex={0} id="child-b">
271 + <a id="grandchild-a" href="/" />
272 + <a id="grandchild-b" href="/" />
273 + </div>
274 + </Fragment>
275 + );
276 + }
277 + await act(() => {
278 + root.render(<Test />);
279 + });
280 + await act(() => {
281 + fragmentRef.current.focusLast();
282 + });
283 + expect(document.activeElement.id).toEqual('grandchild-b');
284 + });
285 });
286
287 describe('blur()', () => {
packages/react-reconciler/src/ReactFiberTreeReflection.js
+13 -18
@@ -354,6 +354,16 @@ export function traverseFragmentInstance<A, B, C>(
354 traverseVisibleHostChildren(fragmentFiber.child, false, fn, a, b, c);
355 }
356
357 +export function traverseFragmentInstanceDeeply<A, B, C>(
358 + fragmentFiber: Fiber,
359 + fn: (Fiber, A, B, C) => boolean,
360 + a: A,
361 + b: B,
362 + c: C,
363 +): void {
364 + traverseVisibleHostChildren(fragmentFiber.child, true, fn, a, b, c);
365 +}
366 +
367 function traverseVisibleHostChildren<A, B, C>(
368 child: Fiber | null,
369 searchWithinHosts: boolean,
@@ -363,24 +373,8 @@ function traverseVisibleHostChildren<A, B, C>(
373 c: C,
374 ): boolean {
375 while (child !== null) {
366 - if (child.tag === HostComponent) {
367 - if (fn(child, a, b, c)) {
368 - return true;
369 - }
370 - if (searchWithinHosts) {
371 - if (
372 - traverseVisibleHostChildren(
373 - child.child,
374 - searchWithinHosts,
375 - fn,
376 - a,
377 - b,
378 - c,
379 - )
380 - ) {
381 - return true;
382 - }
383 - }
376 + if (child.tag === HostComponent && fn(child, a, b, c)) {
377 + return true;
378 } else if (
379 child.tag === OffscreenComponent &&
380 child.memoizedState !== null
@@ -388,6 +382,7 @@ function traverseVisibleHostChildren<A, B, C>(
382 // Skip hidden subtrees
383 } else {
384 if (
385 + (searchWithinHosts || child.tag !== HostComponent) &&
386 traverseVisibleHostChildren(child.child, searchWithinHosts, fn, a, b, c)
387 ) {
388 return true;