Skip to content

Commit 66c2850

Browse files
authored
Fix focusscope restore logic (#5131)
* FocusScope build tree using effects in the correct order
1 parent b98c0fd commit 66c2850

File tree

3 files changed

+83
-32
lines changed

3 files changed

+83
-32
lines changed

packages/@react-aria/focus/src/FocusScope.tsx

Lines changed: 30 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -131,43 +131,42 @@ export function FocusScope(props: FocusScopeProps) {
131131
useRestoreFocus(scopeRef, restoreFocus, contain);
132132
useAutoFocus(scopeRef, autoFocus);
133133

134-
// this layout effect needs to run last so that focusScopeTree cleanup happens at the last moment possible
134+
// This needs to be an effect so that activeScope is updated after the FocusScope tree is complete.
135+
// It cannot be a useLayoutEffect because the parent of this node hasn't been attached in the tree yet.
135136
useEffect(() => {
136-
if (scopeRef) {
137-
let activeElement = document.activeElement;
138-
let scope: TreeNode | null = null;
139-
// In strict mode, active scope is incorrectly updated since cleanup will run even though scope hasn't unmounted.
140-
// To fix this, we need to update the actual activeScope here
141-
if (isElementInScope(activeElement, scopeRef.current)) {
142-
// Since useLayoutEffect runs for children first, we need to traverse the focusScope tree and find the bottom most scope that
143-
// contains the active element and set that as the activeScope
144-
for (let node of focusScopeTree.traverse()) {
145-
if (node.scopeRef && isElementInScope(activeElement, node.scopeRef.current)) {
146-
scope = node;
147-
}
137+
let activeElement = document.activeElement;
138+
let scope: TreeNode | null = null;
139+
140+
if (isElementInScope(activeElement, scopeRef.current)) {
141+
// We need to traverse the focusScope tree and find the bottom most scope that
142+
// contains the active element and set that as the activeScope.
143+
for (let node of focusScopeTree.traverse()) {
144+
if (node.scopeRef && isElementInScope(activeElement, node.scopeRef.current)) {
145+
scope = node;
148146
}
147+
}
149148

150-
if (scope === focusScopeTree.getTreeNode(scopeRef)) {
151-
activeScope = scope.scopeRef;
152-
}
149+
if (scope === focusScopeTree.getTreeNode(scopeRef)) {
150+
activeScope = scope.scopeRef;
153151
}
152+
}
153+
}, [scopeRef]);
154154

155-
return () => {
156-
// Scope may have been re-parented.
157-
let parentScope = focusScopeTree.getTreeNode(scopeRef)?.parent?.scopeRef ?? null;
155+
// This layout effect cleanup is so that the tree node is removed synchronously with react before the RAF
156+
// in useRestoreFocus cleanup runs.
157+
useLayoutEffect(() => {
158+
return () => {
159+
// Scope may have been re-parented.
160+
let parentScope = focusScopeTree.getTreeNode(scopeRef)?.parent?.scopeRef ?? null;
158161

159-
// Restore the active scope on unmount if this scope or a descendant scope is active.
160-
// Parent effect cleanups run before children, so we need to check if the
161-
// parent scope actually still exists before restoring the active scope to it.
162-
if (
163-
(scopeRef === activeScope || isAncestorScope(scopeRef, activeScope)) &&
164-
(!parentScope || focusScopeTree.getTreeNode(parentScope))
165-
) {
166-
activeScope = parentScope;
167-
}
168-
focusScopeTree.removeTreeNode(scopeRef);
169-
};
170-
}
162+
if (
163+
(scopeRef === activeScope || isAncestorScope(scopeRef, activeScope)) &&
164+
(!parentScope || focusScopeTree.getTreeNode(parentScope))
165+
) {
166+
activeScope = parentScope;
167+
}
168+
focusScopeTree.removeTreeNode(scopeRef);
169+
};
171170
}, [scopeRef]);
172171

173172
let focusManager = useMemo(() => createFocusManagerForScope(scopeRef), []);

packages/@react-spectrum/list/stories/ListView.stories.tsx

Lines changed: 52 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,17 @@
11
import {action} from '@storybook/addon-actions';
22
import {ActionBar, ActionBarContainer} from '@react-spectrum/actionbar';
3-
import {ActionButton} from '@react-spectrum/button';
3+
import {ActionButton, Button} from '@react-spectrum/button';
44
import {ActionGroup} from '@react-spectrum/actiongroup';
55
import {ComponentMeta, ComponentStoryObj} from '@storybook/react';
66
import {Content} from '@react-spectrum/view';
77
import Copy from '@spectrum-icons/workflow/Copy';
88
import Delete from '@spectrum-icons/workflow/Delete';
9+
import {Dialog, DialogTrigger} from '@react-spectrum/dialog';
10+
import {Divider} from '@react-spectrum/divider';
911
import Edit from '@spectrum-icons/workflow/Edit';
1012
import File from '@spectrum-icons/illustrations/File';
1113
import {Flex} from '@react-spectrum/layout';
14+
import {FocusScope} from '@react-aria/focus';
1215
import Folder from '@spectrum-icons/illustrations/Folder';
1316
import {Heading, Text} from '@react-spectrum/text';
1417
import {IllustratedMessage} from '@react-spectrum/illustratedmessage';
@@ -466,3 +469,51 @@ function EmptyTest() {
466469
</div>
467470
);
468471
}
472+
473+
export const RemoveListItems = {
474+
render: (args) => (
475+
<Demo {...args} />
476+
)
477+
};
478+
479+
function Demo(props) {
480+
let [items, setItems] = useState<{key: number, label: string}[]>([
481+
{key: 1, label: 'utilities'}
482+
]);
483+
let onDelete = (key) => {
484+
setItems(prevItems => prevItems.filter((item) => item.key !== key));
485+
};
486+
return (
487+
<ListView
488+
selectionMode="multiple"
489+
maxWidth="size-6000"
490+
items={items}
491+
height="300px"
492+
width="250px"
493+
aria-label="ListView example with complex items"
494+
{...props}>
495+
{(item: {key: number, label: string}) => {
496+
return (
497+
<Item key={item.key} textValue={item.label}>
498+
<Text>{item.label}</Text>
499+
<DialogTrigger type="popover">
500+
<ActionButton>Delete</ActionButton>
501+
<Dialog>
502+
<Heading>Warning, cannot undo</Heading>
503+
<Divider />
504+
<Content>
505+
<Text>Are you sure?</Text>
506+
<FocusScope>
507+
<Button variant="accent" onPressStart={() => onDelete(item.key)}>
508+
Delete
509+
</Button>
510+
</FocusScope>
511+
</Content>
512+
</Dialog>
513+
</DialogTrigger>
514+
</Item>
515+
);
516+
}}
517+
</ListView>
518+
);
519+
}

packages/@react-spectrum/list/test/ListView.test.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
* governing permissions and limitations under the License.
1111
*/
1212

13+
1314
jest.mock('@react-aria/live-announcer');
1415
import {act, fireEvent, installPointerEvent, pointerMap, render as renderComponent, triggerPress, within} from '@react-spectrum/test-utils';
1516
import {ActionButton} from '@react-spectrum/button';

0 commit comments

Comments
 (0)