From 0daf77b33e187ae00cde9ca67629e43d9dfb2db3 Mon Sep 17 00:00:00 2001 From: Jack Pope Date: Tue, 29 Apr 2025 15:58:00 -0400 Subject: [PATCH] Allow fragment refs to attempt focus/focusLast on nested host children --- .../fixtures/fragment-refs/FocusCase.js | 13 +++-- fixtures/dom/src/style.css | 4 ++ .../src/client/ReactFiberConfigDOM.js | 9 +++- .../__tests__/ReactDOMFragmentRefs-test.js | 54 +++++++++++++++++++ .../src/ReactFiberTreeReflection.js | 31 +++++------ 5 files changed, 88 insertions(+), 23 deletions(-) diff --git a/fixtures/dom/src/components/fixtures/fragment-refs/FocusCase.js b/fixtures/dom/src/components/fixtures/fragment-refs/FocusCase.js index 888107c9e07c7..baff30895c0e0 100644 --- a/fixtures/dom/src/components/fixtures/fragment-refs/FocusCase.js +++ b/fixtures/dom/src/components/fixtures/fragment-refs/FocusCase.js @@ -43,11 +43,18 @@ export default function FocusCase() {
-
Unfocusable div
- +
+

Unfocusable div

+
+
+

Unfocusable div with nested focusable button

+ +
-
Unfocusable div
+
+

Unfocusable div

+
diff --git a/fixtures/dom/src/style.css b/fixtures/dom/src/style.css index 66fda7afe0cac..e507014d6829d 100644 --- a/fixtures/dom/src/style.css +++ b/fixtures/dom/src/style.css @@ -365,6 +365,10 @@ tbody tr:nth-child(even) { background-color: green; } +.highlight-focused-children * { + margin-left: 10px; +} + .highlight-focused-children *:focus { outline: 2px solid green; } diff --git a/packages/react-dom-bindings/src/client/ReactFiberConfigDOM.js b/packages/react-dom-bindings/src/client/ReactFiberConfigDOM.js index 8584b644eff9d..1eabf7a19a90b 100644 --- a/packages/react-dom-bindings/src/client/ReactFiberConfigDOM.js +++ b/packages/react-dom-bindings/src/client/ReactFiberConfigDOM.js @@ -68,6 +68,7 @@ import { getFragmentParentHostFiber, getNextSiblingHostFiber, getInstanceFromHostFiber, + traverseFragmentInstanceDeeply, } from 'react-reconciler/src/ReactFiberTreeReflection'; export {detachDeletedInstance}; @@ -2698,7 +2699,7 @@ FragmentInstance.prototype.focus = function ( this: FragmentInstanceType, focusOptions?: FocusOptions, ): void { - traverseFragmentInstance( + traverseFragmentInstanceDeeply( this._fragmentFiber, setFocusOnFiberIfFocusable, focusOptions, @@ -2717,7 +2718,11 @@ FragmentInstance.prototype.focusLast = function ( focusOptions?: FocusOptions, ): void { const children: Array = []; - traverseFragmentInstance(this._fragmentFiber, collectChildren, children); + traverseFragmentInstanceDeeply( + this._fragmentFiber, + collectChildren, + children, + ); for (let i = children.length - 1; i >= 0; i--) { const child = children[i]; if (setFocusOnFiberIfFocusable(child, focusOptions)) { diff --git a/packages/react-dom/src/__tests__/ReactDOMFragmentRefs-test.js b/packages/react-dom/src/__tests__/ReactDOMFragmentRefs-test.js index 50447e1eac677..6eb1fb3a0f346 100644 --- a/packages/react-dom/src/__tests__/ReactDOMFragmentRefs-test.js +++ b/packages/react-dom/src/__tests__/ReactDOMFragmentRefs-test.js @@ -145,6 +145,32 @@ describe('FragmentRefs', () => { document.activeElement.blur(); }); + // @gate enableFragmentRefs + it('focuses deeply nested focusable children, depth first', async () => { + const fragmentRef = React.createRef(); + const root = ReactDOMClient.createRoot(container); + + function Test() { + return ( + + + + + ); + } + await act(() => { + root.render(); + }); + await act(() => { + fragmentRef.current.focus(); + }); + expect(document.activeElement.id).toEqual('grandchild-a'); + }); + // @gate enableFragmentRefs it('preserves document order when adding and removing children', async () => { const fragmentRef = React.createRef(); @@ -228,6 +254,34 @@ describe('FragmentRefs', () => { expect(document.activeElement.id).toEqual('child-c'); document.activeElement.blur(); }); + + // @gate enableFragmentRefs + it('focuses deeply nested focusable children, depth first', async () => { + const fragmentRef = React.createRef(); + const root = ReactDOMClient.createRoot(container); + + function Test() { + return ( + + + + + ); + } + await act(() => { + root.render(); + }); + await act(() => { + fragmentRef.current.focusLast(); + }); + expect(document.activeElement.id).toEqual('grandchild-b'); + }); }); describe('blur()', () => { diff --git a/packages/react-reconciler/src/ReactFiberTreeReflection.js b/packages/react-reconciler/src/ReactFiberTreeReflection.js index d032d3247e475..45da707dfea23 100644 --- a/packages/react-reconciler/src/ReactFiberTreeReflection.js +++ b/packages/react-reconciler/src/ReactFiberTreeReflection.js @@ -354,6 +354,16 @@ export function traverseFragmentInstance( traverseVisibleHostChildren(fragmentFiber.child, false, fn, a, b, c); } +export function traverseFragmentInstanceDeeply( + fragmentFiber: Fiber, + fn: (Fiber, A, B, C) => boolean, + a: A, + b: B, + c: C, +): void { + traverseVisibleHostChildren(fragmentFiber.child, true, fn, a, b, c); +} + function traverseVisibleHostChildren( child: Fiber | null, searchWithinHosts: boolean, @@ -363,24 +373,8 @@ function traverseVisibleHostChildren( c: C, ): boolean { while (child !== null) { - if (child.tag === HostComponent) { - if (fn(child, a, b, c)) { - return true; - } - if (searchWithinHosts) { - if ( - traverseVisibleHostChildren( - child.child, - searchWithinHosts, - fn, - a, - b, - c, - ) - ) { - return true; - } - } + if (child.tag === HostComponent && fn(child, a, b, c)) { + return true; } else if ( child.tag === OffscreenComponent && child.memoizedState !== null @@ -388,6 +382,7 @@ function traverseVisibleHostChildren( // Skip hidden subtrees } else { if ( + (searchWithinHosts || child.tag !== HostComponent) && traverseVisibleHostChildren(child.child, searchWithinHosts, fn, a, b, c) ) { return true;