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;